Skip to content

Environment variables#

Every runtime knob is an environment variable read from .env (or the host environment) at container start. This page enumerates them: what each does, what the safe default is, and when you'd want to change it.

The authoritative reference is .env.example at the repo root. The defaults here reflect that file as of the current release; if .env.example changes, that file wins.

How variables flow#

flowchart LR
    F[.env file<br/>at repo root]
    PG[postgres container]
    MIG[migrate container]
    BE[backend container]
    FE[frontend container]

    F -- env_file --> PG
    F -- env_file --> MIG
    F -- env_file --> BE
    F -- env_file --> FE

docker-compose.yml declares env_file: .env on each service, which loads every variable from the file into the container's environment. Some variables are also passed explicitly via environment: blocks — that's a readability aid, not a behavior change.

A handful of variables are computed: DATABASE_URL references ${POSTGRES_USER}, ${POSTGRES_PASSWORD}, and ${POSTGRES_DB}, so they must all be set. Compose does the substitution before the container starts.

Generating secrets#

Two variables require fresh, high-entropy values. The examples below assume Python 3 is on the host.

# SECRET_KEY — 32 bytes, hex-encoded
python3 -c "import secrets; print(secrets.token_hex(32))"

# POSTGRES_PASSWORD — 32 bytes, URL-safe base64
python3 -c "import secrets; print(secrets.token_urlsafe(32))"

Do not reuse the placeholder values from .env.example. The backend's startup log redacts these values, but a placeholder in a public repo is a credential anyone can guess.

Postgres#

Variable Default Description
POSTGRES_USER ticket_board Database superuser name. Used by the postgres image, and substituted into DATABASE_URL.
POSTGRES_PASSWORD (placeholder) Database superuser password. Replace before first boot.
POSTGRES_DB ticket_board Initial database name. The migrate service expects this to exist; the postgres image creates it on first boot.

These three feed the standard postgres:16-alpine entrypoint. Changing them after first boot has no effect on the existing data directory — they're consumed only during the initial initdb.

To change the database password after first boot, connect to the container and run ALTER USER ticket_board WITH PASSWORD '...', then update .env and DATABASE_URL to match before restarting the backend.

Database connection#

Variable Default Description
DATABASE_URL postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} Async DSN consumed by SQLAlchemy + asyncpg.

The driver prefix postgresql+asyncpg:// tells SQLAlchemy to use the async asyncpg driver. The migrate service auto-converts this to a sync postgresql:// DSN at runtime for Alembic.

In a Compose deploy, the host part is postgres — Docker's internal DNS resolves it to the postgres container. If you run the backend outside Compose against a local Postgres, replace postgres with localhost.

Core application#

Variable Default Description
ENV dev Application environment. Controls log renderer and the production cookie-secure assertion. Values: dev, prod.
SECRET_KEY (placeholder) High-entropy random value used for signing/encryption primitives. Required; replace before first boot. The backend does not check whether you replaced the placeholder — any non-empty value boots.

ENV=prod does two things:

  1. Switches structlog to JSON output (from human-readable in dev).
  2. Asserts COOKIE_SECURE=true. The backend refuses to start otherwise (lifespan.py:_assert_production_security).

The asymmetry — ENV=prod enforces COOKIE_SECURE=true, but ENV=dev does not enforce COOKIE_SECURE=false — is intentional. You can run a dev-mode backend with secure cookies if you have TLS; you cannot run a prod-mode backend without them.

DOCS_ENABLED is independent of ENV. A prod deploy with DOCS_ENABLED=true will expose /docs and /redoc; if you don't want that, set DOCS_ENABLED=false explicitly.

Auth and password hashing#

Variable Default Description
AUTH_BCRYPT_COST 12 bcrypt work factor for human password hashing.
ARGON2_TIME_COST 3 argon2id time cost for bot token hashing.
ARGON2_MEMORY_COST 65536 argon2id memory cost (in KiB) for bot token hashing — 64 MiB.
ARGON2_PARALLELISM 4 argon2id parallelism for bot token hashing.

