dev: serve at dev.thermograph.org behind basic auth, bound to loopback
All checks were successful
PR build (required check) / changes (pull_request) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / build-backend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 6s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 2s

Dev was mesh-only: bound to 10.10.0.2, no DNS record, no Caddy site. A public
record now exists for it, so it gets a real front door instead of a name that
resolves to a timeout.

Three things keep that acceptable on a box that also runs Forgejo and its CI
runner, and all three are load-bearing together:

  - the stack binds LOOPBACK, so the site block is the only route in — not even
    the mesh reaches the app directly, which is what makes the guard total
    rather than decorative,
  - HTTP basic auth, so a stumbled-upon URL is not a running build,
  - X-Robots-Tag: noindex, so dev never competes with thermograph.org for the
    same content in a search index.

The credential in Caddyfile.vps1 is a bcrypt hash, not a password.
This commit is contained in:
Emi Griffith 2026-07-26 00:45:58 -07:00
parent 64986189e1
commit fed307e75c
5 changed files with 77 additions and 31 deletions

View file

@ -5,13 +5,7 @@
# git.thermograph.org -> Forgejo (see deploy/forgejo/caddy-git.conf) # git.thermograph.org -> Forgejo (see deploy/forgejo/caddy-git.conf)
# dashboard.thermograph.org -> Grafana (see observability/caddy-grafana.conf) # dashboard.thermograph.org -> Grafana (see observability/caddy-grafana.conf)
# #
# and hosts the **dev** environment, which is deliberately ABSENT from this # and hosts the **dev** environment at dev.thermograph.org (below).
# file. Dev has no site block, no DNS record and no certificate: it publishes on
# the WireGuard address only (http://10.10.0.2:8137) and is reachable from the
# mesh and nowhere else. Dev runs whatever branch is in flight, on the same box
# as Forgejo and its CI — putting it behind a public hostname would be handing
# the internet an unreviewed build. If you ever need it reachable from a phone,
# join the phone to the mesh; do not add a site block here.
# #
# beta.thermograph.org is NOT here either — beta moved to vps2, next to prod. # beta.thermograph.org is NOT here either — beta moved to vps2, next to prod.
# That is the one edit in this file most likely to be made by mistake: a "beta" # That is the one edit in this file most likely to be made by mistake: a "beta"
@ -54,6 +48,52 @@ http://75.119.132.91 {
redir https://emigriffith.dev{uri} permanent redir https://emigriffith.dev{uri} permanent
} }
# Dev. Publicly resolvable, deliberately NOT publicly usable.
#
# Dev runs whatever branch is in flight — including unreviewed ones — on the
# same box as Forgejo and its CI runner. Three things make that acceptable, and
# all three are load-bearing:
#
# 1. The stack binds LOOPBACK (deploy/env-topology.sh sets DEV_BIND_ADDR to
# 127.0.0.1), so this site block is the only route in. Not even the mesh
# reaches the app directly.
# 2. basic auth, so a stumbled-upon URL is not a running build.
# 3. X-Robots-Tag: noindex, so dev never competes with thermograph.org in a
# search index the way a second copy of the same content otherwise would.
#
# Only :8137 is proxied. The dev overlay does not publish the frontend at all
# (docker-compose.dev.yml `ports: !reset null`) — the backend reverse-proxies to
# it internally, so one upstream serves the whole site here, unlike vps2 where
# Caddy path-splits across two ports.
#
# The credential below is a bcrypt hash, not a password. Rotate with:
# caddy hash-password --plaintext '<new>'
dev.thermograph.org {
encode zstd gzip
header {
X-Robots-Tag "noindex, nofollow"
}
basic_auth {
dev $2a$14$LU5sNyxbop3HOsjhXB6ZrOQiveqhbcYVE6.Wi0bydAv4QhNpj4HMC
}
reverse_proxy 127.0.0.1:8137 {
health_uri /healthz
health_interval 15s
health_timeout 3s
health_status 2xx
}
log {
output file /var/log/caddy/dev.log {
roll_size 20MiB
roll_keep 5
}
}
}
# Optional: redirect www -> apex. Add the www CNAME/A record first, then # Optional: redirect www -> apex. Add the www CNAME/A record first, then
# uncomment. # uncomment.
# www.emigriffith.dev { # www.emigriffith.dev {

View file

@ -317,13 +317,14 @@ SERVICE=all BACKEND_IMAGE_TAG=<dev tag> FRONTEND_IMAGE_TAG=<dev tag> \
infra/deploy/deploy-dev.sh infra/deploy/deploy-dev.sh
``` ```
**Verify it is mesh-only.** This is the security-relevant check of the whole **Verify Caddy is the only way in.** This is the security-relevant check of the
cutover — vps1 is a public box and dev runs unreviewed branches: whole cutover — vps1 is a public box and dev runs unreviewed branches:
``` ```
ss -ltnp | grep 8137 # MUST show 10.10.0.2:8137, never 0.0.0.0:8137 ss -ltnp | grep 8137 # MUST show 127.0.0.1:8137, never 0.0.0.0
curl -fsS -o /dev/null -w '%{http_code}\n' http://10.10.0.2:8137/healthz # from the mesh curl --max-time 5 http://75.119.132.91:8137/healthz # MUST fail/refuse
curl --max-time 5 http://75.119.132.91:8137/healthz # MUST fail/refuse curl -o /dev/null -w '%{http_code}\n' https://dev.thermograph.org/ # 401 without creds
curl -o /dev/null -w '%{http_code}\n' -u dev:<pw> https://dev.thermograph.org/healthz # 200
``` ```
Also confirm dev did **not** get the fleet's shared production credentials — Also confirm dev did **not** get the fleet's shared production credentials —

