Files
ai-resume/docs/phase-3-evidence.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

9.8 KiB

Phase 3 evidence — public deployment

Status: mechanism complete and rehearsed; public URL deferred. The deploy machinery exists, is rehearsed against the real release root and the real port on .13, and leaves nothing running. The site itself is not deployed because Phase 2 has not produced a binary yet, and one decision is needed from Sam before the container can be reachable by Caddy on .35.

Recorded 2026-10-26 on .13 (nixos-desktop), run 630e2ed9.


Definition of Done

# Item Verdict Evidence
1 deploy.sh and deploy.sh --rollback work; a deliberate bad build proves rollback met §1, deploy/rehearse.sh 21/21
2 https://resume.lab.audasmedia.com.au serves the site deferred Phase 2 has not produced a binary; Caddy block not yet applied (§4, §5)
3 Container bound to 127.0.0.1 only, ss -ltn met for the container; conflicts with reachability from .35 — see §5 §2
4 memswap_limit == mem_limit, docker inspect met §2
5 Security headers present, CSP does not break the page deferred headers belong to the .35 Caddy block (§4)
6 A1 passes over the public URL with the AI layer stopped deferred needs a live URL
7 Sam applied the Caddy block and confirmed other lab hostnames deferred — Sam's action block and verification steps in §4
8 Rollback rehearsed once and documented met §1, deploy/rehearse.sh

Nothing in the deferred column is asserted. Each depends on the Phase 2 binary (items 2, 5, 6) or on Sam (items 2, 5, 7).


1. What was rehearsed, and the result

deploy/rehearse.sh builds throwaway services and drives the real script. Full transcript:

== 0. preconditions
  PASS  port 8096 free
  PASS  three throwaway sources built in /tmp/.../resume-rehearsal-snpEjy

== 1. good release deploys and health-gates
  PASS  deploy.sh exit 0
  PASS  /healthz 200 on 127.0.0.1:8096
  PASS  serving release 20261026T062957Z
  PASS  container publishes on 127.0.0.1 only
  PASS  memswap_limit == mem_limit (125829120 bytes)

== 2. build failure fails fast, current does not move
  PASS  deploy.sh exit 1 (non-zero, as required)
  PASS  current unchanged (/home/sam/deployment/resume/releases/20261026T062957Z)
  PASS  previous release still serving

== 3. unhealthy release is rolled back by the health gate
  PASS  deploy.sh exit 1 (non-zero, as required)
  PASS  current restored to /home/sam/deployment/resume/releases/20261026T062957Z
  PASS  rolled-back release serving again
  PASS  log records the health-gate failure

== 4. deploy.sh --rollback restores the previous release in one command
  PASS  second good release deployed
  PASS  rollback exit 0
  PASS  current flipped back to /home/sam/deployment/resume/releases/20261026T062957Z
  PASS  rolled-back release healthy
  PASS  serving 20261026T062957Z again (was 20261026T063021Z)

== 5. N3: docker compose down && up restores the service
  PASS  service down after compose down
  PASS  service restored after compose up

== verdict
passed 21, failed 0

The health-gate rollback in the deploy log, verbatim:

06:28:46Z deploy: previous -> /home/sam/deployment/resume/releases/20261026T062828Z
06:28:46Z deploy: current  -> /home/sam/deployment/resume/releases/20261026T062842Z
06:29:02Z deploy: HEALTH GATE FAILED for 20261026T062842Z
06:29:02Z rollback: 20261026T062842Z failed -> restoring .../releases/20261026T062828Z
06:29:04Z health: http://127.0.0.1:8096/healthz -> 200
06:29:04Z rollback: previous release healthy (.../releases/20261026T062828Z)

The deliberate bad build, verbatim:

$ ./deploy/deploy.sh --source .../app-broken
cmd/server/main.go:11:12: syntax error: unexpected {, expected )
exit=1
current before: .../releases/20261026T062828Z
current after:  .../releases/20261026T062828Z      # unchanged
health after broken build: 200                     # old release still serving

Re-run it any time with ./deploy/rehearse.sh (exit 0 = every assertion held).


2. Container limits and bind scope

$ ss -ltn | grep 8096
LISTEN 0  4096  127.0.0.1:8096  0.0.0.0:*

$ docker inspect -f '...' resume-app
name=/resume-app
mem_limit=125829120 bytes          # 120 MiB
memswap_limit=125829120 bytes      # 120 MiB — equal, so no host swap
port_bindings={"8096/tcp":[{"HostIp":"127.0.0.1","HostPort":"8096"}]}
restart=unless-stopped
log_config={"Type":"json-file","Config":{"max-file":"3","max-size":"10m"}}

Measured memory (idle Go static server, placeholder build):

rss=1.5MiB / 120MiB pct=1.25%
rss=6.9MiB / 120MiB pct=5.78%    # just after start, before settling

