2 Commits

Author SHA1 Message Date
Sam Rolfe
f39f560c7f deploy: publish on the LAN address so Caddy on .35 can reach it
127.0.0.1:8096 is unreachable from .35, which is where Caddy runs. Every other
service on .13 is published this way (8095, 8092, 3909, 4200) and proxied by the
same Caddy. Port is LAN-only; Caddy remains the sole public listener.
2026-10-26 22:40:24 +11:00
Sam Rolfe
df864cc29e deploy: health gate and shape assertion follow the LAN publish address
Once the port is published on 192.168.20.13 the host no longer listens on
127.0.0.1:8096, so wait_healthy(), do_status() and the rollback path would all
get connection-refused and every deploy would roll back. They now probe
$PUBLISH_IP (RESUME_PUBLISH_IP, default 192.168.20.13). assert_shape() asserts
the published host IP is that address instead of loopback; the mem_limit ==
memswap_limit checks are unchanged. check_port stays address-agnostic on
`sport = :8096`.

Records the amendment in reference/stack.md (ports table + the bind rule) and
docs/phase-3-deploy.md (§3.2 bullet, flow diagram, DoD line), citing Sam's
decision of 2026-10-26, interaction 54aeb1a2. AGENTS.md is Sam's to change and
is untouched.
2026-10-26 22:40:22 +11:00
4 changed files with 34 additions and 15 deletions

View File

@@ -30,11 +30,13 @@ services:
restart: unless-stopped
mem_limit: 120m
memswap_limit: 120m
# The app listens on 0.0.0.0 inside the container namespace; the host-side
# binding is loopback, so nothing is reachable from the LAN except through
# docker-proxy on 127.0.0.1:8096 — which is what Caddy proxies to.
# Published on this host's LAN address, not loopback. Caddy lives on .35 and
# proxies to 192.168.20.13:8096, exactly as it does for offsite (:8095),
# photo-filter (:8092), garage-ui (:3909) and prefect (:4200). The port is
# reachable inside the house only; Caddy on .35 remains the sole public
# listener, which is the intent of AGENTS.md section 4.
ports:
- "127.0.0.1:8096:8096"
- "192.168.20.13:8096:8096"
read_only: true
security_opt:
- no-new-privileges:true

View File

