Secrets
Secrets
The infra stack puts secrets in compose/.env (gitignored) and passes them to
containers via env_file: references. No Docker secrets, no Vault, no SOPS.
For a single-host stack, env files + filesystem permissions work.
Zero
Vault by default
chmod 600
filesystem flow
Fail-fast
validator
When you outgrow that floor, see When to graduate below. Until then, every step you add is complexity for a problem you don’t have yet.
How a secret flows
Section titled “How a secret flows”flowchart LR envfile["compose/.env<br/>chmod 600 · gitignored"] compose["docker-compose.yml<br/>env_file: .env"] container["container<br/>process.env.SECRET_X"] validator["env validator<br/>(apps/api)"] envfile --> compose --> container --> validator
The container sees a normal environment variable. The API app’s validator refuses to boot in production if anything required is missing or malformed. See Env validator.
Design choices
Section titled “Design choices”compose/.env is the single source
One file to back up, one file to protect; no scattered config.
Gitignored, file mode 600
Filesystem permissions are the access control.
env_file: references, not inline environment: blocks
Avoids leaking secrets into docker-compose.yml.
No vendor secret manager by default
Adding one is harder to undo than to add later.
Validator-fail-fast on missing required secrets
Misconfiguration shows up at boot, not at the first request.
Bootstrapping
Section titled “Bootstrapping”$ cp compose/.env.example compose/.env
$ chmod 600 compose/.env
ok compose/.env initialized successfully
# Permissions set: -rw------- (chmod 600)Every secret in .env.example has a comment explaining what it’s for and where it’s required.
What counts as a secret
Section titled “What counts as a secret”Database (POSTGRES_PASSWORD)
Rotation cadence: annually, or after a suspected leak.
Auth (JWT_SECRET, 32+ chars)
Signs access cookies and hashes refresh tokens. Rotate after any incident; otherwise leave alone.
OAuth (*_OAUTH_CLIENT_SECRET)
Per provider policy; rotate after staff turnover.
Provider keys (Resend, Cloudflare, Stripe)
When a key is leaked; per vendor rotation guidance.
Webhook secrets (STRIPE_WEBHOOK_SECRET)
When the webhook endpoint is regenerated.
Cosmetic config (POSTGRES_USER, EMAIL_FROM) isn’t a secret; it’s just config. Don’t put it through the same rotation rigor.
Rotation playbooks
Section titled “Rotation playbooks”Postgres password
Section titled “Postgres password”- Generate a new strong password.
- Update
POSTGRES_PASSWORDincompose/.env. ALTER USER app WITH PASSWORD '...';inside Postgres../dev.sh restartto recycle the app containers with the new connection string.
JWT secret
Section titled “JWT secret”- Update
JWT_SECRET(32+ chars) incompose/.env. ./dev.sh restart api.- Every access cookie and refresh session is invalidated. Users get a 401 and re-login.
OAuth secret
Section titled “OAuth secret”- Rotate at the provider (Google / GitHub / LinkedIn dashboard).
- Update the matching
*_OAUTH_CLIENT_SECRETincompose/.env. ./dev.sh restart api. No user-visible impact unless they’re mid-OAuth at the moment.
Provider key (Resend / Cloudflare / Stripe)
Section titled “Provider key (Resend / Cloudflare / Stripe)”- Provision a new key alongside the old one.
- Update
compose/.env, restart the API. - Confirm send/charge works.
- Revoke the old key.
Provision the new key before revoking the old one so nothing breaks mid-rotation.
On a leak
Section titled “On a leak”When to graduate
Section titled “When to graduate”Env files plus 600 permissions stop being adequate when one of these is true:
- Multiple operators with different scopes: “Alice can read backups, Bob can read app secrets, nobody can read both” needs a secret manager.
- Audit-trail requirements: SOC 2, HIPAA, and similar want a log of who read which secret when.
- Automated rotation: rotation more often than humans can do reliably.
Common upgrade paths:
- SOPS + age: encrypted env files in git. Cheapest upgrade, no infra to run.
- Hashicorp Vault: purpose-built, all the features. Adds a service to run + back up.
- Cloud-provider KMS / Secrets Manager: AWS / GCP / Azure native. Easy if you’re already on that cloud.
Backups
Section titled “Backups”compose/.env is the most security-sensitive file in the repo and the most important to back up. Treat it like a private SSH key: encrypted, off-host, and recoverable without the live server.
See Backups for the Postgres side; the .env itself is small enough to commit to a private secrets repo or stash in a password manager.
Source
Section titled “Source”compose/.env.example; the per-var reference with comments.
Related
Section titled “Related”- Env validator; the layer that refuses to boot if required secrets are missing.
- Environment variables; cross-repo index.