Introduce timetracker/config.py with a single config() helper that resolves settings from a fixed priority chain: NAME__FILE (opt-in secret) -> env var -> .env -> settings.ini -> in-code default. Supports type casting (bool/list/int/Path), file-based secrets with .strip(), and required_in_prod validation. Migrate settings.py off the previous ad-hoc idioms: - DEBUG via config() (PROD kept as deprecated alias) - SECRET_KEY required in prod, supports SECRET_KEY__FILE - APP_URL derives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS (kept separate, each independently overridable); ALLOWED_HOSTS is now configurable - TZ and DATA_DIR via config() Fix DATA_DIR inconsistency: entrypoint.sh now reads DATA_DIR (was hardcoded) so the bash bootstrap and Django agree on the database directory. Document the container/entrypoint-only flags (PUID/PGID/ CREATE_DEFAULT_SUPERUSER/STAGING/LOAD_SAMPLE_DATA) as bash concerns. Update deployment configs to set APP_URL (and DEBUG), add docs/configuration.md, settings.ini.example, regrouped .env.example, CLAUDE.md, and tests. https://claude.ai/code/session_01FFn8BiGrQpEJarC8xGse8s
5.6 KiB
Configuration
All configurable Django settings are read through a single helper,
config() in timetracker/config.py. It resolves
each value from a fixed chain of sources so the same setting can come from an
environment variable, a .env file, an .ini file, or a built-in default —
without any per-setting special-casing in settings.py.
Resolution priority
For a setting named NAME, the first source that provides a value wins:
| Priority | Source | Notes |
|---|---|---|
| 1 | NAME__FILE env var |
Path to a file; its stripped contents are the value. Opt-in per setting (allow_file=True). For Docker/Kubernetes secrets. |
| 2 | NAME env var |
A real process environment variable. |
| 3 | .env file |
KEY=value lines (see .env syntax). |
| 4 | settings.ini file |
The [timetracker] section, parsed with configparser. |
| 5 | default |
The in-code fallback in settings.py. |
If no source supplies a value and no default is defined, startup fails with
ImproperlyConfigured rather than silently using an empty value.
Worked example. With VALUE set in the environment and in .env and
in settings.ini, the environment variable wins. Remove it and .env wins;
remove that and settings.ini wins; remove that and the code default applies.
Settings reference
| Setting | Cast | Default | __FILE? |
Description |
|---|---|---|---|---|
SECRET_KEY |
str | insecure dev key | yes | Django secret key. Required in production (DEBUG off) — a missing value is a hard error, not a silent insecure fallback. |
DEBUG |
bool | true (dev) |
no | Debug mode. Turn off in production. Defaults on for local development. |
APP_URL |
str | http://localhost:8000 |
no | Public URL of the site. Derives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS when those are not set explicitly. |
ALLOWED_HOSTS |
list | derived from APP_URL |
no | Comma-separated hostnames. Overrides the APP_URL derivation. |
CSRF_TRUSTED_ORIGINS |
list | derived from APP_URL |
no | Comma-separated full origins (https://host). Overrides the APP_URL derivation. |
TZ |
str | Europe/Prague (dev) / UTC (prod) |
no | Time zone. |
DATA_DIR |
path | project root | no | Directory holding the SQLite database. Also read by entrypoint.sh. |
cast understands bool (true/1/yes/on → True), list (comma-separated,
whitespace-trimmed, empty items dropped), int, Path, or any callable.
APP_URL, ALLOWED_HOSTS and CSRF
ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS guard different things — the Host
header versus cross-origin requests — so they are never merged. For the
common case you set only APP_URL and both are derived:
APP_URL=https://tracker.example.com
# -> ALLOWED_HOSTS = ["tracker.example.com"]
# -> CSRF_TRUSTED_ORIGINS = ["https://tracker.example.com"]
Power users override either independently. A typical reverse-proxy setup:
ALLOWED_HOSTS=*
CSRF_TRUSTED_ORIGINS=https://tracker.example.com
Secrets and __FILE
Secret managers (Docker secrets, Kubernetes) mount secrets as files. For any
setting that opts in (currently SECRET_KEY), point a *__FILE variable at
the mounted path:
SECRET_KEY__FILE=/run/secrets/timetracker_secret_key
The file contents are read and .strip()-ed. The strip matters: editors and
echo often append a trailing newline, and a stray \n inside SECRET_KEY
would silently invalidate every signed cookie/token when the file is recreated
without it.
.env syntax
# full-line comment
KEY=value
export KEY=value # optional leading "export"
QUOTED="value with spaces" # surrounding quotes are stripped
SINGLE='also fine'
WITH_HASH="a # b" # '#' inside quotes is literal
INLINE=value # trailing comment after an unquoted value is dropped
Deliberately not supported (documented limits, not bugs):
- variable interpolation (
${OTHER}) - multiline values
File locations default to .env and settings.ini at the project root and
can be moved with the ENV_FILE / INI_FILE environment variables. Missing
files are ignored, so env-only deployments need neither. A .env file used by
docker-compose for ${VAR} substitution is the same file Django reads in
local development; inside the container, real environment variables apply.
See .env.example and
settings.ini.example for starting points.
Container / entrypoint-only variables
These are consumed by entrypoint.sh during container
bootstrap, not by Django. They are intentionally not part of the Python
config — moving them there would buy nothing and force a bash↔Python bridge.
| Variable | Default | Purpose |
|---|---|---|
PUID / PGID |
1000 / 100 |
uid/gid the container process runs as. |
DATA_DIR |
/home/timetracker/app/data |
Database directory. Shared with Django via the same env var + matching default. |
CREATE_DEFAULT_SUPERUSER |
false |
Create an admin/admin superuser on first start. |
STAGING |
false |
Scrub copied sessions / django-q schedule on staging. |
LOAD_SAMPLE_DATA |
false |
Seed sample fixtures when the database is empty. |
Migrating from the old config
PROD=1→DEBUG=false.PRODstill works as a deprecated alias for one release and emits aDeprecationWarning.ALLOWED_HOSTSis now configurable (it was previously hard-coded to*). After upgrading, setAPP_URL(orALLOWED_HOSTSexplicitly) or the host will be rejected. Reverse-proxy deployments that relied on*should setALLOWED_HOSTS=*.