Files
timetracker/docs/configuration.md
T
Claude d8558eca89 Add unified config system (issue #24)
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
2026-06-14 19:51:29 +00:00

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/onTrue), 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=1DEBUG=false. PROD still works as a deprecated alias for one release and emits a DeprecationWarning.
  • ALLOWED_HOSTS is now configurable (it was previously hard-coded to *). After upgrading, set APP_URL (or ALLOWED_HOSTS explicitly) or the host will be rejected. Reverse-proxy deployments that relied on * should set ALLOWED_HOSTS=*.