Secrets management — operator guide
This is the day-to-day reference for working with secrets in p24-infra.
For the design rationale see docs/improvements/03-secrets-management.md.
What is sops + age?
sops is Mozilla’s “secrets operations” tool — a CLI that encrypts/decrypts structured files (yaml, json, env) leaving the keys readable but the values encrypted. We use it with age, a modern minimalist alternative to PGP. Together they give us:
- Plaintext keys, encrypted values — diffs in git remain readable (you can see which secret changed, not the value).
- Multi-recipient encryption — every file is encrypted to N age public keys (the dev’s machine, each VPS, and the GH Actions runner). Any single recipient can decrypt.
- Auditability — every rotation is a git commit.
git blameshows when and why each secret last moved. - No SaaS, no per-seat fees, no vendor lock-in.
The three tiers
| Tier | Storage | Examples | Rotation |
|---|---|---|---|
| 1 — OIDC | nothing stored anywhere — short-lived JWT issued per workflow run | Vercel deploys, Cloudflare DNS edits (future), Wasabi STS (future) | never (per-run) |
| 2 — Auto-rotating | refresh token only | Claude Code OAuth, GitHub App installation tokens | only on account compromise / quarterly hygiene |
| 3 — sops-encrypted static | secrets/*.sops.yaml in git | SMTP password, Grafana admin pw, Anthropic API key | manual — but one place, one PR, audited |
Rule: move every secret as high up this hierarchy as the upstream supports.
Repository layout
.sops.yaml recipient rules — which age pubkeys decrypt which file
secrets/
├── shared.sops.yaml used on multiple targets (SMTP, Wasabi, Supabase, …)
├── vps-i1.sops.yaml IONOS-only (Grafana admin, Cloudflare token)
├── vps-h1.sops.yaml Hostinger-only (n8n encryption key, WAHA api key)
└── github-actions.sops.yaml sync'd into GH Secrets for workflow consumption
scripts/git-hooks/pre-commit rejects plaintext-secret commits
.github/workflows/secrets-sync.yml CI: on push to main touching secrets/, decrypts and ships
Daily tasks
View a secret
sops -d secrets/shared.sops.yamlDecrypts and prints to stdout. Requires your personal age private key at
~/.age/personal.key (Windows: C:\Users\konar\.age\personal.key) plus
SOPS_AGE_KEY_FILE=~/.age/personal.key if not in the default location.
Add or change a secret
sops edit secrets/shared.sops.yamlOpens your $EDITOR with decrypted content. Save + exit → sops re-encrypts
before writing. Then:
git add secrets/shared.sops.yaml
git commit -m "feat(secrets): add pdf_service_api_key"
git pushThe secrets-sync workflow picks it up on push to main and ships to the
relevant VPS(es) within ~2 minutes.
Add a new recipient (developer or VPS)
-
Generate keypair on target host:
age-keygen -o ~/.age/<name>.key # Linux/macOS # Windows PowerShell: age-keygen -o $env:USERPROFILE\.age\<name>.keyThe command prints
Public key: age1.... Save the private key in 1Password under “p24-infra age —”. Note the public key. -
Add public key to
.sops.yamlin every block that should be decryptable by this recipient. -
Re-encrypt existing files for the new recipient:
sops updatekeys secrets/shared.sops.yaml sops updatekeys secrets/vps-i1.sops.yaml # …etc, only the files that include this recipient in the rules -
Commit + push. The new recipient can now
sops -dlocally.
Rotate a secret
sops edit secrets/vps-i1.sops.yaml # change the value, save
git commit -am "fix(secrets): rotate grafana_admin_password"
git pushThen append a row to docs/secrets-rotation-log.md (date, secret, reason,
rotator). The secrets-sync workflow auto-deploys.
Emergency response — “I think a secret leaked”
Treat any secret that touched an LLM session, screen-share, public chat, public commit, or paste-bin as compromised — rotate within 24 h, no exceptions.
Step 1 — assess blast radius
# Where is this secret used?
grep -rln <SECRET_NAME> .
# Look at git history for accidental leaks
git log -p --all -S '<first-8-chars-of-value>'Step 2 — rotate at the source
- API token? Revoke in provider dashboard → generate new → paste into
sops edit secrets/<file>.sops.yaml. - SSH key? Generate new keypair, replace
authorized_keyson every host, updatesecrets/…with new private key. - Password? Change in upstream, update sops, redeploy.
Step 3 — verify deploy
gh run watch # secrets-sync workflow
ssh root@<vps> 'sudo cat /opt/p24-infra/monitoring/.env | grep -c <KEY_NAME>' # must be 1Step 4 — log
Append to docs/secrets-rotation-log.md:
| 2026-05-12 | GRAFANA_ADMIN_PASSWORD | leaked in Claude session | radieu | yes |
Step 5 — if the secret was committed in plaintext at any point
Even after rotation, assume the old value is permanently public.
git push --force can scrub history but only blocks naïve scrapers — caches,
forks, and CI logs may retain it. The only safe path is rotate. History
cleanup is optional and best done with git filter-repo; coordinate with the
team before force-pushing main.
How secrets-sync.yml works
On push to main touching secrets/** or .sops.yaml:
sync-vps-i1— checks out repo, installs sops, decryptsshared+vps-i1withAGE_KEY_GHA, converts yaml →KEY=valuelines (uppercased),scps asclaude-adminto/tmp/monitoring.env.newonvps-i1, thensudo installto/opt/p24-infra/monitoring/.env(atomic), thendocker compose up -dto roll services.sync-vps-h1— same pattern but to Hostinger. Currently a TODO untilclaude-adminuser exists there (or we useVPS_ROOT_SSH_KEY).sync-github-secrets— decryptsgithub-actions.sops.yaml, iterates, callsgh secret setonradieu/p24-infrafor each entry.
What lives where
-
Real age private keys:
~/.age/personal.keyon the dev machine, backed up to 1Password./root/.age/secrets.keyon each VPS (mode 0600).- GitHub Actions: the env-var
AGE_KEYmaterialised from secretAGE_KEY_GHAat runtime (never written to disk except~/.age/keys.txtinside the ephemeral runner).
-
Public keys: in
.sops.yamlonly — never sensitive. -
Derived
.envfiles on VPSes: regenerated by CI, not edited by hand. Treat them as cache.
See also
docs/improvements/03-secrets-management.md— design + bootstrap proceduredocs/secrets-rotation-log.md— append-only audit traildocs/runbook.md—SecretsSyncFailed,AgeKeyMissing, emergency rotationdocs/improvements/rulebook.md§5 — operating rules