feat(delivery): phase 12 — the container, and the defect only a proxy could find
All checks were successful
PR checks / checks (pull_request) Successful in 9m46s

PLAN.md §13 phase 12, the last one. Four decisions of record, D54–D57, taking the
count to fifty-seven; recorded in §6, "How phase 12 delivered it".

A two-stage Dockerfile, a pull-only docker-compose.yml carrying both bind mounts,
.env.example, the workflow that publishes and deploys, CONTRIBUTING.md, the
community-health files this was the only repository of the ten to lack, and
DEPLOY.md.

D54 — a merge deploys, amending D6. build-image.yml pushes
runicgateway-site:latest and :sha-<7>, then rolls the container over on the
`rgcom` runner out of /opt/runicgateway.com, and waits for the container's own
healthcheck rather than for `up -d` to return.

D55 — the site runs on its own host behind a generic reverse proxy, so DEPLOY.md
states the four requirements rather than one worked example, and the container
binds 127.0.0.1 so the safe configuration is the default.

D56 — @astrojs/node derives the request protocol from req.socket.encrypted and
never reads x-forwarded-proto, so behind a TLS-terminating proxy the browser sends
Origin: https://… while the container computes http://… and Astro's CSRF check
compares them for equality. Every beta signup, from every visitor, was answered
403. serve.mjs now normalises both forwarded headers, unconditionally — the image
should deploy and work. Two assertions in test/headers.test.mjs hold both halves.

D57 — DEPLOY.md rather than a README section; SECURITY.md and CODE_OF_CONDUCT.md
are pointers to the org's copies rather than copies, because a copy would hard-code
the contact address D13 confines to brand.json.

Verified: npm run verify green (eleven checks, 36 unit tests, 7 served tests,
astro check 0 errors). The image was built and run with both mounts — a mounted
brand reached 51 files and all 50 search pages, /brand/* fell back per file, a
proxy-shaped signup reached the store, and the export CLI wrote both Play files to
the host mount. docker compose config caught a YAML trap in the healthcheck: a
block sequence reads the `: ` in `r.ok ? 0 : 1` as a mapping.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-25 16:54:38 -05:00
parent 18064062a9
commit f2e59a2426
17 changed files with 1451 additions and 14 deletions

94
docker-compose.yml Normal file
View File

@@ -0,0 +1,94 @@
# runicgateway.com — production.
#
# Pull-only, in common with the rest of the org: `image:` and no `build:`, so a
# production host can never accidentally build. The image is published to the
# Gitea registry by .gitea/workflows/build-image.yml on every merge to main.
#
# Full operator guide, including DNS, TLS and the reverse proxy: DEPLOY.md.
#
# docker compose pull && docker compose up -d
#
# One service. §6 records why there is no second one: the dynamic surface is two
# routes, and one container is one thing to deploy, one thing to patch and one
# log to read.
services:
site:
# IMAGE_TAG defaults to `latest`. Pin a build for a reproducible deploy or a
# rollback — e.g. IMAGE_TAG=sha-1806406 in .env; every merge publishes both.
image: gitea.whitlocktech.com/runicgateway/runicgateway-site:${IMAGE_TAG:-latest}
restart: unless-stopped
# Secrets and tuning for the beta signup (§8). The file is optional in the
# sense that the site starts without it — but read the note in .env.example
# about BETA_IP_SALT and BETA_FORM_KEY before deciding to skip it: their
# defaults are random PER PROCESS, so leaving them unset means every restart
# forgets who has been rate-limited.
env_file: .env
volumes:
# ---------------------------------------------------------------------------
# The branding mount (§7). Read-only: nothing in the container ever writes
# here, and the whole point of the directory is that a human puts files in
# it from the host.
#
# May be empty, partial or complete. Every file resolves against this mount
# first and the image's brand-default/ second, PER FILE — so a directory
# holding only theme.css recolours the site and leaves every logo stock,
# and an empty directory produces exactly the stock site.
#
# Changing a file here takes a RESTART, not a rebuild: `docker compose
# restart site` re-runs the boot rewrite, which is what puts a new site
# name into forty-nine prerendered pages. The exception is brand.json's
# betaOptInUrl, which /beta reads live on every request — so the closed
# test can be opened by editing one file, with no restart at all.
- ./brand:/app/brand:ro
# ---------------------------------------------------------------------------
# The beta signup store (§8): beta.sqlite and exports/. Read-WRITE, and the
# only thing this site persists.
#
# A bind mount rather than a named volume because the tester list has to be
# reachable from the host — `sqlite3 ./data/beta.sqlite`, a backup by `cp`,
# and the CSV the export CLI writes into ./data/exports/ for pasting into
# Play. A named volume would put all three behind `docker cp`.
#
# The container runs as uid 1000. Docker creates a MISSING bind-mount source
# as root:root, and the store then fails to open — so create the directory
# yourself and, if it is owned by someone else, `chown 1000:1000 ./data`.
# DEPLOY.md has the two commands.
- ./data:/app/data
# Bound to loopback, because TLS terminates at a reverse proxy on this host
# and nothing else has any business reaching the container directly.
#
# CHANGE THIS if your proxy runs in its own container or on another machine:
# it then cannot reach 127.0.0.1 of the host, and the binding must become
# `"${SITE_HOST_PORT:-4321}:4321"` (all interfaces) with a firewall in front,
# or the proxy must join a shared Docker network and address the service by
# name instead of by port. DEPLOY.md, "Putting a proxy in front of it".
ports:
- "127.0.0.1:${SITE_HOST_PORT:-4321}:4321"
# Repeats the image's own HEALTHCHECK so `docker compose ps` reports it even
# when the image is pinned to an older tag that predates it. It watches an
# actual response rather than the process, because `npm start` runs the brand
# rewrite before the server: there is a real window where the container is up
# and nothing is listening.
#
# The command is a QUOTED flow sequence, which is not a style choice: written as a
# block sequence, YAML reads the `: ` inside `r.ok ? 0 : 1` as a key/value separator
# and `docker compose config` refuses the file with "healthcheck.test.3 must be a
# string". Keep the quotes.
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4321/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 40s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"