Skip to content
BoringStack
Star

Secrets

6 min read

Production secrets live in 1Password, not on the box. The compose stack reads them via op run --env-file=... which expands op://vault/item/field references at process start, never written to disk, never in git history. Env files with raw values are a local-dev fallback for solo work.

A single compromised dependency on your laptop or VPS, a malicious npm/bun/pip package, a typosquat, or a hijacked maintainer scrapes the filesystem for .env, ~/.aws/credentials, ~/.ssh/, ~/.bashrc. Recent supply-chain incidents (eslint-scope, ua-parser-js, color.js, the entire 2024 npm worm wave) all did exactly this.

Keeping compose/.env on the production host means:

  • One foothold gives an attacker every API key, OAuth secret, database password, Stripe key, JWT signing key
  • Filesystem reads don’t log to anything you can inspect
  • Rotating a leaked key means SSH’ing in and editing a file (you won’t do it under pressure)
  • Team handoff becomes “Where’s the prod env?” via Slack DM with secrets in it

op:// references are inert. Even if the entire compose directory leaks, an attacker holds strings like op://Production/Postgres/password, useless without a logged-in 1Password session.

The flow on a production host:

  1. Install op CLI on the VPS (brew install 1password-cli on macOS, or official packages elsewhere).
  2. Sign in once with a service account, non-interactive, scoped to one vault, revocable.
  3. Author compose/.env.production once with op:// references instead of values (safe to keep on disk, safe to commit to a private repo).
  4. Start the stack via op run --env-file=compose/.env.production -- docker compose up -d. Values are expanded into the subprocess and inherited by Docker; nothing touches the filesystem.

Production boot:

Terminal window
export OP_SERVICE_ACCOUNT_TOKEN=... # from CI secret or systemd EnvironmentFile
op run --env-file=.env.production -- docker compose --profile prod up -d
# Output: op resolved 23 secret references from vault Production
# api-prod started, postgres healthy, traefik routing

Example compose/.env.production (safe to commit to a private mirror):

# Inert references, `op` resolves them at runtime via service account.
POSTGRES_PASSWORD=op://Production/Postgres/password
JWT_SECRET=op://Production/Auth/jwt_secret
STRIPE_SECRET_KEY=op://Production/Stripe/secret_key
STRIPE_WEBHOOK_SECRET=op://Production/Stripe/webhook_secret
GOOGLE_OAUTH_CLIENT_SECRET=op://Production/OAuth/google_client_secret
RESEND_API_KEY=op://Production/Email/resend_api_key
SENTRY_DSN=op://Production/Observability/sentry_dsn

The op://vault/item/field URI is documented at developer.1password.com. Same syntax works for SSH keys, certs, anything in your vault.

Per-environment vaults, per-host tokens. Create separate 1Password vaults for Production, Staging, Development. Mint one service-account token per host scoped to one vault, so a compromised staging VPS cannot read prod secrets. Tokens are revocable from the 1Password admin console; the next op run simply fails.

For local Compose runs (./dev.sh up), use the same pattern:

Terminal window
op run --env-file=.env.local -- ./dev.sh up -d --build

You can keep .env.local checked into your private dotfiles repo (just references, no values), or generate it on demand from a 1Password vault item.

If 1Password is not your team’s choice, the flow shape is the same (a CLI that resolves references at process start):

HashiCorp Vault. Use if you already run k3s or Nomad. Heaviest, most features, audit and leasing.

Infisical. Open-source, prefer self-hosted. Clean UI, similar reference syntax.

Doppler. SaaS, no infra to run. Closed source; good developer experience.

SOPS + age. Encrypted env files committed to git. No service to run; you manage keys.

AWS Secrets Manager / GCP Secret Manager. Use if you’re already on that cloud. IAM is the access control.

All of them produce the same end-state: process inherits expanded env, filesystem stays clean.

If you cannot or will not run a CLI on the dev loop (solo developer, throwaway prototype, air-gapped machine), compose/.env with raw values still works for local development. Two hard rules:

  1. Never in production. Production hosts must use a secret manager. No exceptions.
  2. Never in git. The file is gitignored and gitleaks runs in CI; do not disable either.

Local-only fallback:

Terminal window
cp compose/.env.example compose/.env
chmod 600 compose/.env

Even here, prefer mixing op:// references with raw values in .env. You can pre-resolve at boot with op inject -i .env.template -o .env to keep your template safe to share with the team.

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) is not a secret; keep it inline.

Rotating a secret is: update the vault item, restart. No editing files, no SSH, no scp.

Terminal window
# 1. Rotate the value in the 1Password GUI (or via `op item edit`).
# 2. On the host:
op run --env-file=.env.production -- docker compose --profile prod up -d

Old containers stop, new containers start with the freshly resolved value.

Postgres password: update vault → ALTER USER app WITH PASSWORD '...'; → restart api/migrate containers.

JWT secret: update vault → restart api. All sessions invalidated; users get 401 and re-login.

OAuth secret: rotate at provider → update vault → restart api.

Provider key (Resend / Stripe / etc.): provision new key alongside old → update vault → restart → confirm → revoke old.

Always provision the new value before revoking the old, so traffic mid-rotation doesn’t fail.

Rotate immediately. Do not wait for the post-mortem. Edit the 1Password item, restart the stack.

Audit logs. SELECT * FROM audit.audit_log WHERE created_at > '<leak time>' for anomalous service usage.

Force re-login. Rotate JWT_SECRET if session integrity is in doubt.

Check git history. If a raw value was ever committed, git log -p and assume every secret in that file is compromised. Rotate them all.

compose/.env.example: per-variable reference with comments. Convert each line to an op:// reference when you author .env.production.