Secrets
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.
Why not plaintext .env
Section titled “Why not plaintext .env”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.
1Password CLI: the default
Section titled “1Password CLI: the default”The flow on a production host:
- Install
opCLI on the VPS (brew install 1password-clion macOS, or official packages elsewhere). - Sign in once with a service account, non-interactive, scoped to one vault, revocable.
- Author
compose/.env.productiononce withop://references instead of values (safe to keep on disk, safe to commit to a private repo). - 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:
export OP_SERVICE_ACCOUNT_TOKEN=... # from CI secret or systemd EnvironmentFileop 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 routingExample 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/passwordJWT_SECRET=op://Production/Auth/jwt_secretSTRIPE_SECRET_KEY=op://Production/Stripe/secret_keySTRIPE_WEBHOOK_SECRET=op://Production/Stripe/webhook_secretGOOGLE_OAUTH_CLIENT_SECRET=op://Production/OAuth/google_client_secretRESEND_API_KEY=op://Production/Email/resend_api_keySENTRY_DSN=op://Production/Observability/sentry_dsnThe 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.
Local dev
Section titled “Local dev”For local Compose runs (./dev.sh up), use the same pattern:
op run --env-file=.env.local -- ./dev.sh up -d --buildYou can keep .env.local checked into your private dotfiles repo (just references, no values), or generate it on demand from a 1Password vault item.
Alternatives
Section titled “Alternatives”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.
Fallback: raw .env files (local dev only)
Section titled “Fallback: raw .env files (local dev only)”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:
- Never in production. Production hosts must use a secret manager. No exceptions.
- Never in git. The file is gitignored and
gitleaksruns in CI; do not disable either.
Local-only fallback:
cp compose/.env.example compose/.envchmod 600 compose/.envEven 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.
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) is not a secret; keep it inline.
Rotation with 1Password
Section titled “Rotation with 1Password”Rotating a secret is: update the vault item, restart. No editing files, no SSH, no scp.
# 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 -dOld 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.
On a leak
Section titled “On a leak”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.
Source
Section titled “Source”compose/.env.example: per-variable reference with comments. Convert each line to an op:// reference when you author .env.production.
Related
Section titled “Related”- Env validator: the layer that refuses to boot if required secrets are missing or malformed.
- Environment variables: every var, what it does.
- Env backup runbook: the secret-manager-first workflow plus break-glass disk-backup.