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.
23 KiB
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 |
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
lakeservice 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.pyresolves env → file → generates once and persists to theappdatavolume. 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/metricsis 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 persistentdocker logincredential 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!resets both — dev runs uncapped, so setting them would be a lie. (DB_MEMORYis live: thedbservice'smem_limitis 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 sourced, 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 end that, by the only durable
route: a human stops writing the file.
Seed / verify / rotate:
# 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:
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.txton 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; 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 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).
# 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
sops deploy/secrets/common.yaml, addTHERMOGRAPH_NEW_THING: value.- Add the app reader (
os.environ.get("THERMOGRAPH_NEW_THING")). - Commit + deploy. It flows into
/etc/thermograph.envautomatically.
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)
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:
- Install
sops+ageand drop/etc/thermograph/age.key(0400) +/etc/thermograph/secrets-envon the target hosts (provision-secrets.sh). - 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:
That writes exact per-host copies (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'sprod.yaml/beta.yaml); factor shared values intocommon.yamllater. Commit. - Dry-run: on the box, render to a temp file and
diffagainst the live/etc/thermograph.env— must be byte-identical before cutover. - Merge the
deploy.shwiring; run one deploy; confirm the app is healthy.
Safety
- CI (
.forgejo/workflows/secrets-guard.yml) fails if any*.yamlhere 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.yamlholds 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.shfails closed: if therender-secrets.shit finds cannot honourTHERMOGRAPH_SECRETS_SKIP_COMMON, it aborts the deploy rather than render the shared production layer onto the dev box.