feat(compose): publish on every interface, so the host's own address answers
All checks were successful
PR checks / checks (pull_request) Successful in 9m56s

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>
This commit is contained in:
2026-08-26 23:40:46 -05:00
parent e60812fb34
commit b86f4cabf0
4 changed files with 88 additions and 27 deletions

View File

@@ -18,10 +18,19 @@
# reproducible deploy or to roll back — every merge publishes both tags.
IMAGE_TAG=latest
# Host port the container is published on, bound to 127.0.0.1 (see the note in
# docker-compose.yml if your reverse proxy cannot reach the host's loopback).
# Host port the container is published on.
SITE_HOST_PORT=4321
# Which of the host's addresses that port is published on. The default is every
# interface, so the site answers on the host's own address — http://<vm-ip>:4321
# — which is what a proxy in another container, another machine, or a browser
# elsewhere on the network needs.
#
# Narrow it if this host has a public address and you want only the proxy to
# reach the container: 127.0.0.1 for a proxy on this same host, or one interface
# address for the LAN but not a public NIC. Nothing else in the site changes.
SITE_BIND_ADDR=0.0.0.0
# ---------------------------------------------------------------------------------------
# The closed-beta signup (PLAN.md §8)
# ---------------------------------------------------------------------------------------

View File

@@ -136,32 +136,47 @@ curl -sI http://127.0.0.1:4321/ | head -n 1
curl -sI http://127.0.0.1:4321/ | grep -i content-security-policy | cut -c1-120
```
The port is published on every interface by default ([§3.1](#31-forward-to-the-published-port)), so
the same two commands work from any other machine on the network with the host's address in place of
`127.0.0.1` — which is the quickest way to look at the site in a real browser before DNS or the
proxy exists.
The second command matters more than the first. The site sends its **own** Content-Security-Policy,
per page, built from the hashes of that page's inline scripts and styles. If it is missing, do not
add one at the proxy — see below.
## 3. Putting a proxy in front of it
The container publishes on **`127.0.0.1:4321`** by default and speaks plain HTTP. Any reverse proxy
will do; the site has no opinion about which. What it does have is four requirements, and the third
is the one that is easy to get wrong and quiet when you do.
The container publishes on **port 4321 of every interface** by default and speaks plain HTTP, so it
answers both on `http://127.0.0.1:4321` and on the host's own address — `http://<vm-ip>:4321`. Any
reverse proxy will do; the site has no opinion about which. What it does have is four requirements,
and the third is the one that is easy to get wrong and quiet when you do.
### 3.1 Forward to the published port
Whatever your proxy calls it: forward `runicgateway.com` (and `www.` if you want it) to
`http://127.0.0.1:4321`. There are no WebSockets, no long-polling, no streaming responses and no
upload larger than a form field, so no timeout or buffering setting needs changing.
`http://<host>:4321` — loopback if the proxy runs on this same machine, the host's address if it
runs in another container or on another machine. There are no WebSockets, no long-polling, no
streaming responses and no upload larger than a form field, so no timeout or buffering setting needs
changing.
**If your proxy is itself in a container, or on another machine,** it cannot reach the host's
loopback. Either change the port line in `docker-compose.yml` to publish on all interfaces —
A proxy on a shared Docker network can address the service as `site:4321` instead and skip the host
port entirely.
```yaml
ports:
- "${SITE_HOST_PORT:-4321}:4321"
**Narrowing the binding.** `SITE_BIND_ADDR` in `.env` decides which addresses the port answers on,
and nothing else in the site changes with it:
```bash
SITE_BIND_ADDR=0.0.0.0 # every interface — the default
SITE_BIND_ADDR=192.168.1.10 # one interface: the LAN, but not a public NIC
SITE_BIND_ADDR=127.0.0.1 # loopback only: a proxy on THIS host and nothing else
```
— and firewall the port so only the proxy reaches it, or put the proxy on a shared Docker network
and address the service as `site:4321`, publishing no host port at all.
**On a host with a public address, the default means port 4321 answers from the internet directly**,
beside whatever the proxy serves on 443 — plain HTTP, no TLS, and no proxy in the path to set
`X-Forwarded-For` ([§3.2](#32-set-x-forwarded-for)), so signups arriving that way share one
rate-limit bucket. There is no login and nothing to steal, so this is untidy rather than dangerous —
but on a public host, firewall the port or narrow the binding.
### 3.2 Set `X-Forwarded-For`

37
PLAN.md
View File

@@ -236,7 +236,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
**Decisions after D13 are recorded where they were taken**, in the section describing the phase that
raised them, rather than appended here — a decision is only re-litigated when its reasoning is
somewhere other than the thing it decided. The count of record is **fifty-seven**:
somewhere other than the thing it decided. The count of record is **fifty-nine**:
| # | Where | What it settled |
|---|---|---|
@@ -252,6 +252,7 @@ somewhere other than the thing it decided. The count of record is **fifty-seven*
| D51D53 | §6, "How phase 11 validated it" | The chrome and the head follow the brand mount while the consent sentence does not, the documentation half gets phase 10's skip-link fix, and no twelfth check |
| D54D57 | §6, "How phase 12 delivered it" | A merge deploys (amending D6), the proxy is documented by its requirements rather than by an example, the container trusts the forwarded headers with nothing to configure, and the operator note is its own file while the two policy files are pointers |
| D58 | §6, "How phase 12 delivered it" | `node_modules` ships in three layers because Cloudflare refuses a request body over 100 MB, and the workflow counts layers before it pushes |
| D59 | §6, "How phase 12 delivered it" | The container publishes on every interface (amending D55), with `SITE_BIND_ADDR` to narrow it |
---
@@ -522,8 +523,8 @@ The last phase, and the one that turns a repository into a deployment: a two-sta
pull-only `docker-compose.yml` carrying both bind mounts, `.env.example`, the publishing workflow,
`CONTRIBUTING.md` with the AI-disclosure requirement, the community-health files this repository was
the only one in the organisation to lack, and `DEPLOY.md`. Four decisions, **D54D57**, taking the
count of record to **fifty-seven**and a fifth, **D58**, added when the merge that shipped the
phase could not publish its own image.
count of record to **fifty-seven**plus **D58**, added when the merge that shipped the phase could
not publish its own image, and **D59**, which amends D55's loopback binding. **Fifty-nine.**
It also found a defect that would have made the closed beta impossible, and it is the only phase
that could have found it. Everything before this ran the site the way a developer runs it: one
@@ -555,8 +556,9 @@ Docker socket and no compose directory.
The site runs on its own host, behind whatever reverse proxy the org lead puts there. So
`DEPLOY.md` does not carry a worked Caddyfile or nginx block that would be wrong for three readers
out of four; it states the four things the proxy must do, and the container binds to **`127.0.0.1`**
by default so that the safe configuration is the default one.
out of four; it states the four things the proxy must do. The container originally bound to
`127.0.0.1` by default so that the safe configuration was the default one; **amended by D59
(2026-08-26)** — it publishes on every interface, and which interfaces is a variable.
Two of the four are worth repeating here because they are silent when wrong:
@@ -670,6 +672,31 @@ number it reports for the broken layer (108 MB) agrees with what the registry re
This is a workflow step and not a twelfth check script: it needs a built image rather than a source
tree, which is the one thing the eleven never have. D53 holds.
#### D59 — the container publishes on every interface, which amends D55
D55 bound the published port to `127.0.0.1`, reasoning that TLS terminates at a proxy on the same
host and nothing else has business reaching the container. That is the right default for the host
this eventually runs on, and the wrong one for every step before it: a loopback binding cannot be
opened from a browser on another machine, which is exactly what an operator wants to do first —
look at the thing on the VM's own address, before DNS exists, before the proxy exists, from a
desktop or a phone that is not the VM.
The org lead settled it on 2026-08-26: **publish on all interfaces**, the way a normal bridge
publish behaves. `docker-compose.yml` now reads
`"${SITE_BIND_ADDR:-0.0.0.0}:${SITE_HOST_PORT:-4321}:4321"`, so the site answers on
`http://<vm-ip>:4321` out of the box.
**Which addresses it answers on is a variable, not an edit.** `SITE_BIND_ADDR` in `.env` narrows it
to one interface or back to loopback without touching a file that `docker compose pull` replaces,
which also removes the "change the port line" instruction D55 had to give proxies running in another
container or on another machine.
What is honestly given up: on a host with a public address, port 4321 answers from the internet
directly — plain HTTP beside the proxy's 443, and with no proxy in the path to set `X-Forwarded-For`,
so signups arriving that way share one rate-limit bucket. The site has no login and no secret behind
it, so this is untidy rather than dangerous, and `DEPLOY.md` §3.1 says so and gives both remedies
(firewall the port, or narrow the binding).
---
## 7. Branding is bind-mounted data

View File

@@ -59,16 +59,26 @@ services:
# 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.
# 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.
#
# 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".
# 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:
- "127.0.0.1:${SITE_HOST_PORT:-4321}:4321"
- "${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