The defaults match OWASP's recommendations for 2024–2025. Increase AUTH_BCRYPT_COST after a hardware upgrade — each step roughly doubles per-hash cost. The argon2id parameters are tuned for a multi-core small server; if you're on a constrained host (Pi 4 or smaller), drop ARGON2_MEMORY_COST to 32768 to keep token verification under ~250 ms.

Changing these values does not re-hash existing credentials. Old hashes remain valid; new credentials use the new parameters.

Security and cookies#

Variable Default Description
COOKIE_SECURE false Whether session cookies are marked Secure. Must be true when ENV=prod.
DOCS_ENABLED false Whether /api/v1/docs (Swagger UI) and /api/v1/redoc are exposed. Recommended false in prod; the backend does not force it off based on ENV.

The cookie-secure interaction:

ENV COOKIE_SECURE Result
dev false Plain HTTP works; cookie sent over HTTP.
dev true TLS required, but no other guard rails.
prod true Production posture; expected combination.
prod false Backend refuses to start.

For homelab deploys with Caddy terminating TLS, use ENV=prod and COOKIE_SECURE=true.

Session timeouts#

Variable Default Description
SESSION_IDLE_TIMEOUT_DAYS 7 Inactive human sessions are rejected after this many days.
SESSION_ABSOLUTE_TIMEOUT_DAYS 30 Hard ceiling on session lifetime regardless of activity.

Sessions are server-side rows in the session table. Expired sessions are not deleted by the auth path; they're swept by the background sweeper on its interval. A user with an expired session sees a normal login redirect.

For a shared-device homelab, lower SESSION_IDLE_TIMEOUT_DAYS to 1 or 2. For a personal device you trust, the defaults are fine.

Idempotency#

Variable Default Description
IDEMPOTENCY_KEY_TTL_HOURS 24 TTL applied to idempotency_record rows.

The Idempotency-Key header on POST endpoints stores a fingerprint of the request body and the resulting response. A retry with the same key within the TTL returns the cached response; a retry with a different fingerprint returns 409.

Lengthen this for batch tooling that retries over hours. Shorten it if you want the table to stay small.

Tool invocation log retention#

Variable Default Description
TOOL_INVOCATION_LOG_RETENTION_DAYS 90 Days that tool_invocation_log rows survive before sweeper purge.

Unlike audit_event (which is never deleted), tool_invocation_log rows are bulkier — they carry full tool arguments — and have a finite retention window. The audit row that references each invocation lives forever; the detail row gets purged.

If you want to keep tool detail longer for forensic review, raise this. The trade-off is table size: an active bot can generate thousands of rows per day.

Rate limiting#

Variable Default Description
DEFAULT_RATE_LIMIT_PER_MINUTE 60 Default user.rate_limit_per_minute written when the bot service creates a new bot.

Today this variable is only read by bot_service.create_bot (services/bot_service.py). Human users have no public registration path in v1; the seed script writes its own value (600) when it creates the seeded operator. Editing this variable only affects future bot creations — existing rows keep whatever was written when they were inserted.

Per-actor REST/MCP rate limiting is planned, not yet implemented. The user.rate_limit_per_minute column is reserved for that future middleware. The only rate limit enforced today is the login endpoint's per-IP and per-username sliding window (see auth/rate_limit.py); it does not consult this variable, and no middleware emits X-RateLimit-* headers.

For a homelab where you create bot tokens by hand, the default is fine. If you start handing tokens to less-trusted bots, lower it.

Bot defaults#

Variable Default Description
BOT_CAN_EDIT_GLOBAL_WIKI_DEFAULT false Whether new bots can edit the global wiki by default.

Global wiki pages have no project scope, so they're a soft attack surface — a bot scoped to one project shouldn't accidentally rewrite the workspace's onboarding doc. Default off; flip on per-bot via the UI for bots that need it.

Background sweeper#

Variable Default Description
SWEEPER_INTERVAL_MINUTES 60 How often the background task purges expired rows.

The sweeper covers session (idle/absolute expired), idempotency_record (TTL elapsed), and tool_invocation_log (retention elapsed). It emits a system.sweeper_ran audit event after each pass, so you can confirm it's running by checking the audit log.

