Skip to content

ADR 0008: Split env files into committed .env.app + gitignored .env.shared

Status: Accepted Date: 2026-04-30

Context

Each registered app needed a .env for docker compose variable interpolation. The original pattern was .env.example (committed, placeholder values) and a per-developer .env (gitignored, real values). Two pain points fell out of running across multiple machines:

  1. N files to sync. Every machine — laptop, desktop, server — had to receive every app's .env separately. With one or two apps that's fine; by app five it's a chore. Forgetting one app silently bricks make up.
  2. Secrets mixed with structural config. A typical .env had ~10 keys, of which only two (POSTGRES_PASSWORD, SCALR_TEST_USER_PASSWORD) were truly secret. The other eight (POSTGRES_USER, POSTGRES_DB, ports, OIDC issuer URLs, client IDs) were deterministic from the app's slug — identical across machines, would have been fine to commit. They were gitignored only because the password lines were sitting in the same file.

This is a partial answer to open question #8 (Secrets management). It picks the file shape; the question of how .env.shared syncs between machines (1Password, SOPS, Bitwarden Send, etc.) is still open.

Decision

Split each app's environment into two files with separate lifecycles:

File Location Committed? Lifecycle Contents
.env.app <app>/.env.app yes once per scaffold structural config — slug-derived keys, ports, OIDC IDs, URLs
.env.shared <repo>/.env.shared no (gitignored) rotates with secrets the actual secrets — POSTGRES_PASSWORD, SCALR_TEST_USER_PASSWORD
.env.shared.example <repo>/.env.shared.example yes rare placeholders + docs, so a new clone knows what to fill in

make up, make down, and make down-v pass both files to each app's docker compose via stacked --env-file flags (later files win):

docker compose --env-file <repo>/.env.shared --env-file ./.env.app up -d --build

infra/scripts/create-app-from-template.sh writes .env.app directly (committed-ready, no secrets, no placeholder passwords). infra/scripts/new-app.py no longer prompts for passwords — it preflights that <repo>/.env.shared exists and bails clearly if not.

Why this shape

  • One file to sync, ever. Two-line .env.shared is the only thing that has to move between machines. Use whatever channel suits — 1Password, SOPS, Bitwarden Send, scp. Once it's there, scaffolding new apps doesn't change the sync story.
  • Zero per-app sync. .env.app files are committed, so a fresh clone of the repo gets every app's structural config automatically.
  • Diff-friendly. Renaming a port, OIDC client ID, or DB name shows up as a normal PR diff in <app>/.env.app. Previously these changes were invisible — they'd silently exist on one developer's .env and not another's.
  • Secrets stay narrow. With the only secrets being POSTGRES_PASSWORD and SCALR_TEST_USER_PASSWORD, blast radius of a leaked .env.shared is small and well-defined. Adding a new shared secret means one more line in that file, one more line in .env.shared.example.
  • Compose's stacking semantics give us override-for-free. If any app ever needs to override a shared default (different per-app DB password, say), it just sets the key in its .env.app and the later --env-file wins.

Consequences

Positive: - New machine bootstrap is cp .env.shared.example .env.shared + paste two values, then make up. - New app onboarding is simpler — no passwords prompted, the scaffold script writes a finished .env.app. - PRs reviewing app config become possible (structural keys are now in the diff).

Negative: - services/auth/.env is not split — Authentik's stack (PG_PASS, AUTHENTIK_BOOTSTRAP_*, etc.) still uses the original .env.example.env pattern. It runs standalone and rarely changes, so the trade isn't worth complicating. The one cross-cutting key (SCALR_TEST_USER_PASSWORD) has to be set in both services/auth/.env and <repo>/.env.shared to match — left as a documentation note rather than enforced. - .env itself is still gitignored even though we don't use it. A stray .env in any project would be auto-loaded by Compose / Vite and shadow the merged config — keep ignored as a safety net. - Two files to read. A developer wanting to know "what env does this app boot with" has to look at two places. Clear naming (.app vs .shared) keeps this from being confusing in practice.

Migration path

If we ever decide .env.app is too granular and we want one root file with everything: it's mechanical to merge them back. The structural values would move into .env.shared and become app-prefixed (PORTAL_POSTGRES_USER=…). Compose's interpolation would have to be retooled to read the prefixed name. Not free, but bounded.

If we ever need real secrets management (Doppler, Vault, 1Password Connect for prod), .env.shared is the artifact those tools generate; the rest of the platform doesn't change.