ADR-115: Multi-tenant secret delivery — sops/age at rest, tmpfs + file mounts at runtime¶
- Status: Accepted
- Date: 2026-07-08
- Authors: Marko Dragoljevic, Claude (Opus 4.8)
- Related ADRs: ADR-114 (the ownership-split precedent this mirrors), ADR-011 (credential injection — extended to file-based)
- Security SSOT: Threat model T-08 (secret blast radius) — this ADR is its concrete substance
- Tracking: #1160 (hardening); #1162 (OpenBao future idea)
Context¶
Today prod secrets live as a plaintext /srv/podcast-scraper/.env (0600), rendered
from GH Secrets by deploy-prod.yml, and injected into containers as env vars — the
api then passes all 6 provider keys into every spawned pipeline via env. Three
weaknesses (T-08): (1) plaintext at rest on the box → any host-root (or the docker.sock
→root path, T-01) reads every key at once; (2) env vars leak via /proc/<pid>/environ,
crash dumps, and subprocess inheritance; (3) no rotation/audit/per-service scoping.
The VPS is now multi-tenant (ADR-114: orrery + future podcast-family apps). Secrets must
therefore be a shared pattern each tenant uses with its own secrets, not a
podcast-only .env. sops/age is already the repo's at-rest tool (TF state,
terraform.tfstate.enc).
Decision¶
Adopt B + C: encrypt secrets at rest with sops/age; deliver them at runtime as files in tmpfs, never as a persistent plaintext file and never as host/compose env. Structure it as a multi-tenant contract mirroring the Caddy edge — infra owns the mechanism; each tenant brings its own encrypted secrets from its own repo.
1. At rest — sops/age, per tenant, in the tenant's own repo¶
- The VPS has one age recipient (keypair). Its public key is published by infra;
its private key lives on the box at
/etc/vps-secrets/age.key(0400 root). Provisioning (decision, 2026-07-08): option (a) — the operator runsage-keygen, keeps the private key in the password manager, stages it as a GH secret;deploy-prod.ymlinstalls it over the tailnet (like.env). It is not baked into cloud-inituser_data(that is retrievable via the Hetzner metadata API). cloud-init only creates the/etc/vps-secretsdir. The box can decrypt any tenant's secrets; tenants only ever need the public key. Mirrors the existing sops/TF-state key flow. - Each tenant commits
infra/secrets/<env>.enc.yamlin its own repo, sops-encrypted to the VPS recipient. Encrypted-at-rest, safe to commit (same posture asterraform.tfstate.enc).
2. At runtime — decrypt to tmpfs, mount as files¶
- A shared
decrypt-secrets.sh(owned by infra, in cloud-init — same shape as the tailscale-serve wrapper) decrypts a tenant's<env>.enc.yamlinto/run/secrets/<tenant>/—/runis tmpfs, so decrypted secrets never touch persistent disk and vanish on reboot. Files are0400, owned by the tenant's runtime uid. - A narrow sudoers entry lets the tenant's
deployinvoke decrypt for its own dir only (/etc/sudoers.d/99-<tenant>-decrypt-secrets), mirroring the caddy-reload / tailscale-serve grants. - Each tenant's compose mounts those files via
secrets:(file-backed), notenvironment:.
3. Consuming the file (two tiers)¶
- C1 (delivery, now): an entrypoint shim reads
/run/secrets/<tenant>/*and exports them for the process — removes the secret from the host/compose env and from disk. Quick; app code unchanged. - C2 (full, with #1161 / ADR-011): the app reads the secret from the file at point
of use (extend ADR-011's credential injection to a
*_FILE/ file-first source) — removes it from the container env too. The public consumer plane (#1161) gets zero provider-key files mounted.
4. Ownership split (mirrors ADR-114 §2)¶
| Piece | Owner | Lives in |
|---|---|---|
| VPS age keypair (private key on box) + published public recipient | infra | this repo cloud-init / staged secret |
decrypt-secrets.sh + /run/secrets tmpfs + narrow sudoers |
infra | this repo cloud-init |
Per-tenant secrets.enc.yaml (sops → VPS recipient) |
tenant | tenant repo infra/secrets/ |
Compose secrets: file mounts + app file-reads |
tenant | tenant repo |
podcast_scraper is tenant zero — it migrates off .env-from-GH-Secrets onto this
pattern; the .env render is replaced by decrypt-to-tmpfs + file mounts.
Consequences¶
Positive
- No plaintext secrets at rest; runtime secrets live only in tmpfs.
- Secrets are files, not env → no
/proc/environ/ crash-dump / subprocess-inheritance leak. - Per-tenant isolation: each tenant's secrets in its own
/run/secrets/<tenant>(perms), encrypted to a key only the box holds; a tenant can rotate independently (re-encrypt + redeploy). - Reuses sops/age already in the repo — no new runtime service, no vendor.
Negative
- The VPS age private key is a single high-value decryption key on the box — protect it
(0400 root; consider TPM/
systemd-credswrapping later). Its compromise = all tenants. - No dynamic/leased creds, no audit log, no automatic rotation — static secrets, manual rotation. → the OpenBao future-idea (#1162): a self-hosted, vanilla, LF-governed Vault fork for dynamic secrets + audit, if/when the ops cost is justified.
- Migration touches
deploy-prod.yml(decrypt step replaces.envrender), the systemd unit, compose (secrets:), and — for C2 — app credential loading.
Neutral
- GH Secrets remains only for bootstrap material (the age key staging), not app secrets.
Alternatives considered¶
- Keep GH-Secrets→
.env— centralized, not tenant-friendly (each tenant would need access to this repo's secrets); leaves plaintext-at-rest + env exposure. Rejected. - OpenBao/Vault now — proper lifecycle but heavy: another service to run + unseal on a single VPS, itself a SPOF + prime target. Deferred to a future-idea issue, not now.
- Managed SaaS (Infisical/Doppler) — managed rotation/audit but a vendor in the secret path (against the vanilla/no-lock ethos) + a bootstrap token still on the box. Rejected as primary.
- systemd-creds — vanilla + encrypted, but Hetzner VMs likely lack a vTPM (key on disk anyway) and it bridges awkwardly into compose. Kept as a possible wrapper for the age key.
Addendum (2026-07-21) — this repo is PUBLIC → Option A first (#1250)¶
The original design commits infra/secrets/<env>.enc.yaml in the tenant's repo. That
silently assumed a private repo. podcast_scraper is public, and committing sops
ciphertext to a public repo is a weaker posture: the ciphertext is public and permanent
(every clone/fork/archive forever), so if the age key ever leaks, all committed secrets are
retroactively decryptable from history — and one accidental un-encrypted commit leaks
plaintext irrevocably. So for the podcast tenant we adopt Option A now and defer the
sops-in-git model to the private-infra move (#1251).
Option A (public-repo-safe): keep GH Actions Secrets as the private source of truth
(already renders .env today). Adopt only the ADR's runtime-delivery half — deliver
secrets as files in RAM (tmpfs), mounted into the containers; the baked-in
docker/secrets-shim.sh exports them as the env vars the app reads. No ciphertext is
committed to this public repo. Delivery is flag-gated (PODCAST_SECRETS_VIA_FILES,
default off = today's .env behaviour); flipping it on the box is the cutover, with the
loud check "expected secret files present + non-empty before compose up" so a missing key
fails the deploy rather than silently booting.
- Kept from the ADR: shim + tmpfs-file delivery, "no plaintext at rest on disk", the
cutover gate. Dropped for Option A: committing
prod.enc.yamlto this public repo, theinfra/secrets/*.yamlsops recipient rule. - Not yet done (C2, #1252): the app
still reads the secrets from env (the shim populates env from files). Removing the
runtime-env exposure (app reads
*_FILEat point of use) is the paired follow-up — the residual only matters after a box compromise.
Rotation runbook (Option A)¶
Any container secret (LLM key, GlitchTip DSN) — rotate = update the GH Secret + redeploy:
gh secret set PROD_OPENAI_API_KEY --repo chipi/podcast_scraper # paste new value
# run deploy-prod (PROD_DEPLOY) → writes the new value to the tmpfs file → containers restart
- No
sops, no touching the box by hand, no re-keying. Deploy re-renders the tmpfs files from GH Secrets every time. - Grafana push token rotates the same way but is a CI/workstation secret (used by
push-grafana-dashboards.sh), not a container secret — it is not in the tmpfs set. - Under the future private-infra model (#1251) with sops, rotation becomes
sops edit infra/secrets/prod.enc.yaml+ commit (private) + redeploy.