Files
automations/deployments/ergo/docker-compose.yml
T
57_WolveandClaude Opus 5 134736c082 fix(ergo): findings from reviewing the first live deploy
Seven confirmed findings from an observed-vs-expected review of a real deploy
transcript against the code (four other proposed findings were refuted and no
change was made for them).

- Caddyfile reformatted so `caddy fmt` is clean, removing the warning Caddy
  printed on every validate and every start. Two causes, not one: `caddy fmt`
  indents with TABS, and it deletes a blank line whose following line begins
  with `{` -- which is why the warning pointed at line 17, the blank before the
  global options block. The check is a whole-file byte comparison, so the line
  number was only the first difference and the entire file had to be
  reformatted. Verified whitespace-only outside the heredoc, whose body is left
  byte-identical: Caddy strips padding derived from the closing marker's
  indentation, so re-indenting it would change what the page serves.
- deploy.sh no longer upgrades Caddy behind the operator's back. A bare
  `docker compose pull` refreshed the floating `caddy:2-alpine` on every re-run
  and `up -d` then recreated it, doing exactly what CADDY_AUTOUPDATE=0 promises
  not to, with none of update.sh's health check or rollback. Pulls are now
  per service: the pinned Ergo tag always, Caddy only when absent or opted in.
- deploy.sh seeds the ACME_EMAIL and NETWORK_NAME prompts from .env, so pressing
  Enter through a re-run no longer renames the network to the hostname in the
  summary while .env keeps the real one.
- The Caddy restart guard compares the container's identity across `up -d`. A
  container compose created or recreated has already read the new Caddyfile;
  only one left running still holds the old config, and `svc_state` cannot tell
  those apart.
- Container logs are now rotated (json-file, 10m x 3) on every service. Alpine's
  docker package ships no daemon.json, so the default is unbounded, and the
  60-second health probe alone writes a log line per run.
- ergoctl notes that LUSERS counts its own probe connection, which is why a
  server with nobody on it reports one invisible user.

Verified: seven local suites pass, the embedded archive round-trips, line
endings are LF, and the reformatted Caddyfile is a verified fixed point of the
formatter (semantically identical token-for-token to the previous one).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:38:13 -05:00

91 lines
3.9 KiB
YAML

# Ergo stack -- Caddy (Let's Encrypt + WebSocket front) + Ergo IRC server.
#
# Topology -- BOTH services run in the HOST network namespace (no Docker NAT):
#
# IRC clients --> :6697 TLS ----------------------> ergo (sees the real IPv4/IPv6)
# web clients --> :443 caddy --/webirc--> 127.0.0.1:8097 ergo websocket (X-Forwarded-For)
# Let's Encrypt --> :80/:443 caddy (ACME); the issued cert is copied into ./ircd for
# Ergo's :6697 listener by `update.sh certsync` (+ SIGHUP rehash)
# loopback --> 127.0.0.1:6667 plaintext: compose healthcheck + `ergoctl` ONLY
# (loopback is exempt from ip-limits/bans and counts as secure)
#
# Why host networking: IRC bans, throttling and IP cloaking key on the client's
# address, and Docker's userland proxy would hide every IPv6 client behind the
# bridge gateway. Host mode also means the host's deny-by-default INPUT firewall
# really governs these ports (deploy.sh registers 80/443/6697). Consequences:
# no `ports:`/`networks:` here (Compose rejects them in host mode), Caddy reaches
# Ergo over 127.0.0.1, and the host's loopback is Ergo's trust boundary -- see the
# README ("Security model").
name: ergo
services:
# ---------------------------------------------------------------------------
# Caddy -- obtains/renews the Let's Encrypt cert, terminates HTTPS for the
# IRC-over-WebSocket path and serves a text landing page. Static config:
# apply Caddyfile edits with `docker compose restart caddy` (admin API is off).
# ---------------------------------------------------------------------------
caddy:
image: caddy:${CADDY_TAG:-2-alpine}
container_name: ergo-caddy
restart: unless-stopped
network_mode: host
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE] # :80/:443 (root in-container needs only this)
security_opt: [no-new-privileges:true]
volumes:
- ./caddy/etc:/etc/caddy # Caddyfile + conf.d/ (directory mount, not a file)
- ./caddy/data:/data # ACME account + certs; host-readable for certsync
- ./caddy/config:/config
environment:
ERGO_DOMAIN: "${ERGO_DOMAIN}"
ACME_EMAIL: "${ACME_EMAIL}"
NETWORK_NAME: "${NETWORK_NAME}"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
healthcheck:
# Loopback-only health site defined in the Caddyfile (the admin API is off).
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ | grep -q '^ok'"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# ---------------------------------------------------------------------------
# Ergo -- the IRC server. Runs as the host's `ergo` system user (deploy.sh
# creates it and chowns ./ircd), read-only rootfs, no capabilities. Every
# file it needs lives in ./ircd: ircd.yaml, ircd.db, ergo.motd, fullchain.pem,
# privkey.pem. `init: true` puts tini at PID 1 so `docker compose kill -s HUP`
# (rehash: reloads config, MOTD and TLS certs) reaches the ergo process.
# ---------------------------------------------------------------------------
ergo:
image: ${ERGO_IMAGE:-ghcr.io/ergochat/ergo}:${ERGO_TAG:-stable}
container_name: ergo
restart: unless-stopped
network_mode: host
init: true
user: "${ERGO_UID:-1000}:${ERGO_GID:-1000}"
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
read_only: true
tmpfs: [/tmp]
volumes:
- ./ircd:/ircd
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
healthcheck:
# IRC-level probe: Ergo answers a pre-registration QUIT with "ERROR :Quit"
# and closes. A bare TCP connect would pass even with a wedged server.
# busybox nc exits 0 after a timeout, hence the grep.
test: ["CMD-SHELL", "printf 'QUIT\r\n' | nc -w 3 127.0.0.1 6667 | grep -q '^ERROR'"]
interval: 60s
timeout: 6s
retries: 3
start_period: 20s