Host pressure at the time of the rehearsal — this is why the cap is 120m:

$ free -m
               total   used   free   available
Mem:           15918  14255    227        1663

No residue was left behind: after the rehearsal there is no resume-app container, no resume/app:* image, nothing listening on 8096, and /home/sam/deployment/resume/ does not exist. deploy.sh creates it on the first real deploy. (The pre-existing ai-resume-* containers from /home/sam/deployment/ai-resume/ were not touched.)


3. Facts established on the host

Fact Value
Port 8096 free (ss -ltn; 8090, 8091, 8092, 8095 are taken)
Release root /home/sam/deployment/resume/ — created by deploy.sh
Go toolchain not on PATH; /nix/store/…-go-1.26.5/bin/go, auto-detected by the script
Container runtime Docker 29.6.2, Compose 5.3.1
.13 runs its own Caddy caddy run --config /etc/static/caddy/caddy_config, listening *:8000, 8 legacy site blocks, all reverse_proxy localhost:<port>
Services added since then bind 0.0.0.0 and are proxied by .35 directly on their own LAN port (hub 8788, compass 8792, email-agent 8790, webrestrict 8791)

4. Caddy block for Sam to apply (.35, additive, caddy reload only)

resume.lab.audasmedia.com.au {
	encode zstd gzip
	reverse_proxy 192.168.20.13:8096
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		X-Frame-Options "SAMEORIGIN"
		Content-Security-Policy "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'"
	}
	@static path *.css *.js *.svg *.png *.jpg *.webp *.woff2
	header @static Cache-Control "public, max-age=31536000, immutable"
}

Apply, in this order:

# 1. add the block above to the Caddyfile on .35, then validate:
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
# 2. reload — not restart, so other sites are not interrupted:
caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
# 3. confirm the other hostnames first, from anywhere on the LAN:
for h in gitea.lab.audasmedia.com.au immich.lab.audasmedia.com.au \
         nextcloud.lab.audasmedia.com.au penpot.lab.audasmedia.com.au \
         wiki.lab.audasmedia.com.au chat.lab.audasmedia.com.au; do
  printf '%-45s %s\n' "$h" "$(curl -s -o /dev/null -w '%{http_code}' "https://$h/")"
done
# 4. then the new one (404/502 is expected until Phase 2's binary is deployed):
curl -sSI https://resume.lab.audasmedia.com.au/ | sed -n '1,12p'

Header check after that (item 5 of the DoD): the response above must include Strict-Transport-Security, X-Content-Type-Options, Referrer-Policy, X-Frame-Options and Content-Security-Policy. If the page renders but the CSP breaks it, the CSP is wrong — do not weaken it to unsafe-inline for scripts.


5. Open question for Sam — bind address vs Caddy reachability

The phase document says two things that cannot both be true today:

  • §3.2 and DoD item 3: the container publishes on 127.0.0.1:8096 only.
  • §3.3: Caddy on .35 proxies to 192.168.20.13:8096.

A loopback-only listener on .13 is not reachable from .35. Measured:

$ curl -sS -m3 http://192.168.20.13:8096/healthz
curl: (7) Failed to connect to 192.168.20.13:8096

.13's own Caddy (*:8000, /etc/static/caddy/caddy_config) only serves eight legacy hostnames; every service added since (hub, compass, email-agent, webrestrict) publishes on 0.0.0.0 and is proxied by .35 directly to its LAN port. That is the working pattern, and it is what §3.3's block assumes.

Option What changes Consequence
A (recommended, matches every current service) publish 127.0.0.1:8096 and 192.168.20.13:8096 deploy.sh keeps its loopback health gate; .35 reaches the app; §3.3 applied verbatim; no NixOS change. Exposure is the .13 LAN, same as hub/compass today. DoD item 3 / N1 can then only claim "not on 0.0.0.0" — needs your written change
B (strict loopback) add http://resume.lab.audasmedia.com.au:8000 { reverse_proxy localhost:8096 } to .13's Caddyfile, and .35's block becomes reverse_proxy 192.168.20.13:8000 Satisfies N1 literally. But /etc/static/caddy/caddy_config is NixOS-generated — it needs a NixOS config change and nixos-rebuild, which this phase forbids and only you can do

The committed docker-compose.yml currently follows the phase document (option A's loopback half only: 127.0.0.1:8096). Choosing A or B is a one-line change in deploy/docker-compose.yml plus, for B, the NixOS change on .13.


6. Deferred, with owners

Item Owner Unblocked by
Deploy the real site (DoD 2) this issue, woken by AUD-132 Phase 2 producing bin/server + dist/ from make build
Header + CSP proof (DoD 5) this issue, after the Caddy block is applied Sam applies §4
A1 over the public URL (DoD 6) this issue, after 2 and 5 —
Caddy block applied and other hostnames confirmed (DoD 7) Sam §4
Bind-address decision (§5) Sam A or B above