Sixty minutes is fine for normal use. Shorten it if you're seeing the session or idempotency_record tables grow uncomfortably; lengthen it if the sweeper is causing visible load on a small host (it shouldn't, but).

Metrics#

Variable Default Description
METRICS_ALLOWED_HOSTS ["127.0.0.1","::1"] JSON array of IPs allowed to scrape /metrics without auth.
METRICS_USERNAME (unset) Optional basic-auth username for /metrics.
METRICS_PASSWORD (unset) Optional basic-auth password for /metrics.

The /metrics endpoint exposes Prometheus-format metrics. Default: only localhost can scrape, no auth required. Either widen METRICS_ALLOWED_HOSTS (to include your Prometheus scraper's IP) or set both METRICS_USERNAME and METRICS_PASSWORD for basic auth.

The allowlist is always enforced, even when basic-auth credentials are configured. To allow a Prometheus scraper on the network, add its IP to METRICS_ALLOWED_HOSTS; basic auth becomes a second factor, not a fallback. This is documented as the deliberate choice in observability/metrics.py.

Workspace bootstrap#

Variable Default Description
WORKSPACE_NAME Default Workspace Display name for the auto-created singleton workspace on first boot.

On every boot the backend selects the workspace row whose name equals WORKSPACE_NAME, inserting one if none matches (lifespan.py:_bootstrap_workspace). v1 assumes a single workspace, so treat the first-boot value as permanent: if you change it after the workspace already exists, the SELECT misses, a second workspace row is inserted, and subsequent boots will pick whichever name is configured. The seed script reads this same setting, so the configured name must match what the seeder uses.

Frontend#

Variable Default Description
BACKEND_URL http://backend:8000 Server-side fetch target for Next.js route handlers and server components.

In a Compose stack, this is the internal hostname of the backend service. In a non-Compose environment (e.g. running the frontend locally against a backend on the host), set this to wherever the backend listens — http://localhost:8000 is the usual case.

This variable is not consumed by browser-side fetches. The browser talks to whatever origin served the page (i.e. Caddy), not to BACKEND_URL.

Proxy ports#

Variable Default Description
HTTP_PORT 80 Host-side port for Caddy's HTTP listener.
HTTPS_PORT 443 Host-side port for Caddy's HTTPS listener.

Override these if the host already runs something on 80/443. The container-side ports inside the Caddy container stay 80 and 443; only the host-published ports change. ACME HTTP-01 challenges won't reach Caddy if you remap port 80, so use DNS-01 or self-signed certificates in that case.

Startup validation#

The backend enforces exactly one explicit invariant at startup, in lifespan.py:_assert_production_security:

  • If ENV=prod, then COOKIE_SECURE=true. The process raises RuntimeError and exits before the API binds otherwise.

Everything else is implicit Pydantic field validation done while Settings is constructed: ENV must be dev or prod (literal type), METRICS_ALLOWED_HOSTS must parse as a JSON list of strings, numeric fields must be integers, and so on. A malformed value produces a Pydantic ValidationError at import time with the offending field named.

There is no startup check that SECRET_KEY was rotated away from the placeholder, no explicit parse of DATABASE_URL beyond what SQLAlchemy does on first connection, and no warning if DOCS_ENABLED=true in production. Those are operator responsibilities.

The first 200 lines of docker compose logs backend after a start include a redacted dump of every setting under the ticket_board.startup event. Use it to confirm the live values:

docker compose logs backend | grep ticket_board.startup -A 80

Secrets (SECRET_KEY, POSTGRES_PASSWORD, basic-auth passwords) appear as <redacted> in the dump.

Quick reference#

For a copy-and-edit starting point, work from the .env.example at the repo root. The two changes required for a homelab production deploy:

# Required edits
ENV=prod
COOKIE_SECURE=true
SECRET_KEY=<32 hex bytes from secrets.token_hex(32)>
POSTGRES_PASSWORD=<32 bytes from secrets.token_urlsafe(32)>

# Recommended edits
DOCS_ENABLED=false
DEFAULT_RATE_LIMIT_PER_MINUTE=30   # default written to new bots' user.rate_limit_per_minute

Everything else can stay at defaults unless you have a specific reason to change it.