Files
runicgateway.com/docker-compose.yml
Claude b86f4cabf0
All checks were successful
PR checks / checks (pull_request) Successful in 9m56s
feat(compose): publish on every interface, so the host's own address answers
D55 bound the published port to 127.0.0.1, on the reasoning that TLS terminates
at a proxy on the same host and nothing else has business reaching the container.
That is right for the host this ends up on and wrong for every step before it: a
loopback binding cannot be opened from a browser on another machine, which is the
first thing an operator wants to do — look at the site on the VM's own address,
before DNS exists, before the proxy exists, from a desktop or a phone.

The port line is now "${SITE_BIND_ADDR:-0.0.0.0}:${SITE_HOST_PORT:-4321}:4321",
so http://<vm-ip>:4321 answers out of the box, the way a normal bridge publish
behaves. Which addresses it answers on is a variable rather than an edit:
SITE_BIND_ADDR narrows it to one interface, or back to loopback, without touching
a file that `docker compose pull` replaces. That also retires the "change the port
line yourself" instruction DEPLOY.md had to give a proxy running in another
container or on another machine.

What is given up, said plainly in DEPLOY.md §3.1: on a host with a public address
the default answers on port 4321 from the internet, plain HTTP beside the proxy's
443, with no proxy in the path to set X-Forwarded-For — so signups arriving that
way share one rate-limit bucket. There is no login and no secret behind it, so it
is untidy rather than dangerous, and both remedies are named (firewall the port,
or narrow the binding).

Verified by running it, not only by reading it: `docker compose config` accepts
both bindings and resolves host_ip 0.0.0.0 and 127.0.0.1 respectively; the stack
came up healthy, `docker compose port site 4321` reported 0.0.0.0:4321, and the
site answered 200 on both 127.0.0.1 and the machine's LAN address, still carrying
its own per-page CSP.

Recorded as D59, amending D55. Count of record fifty-nine.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-26 23:42:45 -05:00

105 lines
5.2 KiB
YAML

# 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
# Published on ALL interfaces by default, so the site answers on the host's
# own address — `http://<vm-ip>:4321` — and not only on its loopback. That is
# what makes it reachable from the rest of the network: a proxy in another
# container or on another machine, a browser on the LAN, a phone on the same
# wifi checking the mobile layout.
#
# It is deliberately a variable rather than a fixed address, because the safe
# binding depends on where this host sits. Set SITE_BIND_ADDR in .env to
# narrow it without touching this file:
#
# SITE_BIND_ADDR=127.0.0.1 loopback only — a proxy on THIS host, nothing else
# SITE_BIND_ADDR=192.168.1.10 one interface — the LAN, but not a public NIC
# SITE_BIND_ADDR=0.0.0.0 every interface (the default)
#
# On a host with a public address, `0.0.0.0` means port 4321 answers from the
# internet directly, beside whatever the proxy serves on 443 — plain HTTP, no
# TLS. Firewall the port, or narrow the binding. DEPLOY.md, "Putting a proxy
# in front of it".
ports:
- "${SITE_BIND_ADDR:-0.0.0.0}:${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"