Secrets Management — Decision & Runbook
Status: Decision recorded for issue #168 Phase G. Implementation deferred. Companion docs: ENV_VARS.md (inventory), NAMING.md (naming convention).
This document captures which secrets exist, where they live today, where they should live going forward, and how to rotate them. The implementation that backs this decision (sops + age tooling, CI integration, dev UX) is a separate work item — not in this PR.
1. Decision: sops + age
Section titled “1. Decision: sops + age”Adopt sops with age keys for Terraform state, Hetzner provisioning vars, and any secrets that should be version-controlled. Keep GitHub Secrets for CI credentials and Coolify env UI for live application secrets — sops doesn’t replace those, it complements them.
Why sops + age
Section titled “Why sops + age”| Concern | sops + age |
|---|---|
| Version control of encrypted state | Yes — files are git-committable; diffs visible in PRs |
| Key rotation | Re-encrypt with new recipient; old key revoked separately |
| Multi-recipient (team) | Native — list age public keys in .sops.yaml |
| CI integration | Single secret (SOPS_AGE_KEY) injected via GitHub Actions; sops decrypts in-place |
| Offline-friendly | No SaaS dependency; works on a plane |
| Learning curve | Minimal — sops -e file.yaml, sops -d file.yaml |
Alternatives considered
Section titled “Alternatives considered”| Option | Why not |
|---|---|
1Password CLI (op run) | Excellent dev UX, but Hetzner box can’t reach 1Password’s API without storing a service account token locally — circular. Better as a complement for dev-machine UX than a backend. |
| Doppler / Infisical / Hosted SaaS | Adds vendor lock-in and SaaS cost for a small team. Audit logs are nice but we don’t need them yet. |
| Status quo (Coolify UI + .tfvars in gitignore) | Works but: (a) tfvars are plaintext on someone’s laptop, (b) no audit trail, (c) onboarding requires DM’ing secrets, (d) loss of laptop = re-rotation of every key. |
| HashiCorp Vault | Operational overhead too high for current team size. Reconsider if we hit ~15 engineers or a compliance audit. |
Dev UX (separate from the backend)
Section titled “Dev UX (separate from the backend)”For local dev, continue with plaintext .env files in .gitignore. Onboarding flow:
- New engineer installs sops + age, generates their own age key.
- Existing maintainer adds their public age key to
.sops.yaml. - Re-encrypts current encrypted files for the new recipient.
- Engineer decrypts
infra/hetzner/secrets.yaml→ fills theirapps/api/.envmanually for first-run.
Future improvement: wrap the decrypt step in bun scripts/dev-secrets.ts so the engineer just runs bun run secrets:pull.
2. Current state inventory (2026-05-22)
Section titled “2. Current state inventory (2026-05-22)”2.1 Where secrets live today
Section titled “2.1 Where secrets live today”| Location | Contents | Plaintext? | Encrypted? |
|---|---|---|---|
apps/api/.env (local dev) | DATABASE_URL, AUTH_SECRET, LLM provider keys, Notion token, Gmail OAuth, Resend | ✅ | ❌ |
infra/hetzner/terraform.tfvars (local) | Hetzner API token, postgres password, better_auth_secret, anthropic key, S3 keys | ✅ | ❌ |
| GitHub Actions secrets | HETZNER_SSH_PRIVATE_KEY, COOLIFY_WEBHOOK_URL, COOLIFY_API_TOKEN, GITLEAKS_LICENSE (no npm secret: publishing uses Trusted Publishing, NPM_PUBLIC_TOKEN deleted 2026-09-14 — §4.2) | n/a (managed) | ✅ (GitHub-encrypted at rest) |
| Coolify env UI | Production runtime env (DATABASE_URL, AUTH_SECRET, NOTION_INTEGRATION_TOKEN, etc.) | n/a (Coolify-managed) | ✅ (encrypted in Coolify’s DB) |
2.2 Risks today
Section titled “2.2 Risks today”- Laptop loss = full rotation needed for everything in
apps/api/.envandinfra/hetzner/terraform.tfvars. - Onboarding requires DM’ing 10+ values; mistakes are silent (a typo in
AUTH_SECRETonly surfaces when sessions break days later). - No audit trail for who pulled which secret when.
- CI surface is broad:
HETZNER_SSH_PRIVATE_KEYis root SSH (Phase H deliverable: narrow to deploy user).
3. Scope of the new system
Section titled “3. Scope of the new system”3.1 In scope for sops + age
Section titled “3.1 In scope for sops + age”infra/hetzner/terraform.tfvars→ committed asinfra/hetzner/terraform.tfvars.sops.yaml- Any future bootstrap scripts that need secrets at provision time
- Shared dev-onboarding
.envtemplate that contains placeholder + real OAuth client IDs (the non-secret-but-painful-to-rebuild bits)
3.2 Out of scope (intentionally)
Section titled “3.2 Out of scope (intentionally)”- GitHub Actions secrets — already encrypted, already audited, already team-shared. Adding sops here is duplication.
- Coolify production env — Coolify owns this; sops would compete with it. The runbook is “operator edits in Coolify UI”; never commit production env values.
- Local
apps/api/.env— keep gitignored plaintext. The onboarding flow decrypts the shared bootstrap doc and engineer fills their own file. (Reason: every engineer’s dev .env has slightly different values — Gmail OAuth pointing at their own account, etc. A shared encrypted file would imply we all share the same dev secrets.)
4. Rotation runbook
Section titled “4. Rotation runbook”4.1 Outstanding rotations (post-#171 + #169 leaks)
Section titled “4.1 Outstanding rotations (post-#171 + #169 leaks)”Two recent PRs closed credential leaks; the credentials they exposed must still be rotated as an operator action:
Exposed via PR #171’s fix (/api/config endpoint was returning these to authenticated clients):
NOTION_INTEGRATION_TOKENGMAIL_REFRESH_TOKENTWITTER_BEARER_TOKEN
Exposed via PR #169’s fix (boot stdout was logging the entire parsed env):
BETTER_AUTH_SECRET/AUTH_SECRETCLINE_API_KEYANTHROPIC_API_KEYGMAIL_CLIENT_SECRETRESEND_API_KEY
Rotation steps for each:
- Generate a new value at the provider (Notion → new integration, Gmail → revoke + recreate OAuth refresh token, etc.).
- Update Coolify env UI on staging → smoke test → production.
- Update local
apps/api/.envfor any engineer who has one. - Confirm production logs show no errors post-rotation; hit
/api/health(post-#173 expanded version) to verify subsystems still up. - For
AUTH_SECRET: rotating invalidates all existing sessions — users get logged out and need to log in again. Schedule outside business hours.
Action owner: infra team. Not blocked by this PR — track in a separate operations issue. Cross-reference: [memory: devops_rationalization_2026-05-21.md].
4.2 npm publishing: Trusted Publishing, no token
Section titled “4.2 npm publishing: Trusted Publishing, no token”There is no npm publish secret to rotate. publish-packages.yml publishes the four public artifacts, @viite-ai/platform-utils, @viite-ai/platform-domain, @viite-ai/platform-sdk and the unscoped viite-cli, with npm Trusted Publishing. The job has permissions: id-token: write. Each npm publish trades the job’s GitHub OIDC token for a short-lived credential that npm scopes to that one package. Nothing is stored in GitHub, Coolify, apps/api/.env or on a laptop.
Why it replaced a token: NPM_PUBLIC_TOKEN was a granular access token with “bypass 2FA” and direct publish access. npm deprecated that combination on 2026-09-14. From January 2027 such a token can only stage a version, which a human then approves with 2FA. viite-cli pins the three @viite-ai/* packages at the exact version, so four separately approved staged versions per release would reopen the partial-publish failure below.
What each package needs on npmjs.com (package → Settings → Trusted Publisher → GitHub Actions). All fields are case-sensitive:
| Field | Value |
|---|---|
| Organization or user | abeauvois |
| Repository | platform |
| Workflow filename | publish-packages.yml — the workflow that runs npm publish. Not release.yml: it dispatches this workflow through the API, which starts a separate run. |
| Environment | empty |
| Allowed actions | allow npm publish (direct), not stage-only |
What the workflow relies on, and why:
npm publish, notbun publish. bun does not implement npm’s OIDC exchange (oven-sh/bun#22423).- npm is pinned (
NPM_VERSION, ≥ 11.5.1). Older npm has no trusted publishing, and the runner’s Node 24 can bundle one. - No provenance. npm adds provenance automatically only for a public repository.
abeauvois/platformis private, and npm refuses--provenancethere even for public packages, so the flag is not passed. - No token may be configured in the job. A failed OIDC exchange does not fail
npm publish. npm logs it atverboseand falls back to any configured token. The “Refuse a configured npm token” step makes a green run mean OIDC worked. - Without a
filesfield,npm packpacks by.gitignore, which excludesdist/. bun did not. platform-domain’sfilesfield exists for that reason.scripts/publish-packages.shrefuses to publish a tarball that is missing its ownmain/types/bin/exportstargets.
If a publish fails with ENEEDAUTH (or E404 on the PUT), npm could not match the run to that package’s trusted publisher. npm publish says only “need auth”; the script also prints npm’s oidc log lines. To ask npm directly, without building or publishing anything, run the diagnose-only mode. It prints each package’s exchange result, HTTP 201 … accepted or HTTP 404 … package not found, and the OIDC claims npm matched:
gh workflow run publish-packages.yml --ref main -f diagnose_oidc=truepackage not found means npm has no trusted publisher for that package matching those claims. On 2026-09-14 that was the state of @viite-ai/platform-utils while the other three were set up, and the v2.0.1 publish stopped on it with nothing published. Read or set a package’s publisher from a terminal. Both commands need a browser approval, and the unscoped package is viite-cli, with no @:
npx -y npm@11.19.1 trust list @viite-ai/platform-utilsnpx -y npm@11.19.1 trust github @viite-ai/platform-utils --file publish-packages.yml --repo abeauvois/platform --allow-publishRenaming publish-packages.yml breaks all four packages at once.
Verify a change here with a real release (gh workflow run release.yml), not a bare gh workflow run publish-packages.yml. The bare dispatch skips every version that is already published, so it never reaches npm publish. scripts/publish-packages.sh batches four packages, and a partial publish leaves the CLI uninstallable, because viite-cli depends on the exact versions of the three published before it. The script stops at the first package it cannot confirm.
NPM_PUBLIC_TOKENis retired. v2.0.1 (2026-09-14) was the first release published through OIDC, with no token in the job, and the repo secret was deleted afterwards. There is no token-based rollback any more: if trusted publishing breaks, fix the publisher (above), don’t re-add a token. A bypass-2FA granular token loses direct publish in January 2027 anyway. If theviite-aiaccount still lists the old token under Access Tokens, revoke it.Optional hardening: per package, Settings → Publishing access → “Require two-factor authentication and disallow tokens”. The
viite-aiaccount has 2FA disabled, so this also blocks a manual publish from a laptop until 2FA is on.
Retired:
NPM_TOKENand GitHub Packages. A second token used to sit here, authenticatingnpm.pkg.github.comfor a private@abeauvois/*scope. The only package still published there was@abeauvois/platform-ui, which nothing installed from a registry —apps/dashboardtakes it asworkspace:*. The registry, the token, the repo.npmrcand the packages are all gone;platform-uiis now marked"private": true. If you find a document telling you to rotateNPM_TOKEN, it is out of date.
4.3 General rotation procedure (for any future leak)
Section titled “4.3 General rotation procedure (for any future leak)”- Generate new secret at the provider.
- Update Coolify (staging first, validate, then production).
- Update
infra/hetzner/terraform.tfvars(or the sops-encrypted equivalent once Phase G implementation lands). - Update GitHub Secrets if the value is also referenced in CI.
- Revoke the old secret at the provider — don’t leave it active “for safety.”
- Smoke test: hit
/api/healthand one happy-path endpoint per subsystem touched. - Document the rotation in
docs/devops/ROTATIONS.md(new file when first rotation happens — date, key, reason).
5. Rollout sketch (when implementing)
Section titled “5. Rollout sketch (when implementing)”Not in scope for this PR. Sketched here so the next person picking this up doesn’t reinvent the plan.
G.1 — Bootstrap sops + age (1–2 days)
Section titled “G.1 — Bootstrap sops + age (1–2 days)”- Install sops + age on each maintainer’s laptop.
- Each maintainer generates an age keypair; commits their public key to a new
.sops.yamlrecipient list at repo root. - Add
bun run secrets:editscript that wrapssops infra/hetzner/secrets.yaml.
G.2 — Migrate terraform.tfvars (1 day)
Section titled “G.2 — Migrate terraform.tfvars (1 day)”- Move sensitive values from
terraform.tfvarstoinfra/hetzner/secrets.sops.yaml(encrypted). - Update
infra/hetzner/main.tfto read from the decrypted file via asops_filedata source (terraform-sops provider). - Add
infra/hetzner/terraform.tfvarsto.gitignore(currently gitignored; confirm). - Document the workflow in
infra/hetzner/README.md.
G.3 — CI integration (0.5 days)
Section titled “G.3 — CI integration (0.5 days)”- Add
SOPS_AGE_KEYto GitHub Actions secrets. - Any workflow that needs decrypted values (none today — production deploy is Coolify-driven) installs sops + decrypts in-step.
- Verify CI still passes; document the new step.
G.4 — Onboarding doc (0.5 days)
Section titled “G.4 — Onboarding doc (0.5 days)”- Add
docs/devops/ONBOARDING.mdwith the new-engineer flow. - Update root
README.mdto point at it for first-day setup.
G.5 — Optional: 1Password CLI wrapper (1 day, follow-up)
Section titled “G.5 — Optional: 1Password CLI wrapper (1 day, follow-up)”- Wrap the dev decrypt step in a
bun run secrets:pullthat pulls from 1Password Personal if available, falls back to sops otherwise. Keeps the muscle memory familiar for engineers who already use 1Password.
6. Open questions
Section titled “6. Open questions”- Coolify-only secrets (set in UI, not in
docker-compose.coolify.yml): someone with Coolify access should export and reconcile against ENV_VARS.md before Phase G implementation starts. If there are values living only in Coolify, sops can’t help with them — they’re already “encrypted at rest” by Coolify. - Keep
.tfvars.exampleas the documented shape, withsecrets.sops.yamlcarrying values only? Or merge both into one encrypted source-of-truth? Recommend keeping.examplefor documentation and shape (no values). - Backups: are encrypted secrets included in the postgres backup story? They shouldn’t be (they’re not in postgres), but worth confirming as part of Phase H.
7. References
Section titled “7. References”- sops: https://github.com/getsops/sops
- age: https://github.com/FiloSottile/age
- terraform-sops provider: https://registry.terraform.io/providers/carlpett/sops/latest/docs
- 1Password CLI: https://developer.1password.com/docs/cli
- Original audit comment with the secrets-store decision matrix: #168 comment
