Files
ai-resume/deploy/README.md
System 8b3209fab5 Phase 3: release script, container limits, rehearsed rollback
Adds the deployment mechanism for the portfolio site. The site itself is not
deployed yet: Phase 2 has not produced a binary.

- deploy/deploy.sh: build + test (fail fast) -> releases/<ts> -> atomic
  `current` symlink flip -> docker compose up -d -> /healthz gate ->
  flip back to `previous` and exit non-zero on failure. `--rollback`
  restores the previous release in one command. Auto-detects the Go
  toolchain in /nix/store when `go` is not on PATH.
- deploy/docker-compose.yml: 127.0.0.1:8096, mem_limit == memswap_limit ==
  120m, capped json-file logs (10m x 3), restart unless-stopped.
- deploy/Dockerfile: runtime image only (alpine + prebuilt payload), so a
  release needs no toolchain, no network and no package install.
- deploy/rehearse.sh: repeatable proof (21 assertions, exit 0) of deploy,
  build fail-fast, health-gate rollback, --rollback and N3 restart.
- deploy/README.md + docs/phase-3-evidence.md: how it works, the build
  contract Phase 2 must satisfy, the Caddy block for Sam, and the open
  bind-address question (loopback-only vs reachable from .35).

Satisfies DoD items 1, 3 (container), 4, 8. Items 2, 5, 6, 7 need the Phase 2
binary and Sam's Caddy change; recorded as deferred with owners, not claimed.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-26 17:31:13 +11:00

4.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: 127.0.0.1:8096, 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, publishes on 127.0.0.1 only, 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.

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 — see docs/phase-3-evidence.md for the open bind-address question that decides whether that block points at 192.168.20.13:8096 or at .13's Caddy.