thermograph/infra/deploy/secrets
emi de8e847f9f
All checks were successful
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m23s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 1m22s
Sync infra to hosts / sync-beta (push) Successful in 9s
Sync infra to hosts / sync-prod (push) Successful in 8s
secrets-guard / encrypted (push) Successful in 6s
shell-lint / shellcheck (push) Successful in 9s
Deploy backend to beta VPS / deploy (push) Successful in 2m4s
Deploy frontend to beta VPS / deploy (push) Successful in 1m8s
shell: add shellcheck CI guard and drive the tree to zero findings (#19)
Adds .forgejo/workflows/shell-lint.yml (pinned shellcheck v0.11.0 + sha256, -x,
default severity, fail on any finding, not path-filtered) and drives all 26
scripts to zero findings.

Two defects shellcheck cannot see:

render-secrets.sh left DECRYPTED vault contents in /tmp whenever a sops decrypt
failed -- the caller's set -e aborted the function before either cleanup ran.
Now removed on every exit path, with `|| return 1` on both sops calls so a
decrypt failure can never write a partial /etc/thermograph.env regardless of the
caller's shell options. Explicitly not a `trap ... RETURN`: such a trap set in a
sourced function persists into the caller's shell and re-fires when the caller's
next `.`/source completes, where the function-local tmp is unset -- fatal and
silent under deploy.sh's set -u. The file now records that reasoning.

autoscale.sh ran `set -eu` without pipefail while piping docker stats into awk,
so a failed left side was swallowed and the loop autoscaled on empty input.
Promoted to pipefail with a missed sample treated as a skip; verified busybox ash
in docker:27-cli supports it.

Also: capture-fixtures.sh's `jq . || cat` ran cat after jq had consumed stdin,
silently writing truncated fixtures; deploy.sh/deploy-stack.sh `# shellcheck
source=` paths corrected for the monorepo layout.
2026-07-23 22:26:05 +00:00
..
beta.yaml Subtree-merge thermograph-infra (origin/main) into infra/ 2026-07-22 22:01:11 -07:00
dry-run-render.sh shell: add shellcheck CI guard and drive the tree to zero findings (#19) 2026-07-23 22:26:05 +00:00
example.yaml Subtree-merge thermograph-infra (origin/main) into infra/ 2026-07-22 22:01:11 -07:00
prod.yaml Subtree-merge thermograph-infra (origin/main) into infra/ 2026-07-22 22:01:11 -07:00
README.md Subtree-merge thermograph-infra (origin/main) into infra/ 2026-07-22 22:01:11 -07:00
seed-encrypt-on-host.sh Subtree-merge thermograph-infra (origin/main) into infra/ 2026-07-22 22:01:11 -07:00
seed-from-live.sh Subtree-merge thermograph-infra (origin/main) into infra/ 2026-07-22 22:01:11 -07:00

Secrets — the git-native vault (SOPS + age)

The single source of truth for Thermograph's secrets is the set of SOPS-encrypted YAML files in this directory. They're committed to the repo encrypted (values only — keys stay readable so diffs are meaningful) and rendered into /etc/thermograph.env at deploy time by deploy/render-secrets.sh, which the prod/beta deploy.sh invokes.

Cycling a key is: edit → commit → deploy. No SSHing in to hand-edit a root file, no per-host duplication.

Files

File Holds Encrypted?
common.yaml Secrets shared by every host — Discord tokens/secret, VAPID keypair, THERMOGRAPH_AUTH_SECRET, metrics token, indexnow key, SMTP creds
prod.yaml Prod-only overrides — POSTGRES_PASSWORD, REGISTRY_TOKEN
beta.yaml Beta-only overrides
example.yaml Format reference / CI fixture (fake values)
../../.sops.yaml Which age recipient files are encrypted to (plaintext config)

At deploy the renderer concatenates common.yaml then <env>.yaml, so a host value wins (env_file / source take the last occurrence of a duplicate key). <env> comes from /etc/thermograph/secrets-env on the box (prod/beta/dev). dev has no real secrets and is intentionally not wired in — see deploy-dev.sh.

The age key (the one thing that matters)

  • The private key is the single recovery root. Lose it and every secret here is unrecoverable; leak it and every secret is compromised.
  • It is never in the repo. It lives at:
    • ~/.config/sops/age/keys.txt on the operator's machine (for editing), and
    • /etc/thermograph/age.key (0400) on each host that renders at deploy time.
  • Back it up in the password manager. The public recipient is in .sops.yaml.

Prerequisites (once per machine)

Install sops + age (single static binaries). On a host, use deploy/provision-secrets.sh; on your laptop:

# see the pinned URLs in provision-secrets.sh, or:
go install github.com/getsops/sops/v3/cmd/sops@latest   # if you have Go

Everyday: rotate a key

sops deploy/secrets/common.yaml        # opens DECRYPTED in $EDITOR; re-encrypts on save
git commit -am "rotate <thing>" && git push forgejo
# beta auto-deploys on push to main. prod (no CI) — one command:
ssh agent@169.58.46.181 'cd /opt/thermograph && git pull && deploy/deploy.sh'

A host-specific value (e.g. POSTGRES_PASSWORD) is the same, editing prod.yaml / beta.yaml instead. POSTGRES_PASSWORD is special: the database only reads it on a fresh volume, so also ALTER ROLE thermograph PASSWORD '…' inside the running Postgres to match — the env change alone won't re-key an initialized DB.

Add a new secret

  1. sops deploy/secrets/common.yaml, add THERMOGRAPH_NEW_THING: value.
  2. Add the app reader (os.environ.get("THERMOGRAPH_NEW_THING")).
  3. Commit + deploy. It flows into /etc/thermograph.env automatically.

Rotate the age identity itself (re-key)

age-keygen -o new.key                              # new identity
# add the new PUBLIC recipient alongside the old in .sops.yaml, then:
sops updatekeys deploy/secrets/*.yaml              # re-encrypt to BOTH keys
# distribute new.key to /etc/thermograph/age.key on every host + ~/.config/sops/age,
# deploy once so every host can decrypt with the new key, then remove the OLD
# recipient from .sops.yaml and:
sops updatekeys deploy/secrets/*.yaml              # drop the old key
git commit -am "rotate age identity" && push + deploy

First-time bootstrap (seed from the live boxes)

Order matters — values must match the live env exactly so AUTH_SECRET / VAPID / POSTGRES_PASSWORD don't rotate unintentionally:

  1. Install sops+age and drop /etc/thermograph/age.key (0400) + /etc/thermograph/secrets-env on prod & beta (provision-secrets.sh).
  2. Seed each host's file from its live env with the helper (pulls over SSH, encrypts, and verifies the render round-trips — run it on your own machine, it reads live secrets):
    deploy/secrets/seed-from-live.sh prod agent@169.58.46.181
    deploy/secrets/seed-from-live.sh beta agent@75.119.132.91
    
    That writes exact per-host copies (prod.yaml/beta.yaml); factor shared values into common.yaml later. Commit.
  3. Dry-run: on the box, render to a temp file and diff against the live /etc/thermograph.env — must be byte-identical before cutover.
  4. Merge the deploy.sh wiring; run one deploy; confirm the app is healthy.

Safety

  • CI (.forgejo/workflows/secrets-guard.yml) fails if any *.yaml here is committed unencrypted.
  • Rendering is presence-detected: a host without the age key + marker keeps its existing /etc/thermograph.env, so nothing breaks before a host is migrated.
  • Rendering happens on the target box, so CI/runners never see the age key or the plaintext.