View file

@ -176,12 +176,16 @@ thermograph_topology() {
# instance lives on vps2 and holds real user data. # instance lives on vps2 and holds real user data.
TG_DB_NAME=thermograph TG_DB_NAME=thermograph
TG_DB_USER=thermograph TG_DB_USER=thermograph
# Mesh-only. vps1 is a public VPS, so the dev overlay must not publish on # LOOPBACK ONLY. vps1 is a public VPS and dev runs whatever branch is in
# 0.0.0.0 the way it did on the operator's LAN box — dev runs whatever # flight, including unreviewed ones, so the stack must never publish on
# branch is in flight, including unreviewed ones. Reachable at # 0.0.0.0 the way it did on the operator's LAN box.
# http://10.10.0.2:8137 from anything on the WireGuard mesh, and from #
# nowhere else. There is deliberately no dev DNS record and no Caddy site. # It was briefly mesh-only (10.10.0.2). It is now fronted by Caddy at
TG_BIND_ADDR=10.10.0.2 # dev.thermograph.org — TLS, HTTP basic auth, and X-Robots-Tag: noindex
# (see deploy/Caddyfile.vps1) — so the only reachable path is through that
# site block, and binding loopback is what guarantees it: nothing can
# reach the app without passing the password, not even from the mesh.
TG_BIND_ADDR=127.0.0.1
# Dev NEVER layers common.yaml — that file is the internet-facing fleet's # Dev NEVER layers common.yaml — that file is the internet-facing fleet's
# shared production credential set (both S3 keypairs, the VAPID signing # shared production credential set (both S3 keypairs, the VAPID signing
# key, REGISTRY_TOKEN, the metrics token), and dev runs whatever branch is # key, REGISTRY_TOKEN, the metrics token), and dev runs whatever branch is

View file

@ -13,9 +13,10 @@
# - It renders dev.yaml ALONE, never layering common.yaml (the fleet's shared # - It renders dev.yaml ALONE, never layering common.yaml (the fleet's shared
# production credentials). vps1 also runs Forgejo and its CI, and dev runs # production credentials). vps1 also runs Forgejo and its CI, and dev runs
# whatever branch is in flight — see the long note in render-secrets.sh. # whatever branch is in flight — see the long note in render-secrets.sh.
# - It is MESH-ONLY: published on 10.10.0.2:8137 (wg0), never 0.0.0.0. No DNS # - It binds LOOPBACK, never 0.0.0.0. Caddy fronts it at dev.thermograph.org
# record, no Caddy site, no TLS. Anything that can reach it is already on # with TLS, basic auth and X-Robots-Tag: noindex (deploy/Caddyfile.vps1).
# the WireGuard mesh. # Binding loopback is what makes that guard total rather than decorative:
# the site block is the only route in.
# #
# Idempotent; re-run it after changing the branch or repointing the remote. # Idempotent; re-run it after changing the branch or repointing the remote.
set -euo pipefail set -euo pipefail
@ -70,10 +71,10 @@ Deploy it:
SERVICE=all BACKEND_IMAGE_TAG=sha-<a> FRONTEND_IMAGE_TAG=sha-<b> \\ SERVICE=all BACKEND_IMAGE_TAG=sha-<a> FRONTEND_IMAGE_TAG=sha-<b> \\
$APP_DIR/infra/deploy/deploy-dev.sh $APP_DIR/infra/deploy/deploy-dev.sh
Reach it (mesh only): Reach it:
http://${TG_BIND_ADDR}:8137 https://dev.thermograph.org (basic auth; see deploy/Caddyfile.vps1)
Confirm it is NOT publicly exposed — this must show ${TG_BIND_ADDR}:8137 and Confirm the app itself is NOT directly exposed — this must show
never 0.0.0.0:8137: ${TG_BIND_ADDR}:8137 and never 0.0.0.0:8137, so Caddy is the only way in:
ss -ltnp | grep 8137 ss -ltnp | grep 8137
EOF EOF

View file

@ -22,12 +22,12 @@
# unreviewed branch is in flight to the entire internet — with no Caddy, no # unreviewed branch is in flight to the entire internet — with no Caddy, no
# TLS and no auth in front of it. # TLS and no auth in front of it.
# #
# So the default is 127.0.0.1 (safe anywhere, including a laptop running # So the default is 127.0.0.1 safe anywhere, including a laptop running
# `make dev-up`), and deploy.sh sets DEV_BIND_ADDR to the environment's mesh # `make dev-up` — and on vps1 deploy/env-topology.sh keeps it there. Caddy
# address from deploy/env-topology.sh — 10.10.0.2 on vps1. Dev is then # fronts the stack at dev.thermograph.org with TLS, HTTP basic auth and
# reachable at http://10.10.0.2:8137 from anything on the WireGuard mesh and # X-Robots-Tag: noindex (deploy/Caddyfile.vps1). Loopback is what makes that
# from nowhere else. There is deliberately no dev DNS record and no Caddy # guard total: the site block is the only route in, so nothing reaches the
# site block for it. # app without the password — not even from the WireGuard mesh.
# 2. frontend's port publish is dropped entirely -- dev has no Caddy to reach it # 2. frontend's port publish is dropped entirely -- dev has no Caddy to reach it
# directly, so it stays compose-internal-only, reached solely through # directly, so it stays compose-internal-only, reached solely through
# backend's own reverse-proxy fallback (THERMOGRAPH_FRONTEND_BASE_INTERNAL, # backend's own reverse-proxy fallback (THERMOGRAPH_FRONTEND_BASE_INTERNAL,