# 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// immutable: bin/server, dist/, compose, Dockerfile, manifest.txt, .env ├── current -> releases/ live release; swapped with ln + mv -T (atomic rename) ├── previous -> releases/ 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 ```bash 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 ```bash ./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).