Skip to content
BoringStack
GitHub

Secrets

5 min read

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.

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.

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.

Bootstrap secrets file
$ 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.

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.

  1. Generate a new strong password.
  2. Update POSTGRES_PASSWORD in compose/.env.
  3. ALTER USER app WITH PASSWORD '...'; inside Postgres.
  4. ./dev.sh restart to recycle the app containers with the new connection string.
  1. Update JWT_SECRET (32+ chars) in compose/.env.
  2. ./dev.sh restart api.
  3. Every access cookie and refresh session is invalidated. Users get a 401 and re-login.
  1. Rotate at the provider (Google / GitHub / LinkedIn dashboard).
  2. Update the matching *_OAUTH_CLIENT_SECRET in compose/.env.
  3. ./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)”
  1. Provision a new key alongside the old one.
  2. Update compose/.env, restart the API.
  3. Confirm send/charge works.
  4. Revoke the old key.

Provision the new key before revoking the old one so nothing breaks mid-rotation.

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.

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.

compose/.env.example; the per-var reference with comments.