Files
ai-resume/deploy/README.md
System agent e6240c1e8e Phase 3: dual bind per Sam's decision (DoD 3 amended, DoD 1/4/8 re-evidenced)
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.
2026-10-26 17:44:22 +11:00

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

  1. make build if a Makefile with a build target exists, otherwise go build ./cmd/server. Either way the result must be one server binary at bin/server or dist/server; anything else the binary serves from disk goes in dist/ and is copied into the release.
  2. make test (or go test ./...) — runs before anything is copied or flipped, so a broken build never reaches the release directory.
  3. GET /healthz → 200. If the body is JSON with a static field it must be true. AI readiness is deliberately not part of the gate: Mode A must work with every AI service stopped (acceptance test A1).
  4. Bind $PORT (default 8096) on 0.0.0.0 inside the container. The container's own loopback is not reachable through a published port; exposure is controlled by the host side of ports: in docker-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:

  1. a good release deploys, answers /healthz over loopback and the LAN address, publishes on exactly those two addresses with no wildcard bind, and has memswap_limit == mem_limit;
  2. a build failure exits non-zero, leaves current untouched and keeps serving;
  3. a release whose /healthz is 500 is rolled back by the health gate, exits non-zero, and the previous release serves again;
  4. deploy.sh --rollback flips back to the previous release and exits 0;
  5. 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).