# 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