@@ -44,6 +44,15 @@ PROJECT=ai-resume-site # compose project; distinct from the older
PORT=8096
HEALTH_PATH=/healthz
# The host address the port is published on. Caddy runs on .35 and proxies to
# this address, so a loopback publish is unreachable from the edge (Sam's
# decision, 2026-10-26, interaction 54aeb1a2). Never 0.0.0.0: the port must not
# be reachable off the LAN. The health gate below follows this address — once the
# port is published on the LAN address the host no longer listens on
# 127.0.0.1:8096, so a loopback probe would refuse and every deploy would roll
# back.
PUBLISH_IP=${RESUME_PUBLISH_IP:-192.168.20.13}
ROOT=${RESUME_DEPLOY_ROOT:-/home/sam/deployment/resume}
RELEASES=$ROOT/releases
CURRENT=$ROOT/current
@@ -231,7 +240,7 @@ start() {
wait_healthy() {
local i body
for i in $(seq 1 "$HEALTH_TRIES"); do
if body=$(curl -fsS --max-time 3 "http://127.0.0.1:$PORT$HEALTH_PATH" 2>/dev/null); then
if body=$(curl -fsS --max-time 3 "http://$PUBLISH_IP:$PORT$HEALTH_PATH" 2>/dev/null); then
if printf '%s' "$body" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"ok"'; then
log "health gate passed after ${i}s"
return 0
@@ -240,14 +249,15 @@ wait_healthy() {
sleep 1
done
warn "health gate failed after ${HEALTH_TRIES}s; last response:"
curl -sS --max-time 3 "http://127.0.0.1:$PORT$HEALTH_PATH" >&2 || true
curl -sS --max-time 3 "http://$PUBLISH_IP:$PORT$HEALTH_PATH" >&2 || true
compose logs --tail 40 resume >&2 || true
return 1
}
# Definition-of-Done evidence, checked on every deploy instead of by hand:
# mem_limit == memswap_limit == the value declared in compose.yml, and the port
# published on 127.0.0.1 only.
# published on $PUBLISH_IP only (the LAN address Caddy proxies to — never
# 0.0.0.0 and no longer loopback).
assert_shape() {
local cid mem swap ip declared
declared=$(awk '/^[[:space:]]*mem_limit:/ {gsub(/[^0-9]/, "", $2); print $2}' "$COMPOSE")
@@ -258,8 +268,8 @@ assert_shape() {
[ "$mem" = "$swap" ] || die "mem_limit ($mem) != memswap_limit ($swap)"
[ "$mem" = "$((declared * 1024 * 1024))" ] || die "mem_limit is $mem, expected ${declared} MiB"
ip=$(docker inspect -f '{{range $p, $b := .HostConfig.PortBindings}}{{range $b}}{{.HostIp}} {{end}}{{end}}' "$cid" | xargs)
[ "$ip" = "127.0.0.1" ] || die "published on '${ip}', expected 127.0.0.1 only"
log "shape ok: mem_limit == memswap_limit == ${declared} MiB, published on 127.0.0.1:$PORT"
[ "$ip" = "$PUBLISH_IP" ] || die "published on '${ip}', expected $PUBLISH_IP only"
log "shape ok: mem_limit == memswap_limit == ${declared} MiB, published on $PUBLISH_IP:$PORT"
}
deploy_release() {
@@ -348,7 +358,7 @@ prune() {
do_status() {
local cur prev body
cur=$(current_target); prev=$(previous_target)
body=$(curl -fsS --max-time 3 "http://127.0.0.1:$PORT$HEALTH_PATH" 2>/dev/null || true)
body=$(curl -fsS --max-time 3 "http://$PUBLISH_IP:$PORT$HEALTH_PATH" 2>/dev/null || true)
printf 'root %s
' "$ROOT"
printf 'current %s

View File

@@ -30,7 +30,7 @@ deploy.sh
├─ copy to releases/<timestamp>/
├─ flip `current` symlink (atomic)
├─ docker compose up -d
├─ curl http://127.0.0.1:8096/healthz → must be ready
├─ curl http://192.168.20.13:8096/healthz → must be ready
└─ on failure: flip back to `previous`, exit non-zero
```
@@ -40,7 +40,12 @@ deploy.sh
### 3.2 Container
- `mem_limit` = `memswap_limit` = `120m`.
- `ports: ["127.0.0.1:8096:8096"]` — never `0.0.0.0`.
- `ports: ["192.168.20.13:8096:8096"]` — **never** `0.0.0.0`.
Caddy runs on `.35`, so the port has to be reachable from the LAN; a
`127.0.0.1` publish answers only on `.13` and the public URL returns 502.
LAN-only publish, Caddy on `.35` still the sole public listener. Decision:
Sam, 2026-10-26, interaction `54aeb1a2`. The health gate and `assert_shape()`
in `deploy.sh` follow the same address (`RESUME_PUBLISH_IP`).
- `logging` capped (`max-size: 10m`, `max-file: 3`).
- `restart: unless-stopped`.
@@ -75,7 +80,7 @@ Repo stays private through this phase. Sam decides on public visibility after th
- [ ] `deploy.sh` and `deploy.sh --rollback` both work; a deliberate bad build proves rollback.
- [ ] `https://resume.lab.audasmedia.com.au` serves the site.
- [ ] Container bound to `127.0.0.1` only; confirmed with `ss -ltn`.
- [ ] Container published on `192.168.20.13:8096` only, reachable from `.35`; confirmed with `ss -ltn`.
- [ ] `memswap_limit == mem_limit`; confirmed with `docker inspect`.
- [ ] Security headers present; CSP does not break the page.
- [ ] A1 still passes over the public URL with the AI layer stopped.

View File

@@ -15,7 +15,7 @@
| Service | Host | Port | State |
|---|---|---|---|
| Caddy | `.35` | 443 (+ LAN 8000) | existing — **Sam applies any change** |
| **Go app** | `.13` | **8096** | new — verify free (`8095` = offsite.lab) |
| **Go app** | `.13` | **8096** | new — verify free (`8095` = offsite.lab); published on `192.168.20.13:8096`, **never** `0.0.0.0` |
| Qdrant REST | `.13` | 6333 | new, `127.0.0.1` only |
| Qdrant gRPC | `.13` | 6334 | new, `127.0.0.1` only |
| Prefect server | `.13` | existing | reuse — do not deploy a second |
@@ -55,7 +55,9 @@ Ceiling: 20,000 chunks at 1024 dimensions. Beyond that, set
`RESUME_QDRANT_VECTORS_ON_DISK=1`, reduce the dimension, or raise the cap — in
that order. Detail: `docs/phase-4-notes.md` §7.
Every service sets `mem_limit` **and** `memswap_limit` to the same value so it cannot consume host swap. Bind to `127.0.0.1`; Caddy is the only public listener.
Every service sets `mem_limit` **and** `memswap_limit` to the same value so it cannot consume host swap. Bind services to `127.0.0.1`; Caddy on `.35` is the only public listener.
**One documented amendment (Go app, port 8096):** the app publishes on the LAN address `192.168.20.13:8096`, not `127.0.0.1`. Caddy runs on `.35` and proxies to `192.168.20.13:8096`, so a loopback-only publish is unreachable from the edge and the public URL returns 502. This is the same way `8095` (offsite), `8092` (photo-filter), `3909` (garage-ui) and `4200` (prefect) are published on `.13`. The port is LAN-only — never `0.0.0.0` — and Caddy on `.35` is still the only public listener, so `AGENTS.md` §4.2 holds in substance. Decision: Sam, 2026-10-26, interaction `54aeb1a2`. `deploy/deploy.sh` follows the same address (`RESUME_PUBLISH_IP`, default `192.168.20.13`) in its health gate and shape assertion.
## Paths