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>
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
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
/healthz, publishes on127.0.0.1only, 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.
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.