All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
shell-lint / shellcheck (pull_request) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 2s
PR build (required check) / validate-observability (pull_request) Successful in 18s
Environments stop being machines. Until now each box WAS an environment --
"beta" named both a deploy target and a host, "the desktop" named both the
operator's computer and the dev server -- so every path could assume one
environment per host and hardcode /opt/thermograph, /etc/thermograph.env and
ports 8137/8080. That assumption ends here:
vps1 75.119.132.91 Forgejo, Grafana/Loki, the portfolio site, and DEV
(own Postgres, mesh-only on 10.10.0.2:8137)
vps2 169.58.46.181 PROD and BETA as two Swarm stacks sharing one
TimescaleDB instance, plus Centralis, Postfix, backups
desktop AI model hosting + flex Swarm capacity, no environment
Nothing live has moved; the ordered cutover is in
infra/deploy/RUNBOOK-vps1-vps2-cutover.md.
deploy/env-topology.sh is the single source of truth: env -> host, checkout,
branch, deploy mode, stack name, env file, LB ports, DB role/database, service
prefix. THERMOGRAPH_ENV is the input, and on vps2 it is the only thing
distinguishing a beta deploy from a prod one -- deploy.sh refuses to run when it
disagrees with the checkout it was invoked from. The host-wide secrets-env and
deploy-mode markers survive only as a fallback for a single-environment box.
Beta gets its own stack file rather than an overlay (a merge cannot REMOVE the
db service, and beta having no database of its own is the point). Its services
are prefixed beta-*: Swarm registers a service's short name as a DNS alias on
every network it joins, so two stacks both calling a service `web` on the shared
data network would let prod's frontend resolve a beta task. Prod's stack, env
and LB config are untouched.
One Postgres, two databases with two roles: deploy/db/provision-env-db.sh
creates thermograph_beta (NOSUPERUSER, owns only its own database, CONNECT
revoked from PUBLIC) plus a <role>_ro for ad-hoc queries, and refuses to run for
the environment that owns the instance -- doing so would demote prod's bootstrap
superuser.
Fixes that co-residency would otherwise have broken silently:
- CI secrets are keyed by host (VPS1_SSH_*, VPS2_SSH_*). SSH_* meant "beta" and
also "the Forgejo box" because those shared a machine; that conflation is what
once had the prod backup dumping beta.
- The nightly backup dumps BOTH application databases to separate off-box
prefixes, and fails loudly on a missing one instead of skipping it.
- Alloy stopped deriving the `host` label from the node -- on vps2 that would
have filed every beta line as prod, feeding prod's alert rules with beta's
traffic. It is now derived per source, with a new `node` label for the machine,
and beta's log volume is mounted via a vps2-only overlay.
- ops/dbq.sh, ops/iceberg.sh and the secrets seed scripts derived their SSH
target and env-file path from the topology instead of hardcoding beta to
75.119.132.91 -- which is vps1's address now.
- Dev's overlay binds DEV_BIND_ADDR (loopback by default, the mesh address on
vps1) instead of 0.0.0.0: correct for a home LAN box, a public exposure of
unreviewed branches on a VPS.
Terraform's hosts variable is keyed by machine with a nested environments map,
and main.tf flattens (host, environment) pairs so two environments on one box
cannot share a checkout. Caddy's single reference config is split per host.
Docs, onboarding and the runbooks are updated throughout, including the security
rationales that co-residency makes false: separate SSH credentials no longer put
a host boundary between beta and prod, and the boundary that remains is the
database and the filesystem.
410 lines
23 KiB
Markdown
410 lines
23 KiB
Markdown
# 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`](../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` | The 16 values identical on **prod and beta** — VAPID keypair, metrics token, IndexNow key, `REGISTRY_TOKEN`, S3 endpoint/bucket and both S3 keypairs, plus shared non-secret config. **Not layered under `dev`** — see below | ✅ |
|
|
| `prod.yaml` | Prod's own — the three held-back credentials below, sizing (`APP_CPUS`, `DB_CPUS`, `DB_MEMORY`, `WORKERS`), `THERMOGRAPH_BASE_URL`, and the Discord + mail credentials that exist nowhere else | ✅ |
|
|
| `beta.yaml` | Beta's own — the three held-back credentials, `THERMOGRAPH_BASE_URL`, `APP_CPUS`/`WORKERS`, plus `THERMOGRAPH_MAIL_BACKEND=console` and `THERMOGRAPH_DISCORD_BOT=0`. Its `POSTGRES_PASSWORD` and `THERMOGRAPH_DATABASE_URL` name beta's **own role and database** (`thermograph_beta`) on the instance it shares with prod. `DB_CPUS`/`DB_MEMORY` were **removed**: beta no longer runs a database of its own, and leaving them would have implied it sized one — the shared instance is sized by `prod.yaml`'s values (see `deploy/stack/thermograph-stack.yml`'s `db` service) | ✅ |
|
|
| `dev.yaml` | Dev's own (vps1) — **self-contained**, 12 values, no production credential among them | ✅ |
|
|
| `centralis.prod.yaml` | **Centralis's** nine variables, rendered to `/etc/centralis.env` on prod. A different service, a different output file, a different renderer — see below | ✅ |
|
|
| `example.yaml` | Format reference / CI fixture (fake values) | ✅ |
|
|
| `../../.sops.yaml` | Which age recipient files are encrypted to (plaintext config) | — |
|
|
|
|
### Three credentials are deliberately NOT in `common.yaml`
|
|
|
|
`POSTGRES_PASSWORD`, `THERMOGRAPH_AUTH_SECRET` and `THERMOGRAPH_DATABASE_URL`
|
|
hold **different values on prod and beta today** (beta has its own database
|
|
role/password on the shared instance, and its own auth secret), so unlike the
|
|
rest of `common.yaml` they were never candidates for sharing on values alone.
|
|
They stay per-environment for a reason that matters more now than it used to:
|
|
|
|
**These are the credentials that let one environment act as another.** With
|
|
matching values, a foothold in one environment's rendered env file is a
|
|
foothold on the other's database and the other's session signing. That used
|
|
to matter because beta and prod were separate boxes; it matters for a
|
|
different reason now that they're co-resident on vps2 — keeping the
|
|
credentials themselves separate is what stops co-location on one host from
|
|
also becoming equivalence at the credential level. `deploy/db/provision-env-db.sh`
|
|
enforces the same boundary from the database side: beta's role
|
|
(`thermograph_beta`) is `NOSUPERUSER`/`NOCREATEDB`/`NOCREATEROLE`, owns only
|
|
its own database, and `CONNECT` is revoked from `PUBLIC` on it — so even a
|
|
leaked beta credential cannot reach prod's data, and the reverse.
|
|
|
|
**What actually separates beta and prod now, stated plainly:** they share a
|
|
host by design (see `deploy/env-topology.sh`'s header), so a host-level
|
|
compromise of vps2 is shared between them — separate SSH credentials no
|
|
longer buy a host boundary between beta and prod, the way they once did when
|
|
beta and prod were different machines. What remains is isolation at the
|
|
**database** level (separate roles, separate databases, `CONNECT` revoked from
|
|
`PUBLIC`) and the **file** level (separate checkouts, separate rendered env
|
|
files, separate stack env files, separate loopback ports). Keeping
|
|
`POSTGRES_PASSWORD`/`THERMOGRAPH_AUTH_SECRET`/`THERMOGRAPH_DATABASE_URL` out of
|
|
a shared file is part of that file-level isolation: nothing on vps2 would ever
|
|
hand you prod's auth secret merely because you have beta's.
|
|
|
|
**vps1 is the box that must never hold prod credentials.** It runs Forgejo,
|
|
Forgejo's CI runner, Grafana, and the `dev` environment — which runs whatever
|
|
branch is currently in flight, reviewed or not. That is the actual reason
|
|
`dev` renders `dev.yaml` **alone** and never layers `common.yaml` (see below):
|
|
not squeamishness about a dev box, but that vps1 is uniquely positioned to
|
|
leak whatever it's handed, and `common.yaml` is the fleet's shared production
|
|
credential set.
|
|
|
|
Keeping the three held-back credentials per-environment costs nothing (one
|
|
line each) and buys the ability for them to diverge further, or for a future
|
|
environment to be added without inheriting anyone else's values by accident.
|
|
|
|
`dev.yaml` is the case that already differs completely: all three of its
|
|
values are its own, generated on vps1 and never shared with prod or beta.
|
|
|
|
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 an explicit `THERMOGRAPH_ENV`/argument on vps2 (which runs
|
|
two environments) or `/etc/thermograph/secrets-env` as a fallback elsewhere —
|
|
see `deploy/env-topology.sh`.
|
|
|
|
### `dev` is vaulted, but renders `dev.yaml` **alone**
|
|
|
|
All three environments are managed here now. `dev` is the one that does **not**
|
|
get `common.yaml` layered under it: `deploy-dev.sh` exports
|
|
`THERMOGRAPH_SECRETS_SKIP_COMMON=1`, and the renderer skips the shared layer.
|
|
|
|
That is not squeamishness about a dev box — it is what `common.yaml` contains.
|
|
Eleven of its sixteen values are live production credentials: `REGISTRY_TOKEN`,
|
|
`THERMOGRAPH_S3_ACCESS_KEY`/`_SECRET_KEY` and
|
|
`THERMOGRAPH_LAKE_S3_ACCESS_KEY`/`_SECRET_KEY` (one keypair, **read-write**, on the
|
|
bucket that also holds prod's database backups — see `../../ops/README.md`),
|
|
`THERMOGRAPH_VAPID_PRIVATE_KEY`/`_PUBLIC_KEY` (they sign Web Push to real
|
|
subscribers), `THERMOGRAPH_INDEXNOW_KEY`, `THERMOGRAPH_METRICS_TOKEN`. The render
|
|
is a **plaintext concatenation**, so layering it would put every one of those in
|
|
`/etc/thermograph.env` on the dev box and, through `env_file:`, into the
|
|
environment of every container in the dev stack.
|
|
|
|
The dev box is **vps1** — the same box that runs Forgejo and its CI. Its
|
|
`docker`-labelled runner jobs get the host docker socket automounted, and the
|
|
stack it runs is the `dev` branch — code that has not been through the gate
|
|
that guards prod. Handing that the estate's read-write object-storage keys and
|
|
the push signing key is a strictly larger blast radius than anything the vault
|
|
buys back. An override in `dev.yaml` would not help: last-wins governs
|
|
*consumers*, but the production value is still physically a line in the file.
|
|
|
|
One value in `common.yaml` is also simply **wrong** for dev:
|
|
`THERMOGRAPH_COOKIE_SECURE=1` is right for the TLS hosts and silently breaks login
|
|
over dev's plain-HTTP mesh-only URL. `dev.yaml` sets `0`.
|
|
|
|
So `dev.yaml` is self-contained. Where the value is shared-but-harmless
|
|
(`PORT`, `THERMOGRAPH_BASE`, `TIMESCALEDB_TAG`) it carries its own copy — a
|
|
duplicated constant is the cheap half of this trade.
|
|
|
|
**What `dev.yaml` holds** (12 keys):
|
|
|
|
| Key | Why |
|
|
|-----|-----|
|
|
| `POSTGRES_PASSWORD`, `THERMOGRAPH_DATABASE_URL` | dev's own. Deliberately the weak, well-known `thermograph-dev` — the value dev's `pgdata` volume is already initialized with, and the DB is never published off the compose network. Kept per-host for the reason below, and *not* shared with prod |
|
|
| `THERMOGRAPH_AUTH_SECRET` | dev's **own**, freshly generated. Fixes a live crash loop: the `daemon` service refuses to start without it (it derives the `/internal/*` token from it), so dev's daemon had been restarting every 60s. Prod's value must never be here — it signs sessions and verification links |
|
|
| `THERMOGRAPH_BASE_URL` | `http://10.10.0.2:8137` (vps1's mesh address). Unset, the app defaults to `https://thermograph.org`, so dev's IndexNow pings and Discord links claimed to be prod |
|
|
| `THERMOGRAPH_COOKIE_SECURE=0` | plain HTTP on the mesh-only URL |
|
|
| `THERMOGRAPH_MAIL_BACKEND=console`, `THERMOGRAPH_DISCORD_BOT=0` | explicit fail-safes. Both are already the code defaults; stating them means dev cannot start mailing or open a Discord gateway by inheriting a value |
|
|
| `PORT`, `THERMOGRAPH_BASE`, `TIMESCALEDB_TAG`, `DB_MEMORY`, `WORKERS` | non-secret config that would otherwise have come from `common.yaml` |
|
|
|
|
**Deliberately absent, and why nothing breaks:**
|
|
|
|
- **Both S3 keypairs.** Without them the `lake` service answers 503 and history
|
|
reads fall through to the Open-Meteo archive — an accelerator, never a
|
|
dependency (`docker-compose.yml`, `backend/data/climate.py`).
|
|
- **VAPID keypair.** `backend/notifications/push.py` resolves env → file →
|
|
generates once and persists to the `appdata` volume. Dev gets its own identity
|
|
for free; prod's private key stays off the box.
|
|
- **`THERMOGRAPH_INDEXNOW_KEY`.** Same env → file → self-generate ladder
|
|
(`backend/indexnow.py`). Prod's key is what authenticates URL submissions *for
|
|
thermograph.org*.
|
|
- **`THERMOGRAPH_METRICS_TOKEN`.** With no token `/api/v2/metrics` is
|
|
direct-loopback-only, which is exactly right on a mesh-only box; dev's Alloy
|
|
agent ships logs, not metrics, so nothing scrapes it.
|
|
- **`REGISTRY_TOKEN`.** dev pulls with the persistent `docker login` credential
|
|
already in the host's docker config, like the prod/beta CI paths. A vault copy
|
|
would only duplicate a credential the box already has into a more readable file.
|
|
- **All Discord and mail credentials.** Dev must not be able to act as the real
|
|
bot or send real mail.
|
|
- **`APP_CPUS`, `DB_CPUS`.** `docker-compose.dev.yml` `!reset`s both — dev runs
|
|
uncapped, so setting them would be a lie. (`DB_MEMORY` *is* live: the `db`
|
|
service's `mem_limit` is not reset.)
|
|
|
|
If dev ever needs one of these for real, it gets **its own** — a dev Discord app,
|
|
a scoped read-only bucket key — never a copy of prod's.
|
|
|
|
## Centralis: its own file, its own renderer
|
|
|
|
`/etc/centralis.env` on prod is the control plane's env file — four real
|
|
credentials (`CENTRALIS_AUTH_TOKEN`, `CENTRALIS_TOKENS`,
|
|
`CENTRALIS_FORGEJO_TOKEN`, `DISCORD_TOKEN`) and five non-secret settings. It was
|
|
**hand-edited** until 2026-07-24, when a hand-edit wrote `CENTRALIS_TOKENS` as
|
|
bare JSON into a file that gets `source`d, bash stripped the double quotes,
|
|
`{"emi":"…"}` arrived as `{emi:…}`, and Centralis — correctly failing closed on
|
|
malformed JSON — dropped to the single `shared` identity. Nothing logged an
|
|
error. It looked exactly like the file had never been written.
|
|
|
|
`centralis.prod.yaml` + `render_centralis_secrets()` in
|
|
[`../render-secrets.sh`](../render-secrets.sh) end that, by the only durable
|
|
route: **a human stops writing the file.**
|
|
|
|
Seed / verify / rotate:
|
|
|
|
```sh
|
|
# One-time seed from the live box (plaintext never leaves prod):
|
|
deploy/secrets/seed-centralis-on-host.sh prod agent@169.58.46.181
|
|
|
|
# Prove a render matches the live file before cutting over. Renders to a 0700
|
|
# scratch dir on the host, sources both, compares — key NAMES and a verdict only:
|
|
deploy/secrets/verify-centralis-render.sh prod agent@169.58.46.181
|
|
|
|
# Thereafter, rotation is edit -> commit -> merge to main -> redeploy Centralis:
|
|
sops deploy/secrets/centralis.prod.yaml
|
|
```
|
|
|
|
Merging to `main` is what puts the new ciphertext on prod (`infra-sync.yml`
|
|
fast-forwards `/opt/thermograph`); the render happens when Centralis is
|
|
redeployed. See Centralis's own `deploy/README.md` §2 and §5.
|
|
|
|
### Why `CENTRALIS_*` is not just added to `prod.yaml`
|
|
|
|
It is the obvious move, and it hands the estate's control-plane bearer token to
|
|
every application container.
|
|
|
|
`/etc/thermograph.env` is loaded into the app stack through `env_file:` and
|
|
through `. /host/thermograph.env` in the stack services' entrypoints. Every key
|
|
in it is in the environment of every container in the stack. `CENTRALIS_TOKENS`
|
|
and `CENTRALIS_AUTH_TOKEN` authenticate an endpoint that carries `sql_query`
|
|
with `write:true` and `run_on_host` over production, and `CENTRALIS_FORGEJO_TOKEN`
|
|
is repo-scope on the monorepo. Putting them in `prod.yaml` would mean that a
|
|
read-anything bug in the web backend, or a leaked container env dump, is a
|
|
foothold on the control plane. The two files are separate for a reason that
|
|
predates the vault, and the vault is not a reason to merge them.
|
|
|
|
The converse — pointing Centralis's deploy at `/etc/thermograph.env` — has the
|
|
same problem from the other side, plus 32 unrelated variables in the shell that
|
|
runs its compose, plus a lifecycle coupling: every app deploy re-renders that
|
|
file, and Centralis and the app stack are deliberately deployed on different
|
|
cadences.
|
|
|
|
### Why this file is fully quoted and `/etc/thermograph.env` is not
|
|
|
|
Different consumers.
|
|
|
|
`/etc/thermograph.env` is read by docker-compose `env_file:` and by systemd
|
|
`EnvironmentFile=`, which parse `KEY=value` literally. Raw `sops --output-type
|
|
dotenv` is the right representation for it, and it must stay that way: Centralis's
|
|
own `secrets_inventory` / `secrets_render` tools compare rendered values against
|
|
that file **textually**, so quoting it would report all 32 keys as value-changed.
|
|
|
|
`/etc/centralis.env` is read by exactly one thing, and it is a shell:
|
|
|
|
```sh
|
|
sudo bash -c 'set -a && . /etc/centralis.env && set +a && docker compose up -d --build'
|
|
```
|
|
|
|
`set -a; . file` runs every line through bash's full expansion pipeline. Raw
|
|
dotenv output is *unsafe* there and sops cannot make it safe — it emits values
|
|
verbatim. So `render_centralis_secrets` decrypts to **JSON** (lossless; dotenv
|
|
escapes a newline to a literal `\n` and cannot be told apart from a literal
|
|
backslash-n) and writes POSIX single-quoted assignments, in which every byte is
|
|
literal except `'` itself.
|
|
|
|
Then it **proves the file** before letting it near `/etc`: it sources the
|
|
rendered file in a clean shell (`env -i`), reads every variable back, and
|
|
compares to the plaintext it started from. If any value does not survive, or if
|
|
`CENTRALIS_TOKENS` does not parse as a JSON object of subject → token, the
|
|
render aborts and the existing file is left untouched. The 2026-07-24 bug cannot
|
|
be written by this path, and cannot be written *through* it either.
|
|
|
|
### Scoped by environment even though Centralis is prod-only
|
|
|
|
The file is `centralis.prod.yaml`, not `centralis.yaml`, and the renderer takes
|
|
the `prod` from `/etc/thermograph/secrets-env` like everything else here. An
|
|
unscoped file would render prod's bearer tokens onto any host that ran the
|
|
function. If Centralis ever gains a second home, that is a new file rather than
|
|
a redesign — and the two boxes get different tokens, which is the point.
|
|
|
|
`centralis.prod.yaml` is **not** layered under `common.yaml`. Centralis shares
|
|
no key with the app stack (verified: zero overlap between the two live env
|
|
files), so layering would only push the VAPID private key, both S3 keypairs and
|
|
`REGISTRY_TOKEN` into a file that wants none of them.
|
|
|
|
## 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 VPS that renders at deploy time.
|
|
- **Back it up** in the password manager. The public recipient is in `.sops.yaml`.
|
|
|
|
On **vps1** (dev) those two can end up being the same file. `render-secrets.sh`
|
|
falls back to `sudo cat` for a root-owned key, and `deploy-dev.sh` still assumes
|
|
the account driving a CI-triggered dev deploy has no passwordless sudo and no
|
|
tty to answer a `sudo` prompt (a `systemd --user` Forgejo-runner service) —
|
|
inherited from the old desktop setup, where that was true of the whole box.
|
|
Whether it's still true of vps1's CI-runner account specifically (as opposed
|
|
to the `agent` login, which does have passwordless sudo there per `ACCESS.md`)
|
|
wasn't re-verified for this pass; `deploy-dev.sh` points `THERMOGRAPH_AGE_KEY`
|
|
at the operator's keyring as a fallback either way, so the box keeps **one**
|
|
copy of the recovery root rather than two regardless of which account ends up
|
|
mattering.
|
|
|
|
## Prerequisites (once per machine)
|
|
|
|
Install `sops` + `age` (single static binaries). On a host, use
|
|
[`deploy/provision-secrets.sh`](../provision-secrets.sh); on your laptop:
|
|
|
|
```sh
|
|
# 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
|
|
|
|
```sh
|
|
sops deploy/secrets/common.yaml # opens DECRYPTED in $EDITOR; re-encrypts on save
|
|
git commit -am "rotate <thing>" && git push forgejo
|
|
# beta and prod both auto-deploy on push (main -> beta, release -> prod) via
|
|
# the Deploy workflow. To force it by hand on vps2, say which environment:
|
|
ssh agent@169.58.46.181 'cd /opt/thermograph && git pull && THERMOGRAPH_ENV=prod deploy/deploy.sh'
|
|
ssh agent@169.58.46.181 'cd /opt/thermograph-beta && git pull && THERMOGRAPH_ENV=beta deploy/deploy.sh'
|
|
```
|
|
|
|
A host-specific value (e.g. `POSTGRES_PASSWORD`) is the same, editing `prod.yaml` /
|
|
`beta.yaml` / `dev.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. (That applies to dev too. `dev.yaml` deliberately carries the value
|
|
dev's existing `pgdata` volume was initialized with, so nothing rotates the moment
|
|
dev is provisioned; rotating it later is the two-step above, plus the matching
|
|
default in `deploy-dev.sh`.)
|
|
|
|
Dev is edited and deployed exactly like the others — `sops deploy/secrets/dev.yaml`,
|
|
commit, and the next `deploy-dev.sh` run (merge to `dev`, or by hand) renders it.
|
|
|
|
## Setting up a new dev machine
|
|
|
|
Dev is provisioned like any fleet host now (`deploy/provision-dev.sh`, run as
|
|
root on vps1), not the sudo-free desktop bootstrap this predates. The
|
|
secrets-specific steps below are still worth doing by hand rather than
|
|
folding into that script: `provision-secrets.sh` is **not** the right tool
|
|
here — it installs the age key root-owned at `/etc/thermograph/age.key`,
|
|
which the CI-runner-driven render may not be able to read (see the age-key
|
|
note above).
|
|
|
|
```sh
|
|
# 1. sops + age on PATH, and the age private key in the operator's keyring:
|
|
# ~/.config/sops/age/keys.txt (0600, restored from the password manager)
|
|
sops --version && age --version
|
|
|
|
# 2. Name the environment. This marker is what switches rendering ON — until it
|
|
# exists the box renders nothing, which is the safe default.
|
|
sudo mkdir -p /etc/thermograph
|
|
printf 'dev\n' | sudo tee /etc/thermograph/secrets-env >/dev/null
|
|
sudo chmod 0644 /etc/thermograph/secrets-env
|
|
|
|
# 3. Pre-create the render target owned by the deploying user, so no deploy ever
|
|
# needs sudo (the renderer writes in place when the file is writable — the same
|
|
# path beta's non-root `deploy` user takes).
|
|
sudo install -m 0600 -o "$USER" -g "$USER" /dev/null /etc/thermograph.env
|
|
|
|
# 4. Dry-run the render before letting a deploy do it, and read the key names back.
|
|
# Nothing from common.yaml may appear here.
|
|
THERMOGRAPH_SECRETS_SKIP_COMMON=1 \
|
|
SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt \
|
|
sops -d --input-type yaml --output-type dotenv deploy/secrets/dev.yaml | sed 's/=.*/=/'
|
|
|
|
# 5. Deploy. deploy-dev.sh refuses to run if render-secrets.sh in the checkout
|
|
# cannot honour THERMOGRAPH_SECRETS_SKIP_COMMON, rather than leak.
|
|
APP_DIR=/opt/thermograph-dev bash deploy/deploy-dev.sh
|
|
```
|
|
|
|
To take a dev box **back** off the vault, delete `/etc/thermograph/secrets-env`:
|
|
presence detection turns rendering off and `deploy-dev.sh` falls back to its
|
|
built-in `POSTGRES_PASSWORD` default.
|
|
|
|
A *different* dev machine (a laptop running `make dev-up` locally, or a future
|
|
second dev host) needs its own values, not a copy of vps1's: give it its own
|
|
`<env>.yaml` and its own marker name, so the two can never be confused for one
|
|
host — the same argument as the three held-back credentials above.
|
|
|
|
## 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.
|
|
|
|
`common.yaml` reaches prod and beta only. If dev needs the new thing, decide
|
|
which it is and act on it: harmless config → copy it into `dev.yaml`; a real
|
|
credential → mint dev **its own** and put that in `dev.yaml`; neither → leave dev
|
|
without it and make sure the code degrades rather than crashes. Do not reach for
|
|
prod's copy.
|
|
|
|
## Rotate the age identity itself (re-key)
|
|
|
|
```sh
|
|
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)
|
|
|
|
**Historical** — this ran once, before the vps1/vps2 split, when beta was its
|
|
own box at `75.119.132.91`. That address is **vps1** now (dev + Forgejo +
|
|
Grafana), not beta; re-running the beta line below as written would seed
|
|
`beta.yaml` from the wrong host. Kept for the shape of the process, which is
|
|
unchanged if a new environment is ever bootstrapped the same way — just point
|
|
`seed-from-live.sh` at wherever that environment actually lives today.
|
|
|
|
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 the target hosts (`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), e.g. as originally run:
|
|
```sh
|
|
deploy/secrets/seed-from-live.sh prod agent@169.58.46.181
|
|
deploy/secrets/seed-from-live.sh beta agent@75.119.132.91 # beta's address AT THE TIME; now vps1's
|
|
```
|
|
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 — **except on dev**, where the box *is* the runner. That is not a hole
|
|
this vault can close; it is the reason `dev.yaml` holds nothing that would matter
|
|
if the box were owned. Keep it that way: the day dev needs a real credential,
|
|
mint it a scoped one of its own.
|
|
- `deploy-dev.sh` **fails closed**: if the `render-secrets.sh` it finds cannot honour
|
|
`THERMOGRAPH_SECRETS_SKIP_COMMON`, it aborts the deploy rather than render the
|
|
shared production layer onto the dev box.
|