Sam answered the bind question on AUD-133 (2026-10-26):
"A - dual bind: 127.0.0.1:8096 and 192.168.20.13:8096".
- docker-compose: publish 127.0.0.1 and ${APP_LAN_IP:-192.168.20.13}, never 0.0.0.0
- deploy.sh: carry APP_LAN_IP into the release .env, --lan-ip flag, health gate
now requires /healthz 200 on loopback AND the LAN address
- rehearse.sh: assert both binds, no wildcard, LAN reachability; KEEP_REHEARSAL
now really keeps the container/image; N3 waits up to 20s instead of 3s
- docs/phase-3-evidence.md: decision recorded, new measured output
DoD: 1, 3 (as amended by Sam), 4, 8 met with recorded output. 2, 5, 6 deferred
on the Phase 2 binary; 7 is Sam's Caddy apply. rehearse.sh 24/24, exit 0.
5.3 KiB
Deployment (Phase 3)
One script, one release directory, one atomic symlink. Deploy is a script, not a
model decision (AGENTS.md §4.6), and nobody hand-edits files on the runtime
host: change these files in git and deploy again.
| File | Purpose |
|---|---|
deploy.sh |
build + test → release → flip → up -d → health gate → rollback on failure |
deploy.sh --rollback |
restore the previous release in one command |
docker-compose.yml |
runtime container: published on 127.0.0.1:8096 and 192.168.20.13:8096 (never 0.0.0.0), mem_limit == memswap_limit == 120m, capped logs |
Dockerfile |
runtime image only — alpine + the prebuilt release payload, no toolchain, no network |
rehearse.sh |
repeatable proof that deploy, fail-fast, health-gate rollback and --rollback all work |
Layout on .13
/home/sam/deployment/resume/
├── releases/<UTC timestamp>/ immutable: bin/server, dist/, compose, Dockerfile, manifest.txt, .env
├── current -> releases/<ts> live release; swapped with ln + mv -T (atomic rename)
├── previous -> releases/<ts> the release that was live before the last flip
└── deploy.log append-only record of every attempt, including failures
What deploy.sh expects from the app
make buildif aMakefilewith abuildtarget exists, otherwisego build ./cmd/server. Either way the result must be one server binary atbin/serverordist/server; anything else the binary serves from disk goes indist/and is copied into the release.make test(orgo test ./...) — runs before anything is copied or flipped, so a broken build never reaches the release directory.GET /healthz→200. If the body is JSON with astaticfield it must betrue. AI readiness is deliberately not part of the gate: Mode A must work with every AI service stopped (acceptance test A1).- Bind
$PORT(default8096) on0.0.0.0inside the container. The container's own loopback is not reachable through a published port; exposure is controlled by the host side ofports:indocker-compose.yml.
Deploying
cd /home/sam/paseo/projects/resume_ai_system_paperclip_build
./deploy/deploy.sh # build, test, release, flip, health gate
./deploy/deploy.sh --rollback # one command back to the previous release
tail -40 /home/sam/deployment/resume/deploy.log
Exit code is the answer: 0 means the new release answered /healthz.
Non-zero means either (a) build/test failed and nothing changed, or (b) the
health gate failed, current was flipped back to previous, the previous
release was restarted, and the log says which.
Flags worth knowing: --source DIR (build from another checkout — this is how
rehearse.sh drives the script), --root DIR, --port N,
--health-timeout N.
The Go toolchain is auto-detected, including /nix/store/*-go-1.*/bin/go, so
the script works on a host where go is not on PATH.
Rehearsing the rollback
./deploy/rehearse.sh # 21 assertions, exit 0 when every one holds
It builds three throwaway services in a scratch directory, runs the real script against the real release root and the real port, and asserts:
- a good release deploys, answers
/healthzover loopback and the LAN address, publishes on exactly those two addresses with no wildcard bind, and hasmemswap_limit == mem_limit; - a build failure exits non-zero, leaves
currentuntouched and keeps serving; - a release whose
/healthzis500is rolled back by the health gate, exits non-zero, and the previous release serves again; deploy.sh --rollbackflips back to the previous release and exits 0;docker compose down && up(acceptance test N3) restores the service.
It removes the release root, container and images it created. KEEP_REHEARSAL=1
keeps them for inspection.
Container limits — not negotiable
mem_limit and memswap_limit are both 120m, so the container cannot eat
host swap on a host that is already swapping. Measured RSS for a Go static
server is single-digit MiB; deploy.sh prints the real figure after every
deploy so drift shows up in the log.
Bind addresses — decided 2026-10-26
The container publishes on two explicit addresses and nothing else:
| Address | Why |
|---|---|
127.0.0.1:8096 |
deploy.sh's health gate and any check made on .13 itself |
192.168.20.13:8096 |
the LAN address Caddy on .35 proxies to (docs/phase-3-deploy.md §3.3) |
A wildcard bind (0.0.0.0) is still forbidden: Caddy on .35 stays the only
public listener. The phase document asked for loopback only, but a loopback-only
listener is unreachable from .35; Sam resolved the contradiction on AUD-133
with "A - dual bind: 127.0.0.1:8096 and 192.168.20.13:8096". APP_LAN_IP
overrides the LAN address (default 192.168.20.13); deploy.sh --lan-ip ADDR
sets it for one run. Both deploy.sh and rehearse.sh assert that the LAN
address answers, not just loopback.
Caddy
docs/phase-3-deploy.md §3.3 holds the block for .35. It is additive, applied
with caddy reload (never a restart), and Sam applies it. The block proxies
to 192.168.20.13:8096, which is why the container publishes on that address as
well as loopback (see Bind addresses above).