feat(deploy): replace the PM2 build-on-server deploy with Docker images (#1121) - #1154
Conversation
…es (#1121) The deploy SSHed in, `git reset --hard`, `uv venv --clear && uv sync`, `npm ci && npm run build`, then `pm2 delete && pm2 start`. A failure anywhere in that sequence left the box holding new code and a dead process, with no previous build to fall back to — and no artifact had ever been proven to build before it reached a host. CI now builds two images and pushes them to GHCR tagged by commit SHA. The deploy is `docker compose pull && docker compose up -d`. Nothing is built on the server. Dockerfile backend: python:3.11-slim, uv sync --frozen, non-root, git present (workspaces and worktrees need it at RUNTIME) web-ui/Dockerfile frontend: multi-stage → Next.js standalone runner docker-compose.yml + .staging.yml / .production.yml overrides Decisions the issue asked to be made explicitly: - **One image per environment.** NEXT_PUBLIC_* are inlined into the client bundle at build time, so they are build args and staging/production need separately tagged frontend images. BACKEND_ORIGIN turned out to be the same: standalone output snapshots next.config.js into required-server-files.json, so a runtime value is ignored — found by watching the container still dial localhost:8000 and 500 on /api/*. - **Guard, don't delete, for production.** Both paths are converted so nothing is half-migrated, but production has never run (#1143's preflight still refuses it), and the compose override says so rather than implying it works. - **Delegated coding agents are not in the image.** claude-code/codex/opencode are absent from the container, so those engines will not work on a containerised host. The built-in ReAct engine is unaffected. Documented, not silently broken — this was the migration risk the issue flagged. Safety properties preserved rather than assumed: - Ports publish on 127.0.0.1 only. nginx stays the sole public listener (#747) — 0.0.0.0 inside a container is fine, publishing on 0.0.0.0 is not, and a test asserts every mapping in both overrides. - DATABASE_PATH and WORKSPACE_ROOT are named volumes. Anything written inside the image is gone the moment a new tag is pulled. - The npm audit gate (#1131) moved into the image build. Deleting build-on-server would otherwise have deleted the gate with it; in the image it is stronger, since an image carrying a high advisory cannot be built at all. - #933's release-tag hardening is intact and simpler: the production deploy no longer checks out the tag, and the tag still crosses to the server base64 encoded. - #912's shared-VPS invariant is carried over. `pm2 delete all` once stopped every unrelated app on this box; `docker system prune` and `docker stop $(docker ps -q)` are the same blunt instrument. The scanner now catches both, verified against four host-wide forms and three scoped ones. Retired: ecosystem.{staging,production}.config.js, the root package.json (it existed only for PM2's dotenv), systemd/codeframe-staging.service, and scripts/{deploy,start}-staging.sh. tests/test_deploy_config.py is rewritten against the compose stack. Three pre-existing tests had to move with the thing they guarded rather than be deleted — the audit level, the tag hardening, and the stop-scoping. Verified locally end to end before any CI change: both images build, the stack comes up healthy, /health 200, the API is auth-gated (401), the frontend serves and its /api proxy reaches the backend, and /data survives a down/up. Full backend suite 6510 passed.
…s pull (#1121) Both P1s from review, both real. **The deploy jobs could not pull what the build jobs pushed.** They inherit the workflow's `permissions: contents: read`, while only the build jobs asked for package access — and GHCR packages are private by default even for a public repo. So the images would build, and the deploy would then fail at `docker compose pull`. Added `packages: read` to both deploy jobs. **The first containerised deploy would have come up with an empty database.** The PM2 layout kept SQLite on the host (`/opt/codeframe/.codeframe/state.db` — 152K of real data on staging, including the operator's own login account); the containers use a named volume, which starts empty. Silently. The deploy now seeds the volume from `.env.<env>`'s DATABASE_PATH when the volume has no database yet. Idempotent by construction: once a database is there the step leaves it alone, so re-running a deploy cannot clobber live data with a stale copy. Verified on the actual box rather than reasoned about: run 1: copied into the volume run 2: volume already has a database — left alone ls: -rw-r--r-- 1 10001 10001 155648 codeframe.db The chown is not decoration. Docker applies the image directory's ownership only when it initialises an EMPTY named volume, and this one is pre-populated — so without it the DB lands root-owned and the non-root backend cannot write to it. Also caught on the box. WORKSPACE_ROOT is deliberately not migrated (clones and worktrees are reproducible); deploy/README.md documents the manual copy for a host that has ones worth keeping.
|
Claude finished @frankbria's task in 13m 56s —— View job GLM precision review — PR #1154 (Docker deploy)Bug-hunting pass: logic errors, security, data loss, race conditions, breaking contracts. (Style, architecture, naming, and coverage are handled by the separate general review — not covered here.)
Two concrete defects found, posted as inline review comments:
Notes on things checked and cleared:
Not modified, not pushed — review only. |
ReviewSolid piece of infra work overall — the CI-builds-then-pulls shape is the right fix for the original problem, the compose files are careful about loopback binding and volumes, the DB-migration step is idempotent and was verified on the actual box, and the test rewrite (parsing YAML instead of grepping the raw workflow) closes a real self-identified gap (#1113/#1116/#1064-style false positives from matching explanatory comments). The two P1s fixed in the second commit ( One gap worth closing before calling the migration complete:
|
The real staging deploy failed, which is exactly why the issue says this cannot
be validated from a PR:
failed to bind host port 127.0.0.1:14200/tcp: address already in use
The PM2 backend and frontend were still running and holding the ports. The
migration needs a cutover step, not just a state copy.
The deploy now retires the two legacy processes BY NAME — read from the
PM2_BACKEND_NAME/PM2_FRONTEND_NAME secrets, which exist only for this — and
never a pm2 delete of 'all', which on this shared VPS takes out unrelated apps
(#912). It is a no-op forever after, and on any host that never ran PM2.
Also labels the pre-created volume so compose adopts it instead of warning
'already exists but was not created by Docker Compose' on every deploy. Noise
that trains you to ignore the log is worth removing while it is one line.
Two deploy tests updated to encode the real invariant rather than a blanket ban:
the deploy may RETIRE a PM2 process, but must never start one, and a stop must
still name its target.
shellcheck caught a backtick I put inside the unquoted heredoc while writing
that comment — the #1130 bug, in the same file, three hours later. Which is the
argument for having turned it on.
|
Claude finished @frankbria's task in 16m 23s —— View job Precision bug-hunting review — PR #1154 (Docker deploy)Bug-hunting pass on the synchronized commit
✅ No new defects in
|
| Area | Verdict |
|---|---|
PM2 cutover quoting (deploy.yml:385-393) |
Sound. ${LEGACY_BACKEND_NAME}/${LEGACY_FRONTEND_NAME} are deliberately runner-expanded (baking the non-secret process names in); \${legacy} is escaped so the loop iterates server-side. No backticks / $( ) in the unquoted heredoc → no #1130 command-substitution hazard. |
| #912 shared-VPS invariant | Held. pm2 delete targets per-process names from secrets, never all; the host-wide-docker regex and test_pm2_scoping_912 both pass. (The unconditional pm2 save rewrites the box's resurrection dump each deploy — not a true no-op as the commit message claims — but it stops/deletes nothing, so it is not a #912 violation. Low impact, noted not flagged.) |
| Cutover ordering / race | Safe. Delete-by-name runs under command -v pm2 + pm2 describe with || true, and completes before compose pull/up, so 14100/14200 are free before the containers bind. Idempotent on every later deploy (describe finds nothing). |
Volume labelling (deploy.yml:401-404) |
Correct. Labels com.docker.compose.project=codeframe + =codeframe-data match the compose name: and volume name, so compose adopts the pre-seeded volume. docker volume create is a no-op on the second deploy (volume exists, labels unchanged) — fine. |
Test rewrite (test_deploy_config.py, test_pm2_scoping_912.py) |
Correct. The forbidden list now targets pm2 start/pm2 restart (allows the cutover delete); the naming regex pm2 (stop|delete) "?\\?\$\{?\w+ matches the new pm2 delete "\${legacy}" form. The regex still rejects unscoped all (caught by _PM2_ALL). |
Previously-reported findings still stand
35ef536 touched neither, so both remain valid on the current HEAD — not re-posted, just flagged as still open:
| Severity | File:line | Finding | Status |
|---|---|---|---|
| major | .github/workflows/deploy.yml:745-746 |
Production "Create pre-deployment backup" still runs git rev-parse HEAD / git log -1 under set -e on a host with no checkout → first production deploy aborts at the backup step, before compose pull. |
open |
| minor | docker-compose.production.yml:12 |
Override hardcodes published port 14400, decoupled from secrets.API_PORT that the verify step curls → false-negative health failure if API_PORT ≠ 14400. |
open |
(The general-review concerns — scripts/health-check.sh + its systemd unit left dangling on PM2 retirement, the echo "${GHCR_TOKEN}" argv exposure window, and the hardcoded codeframe_codeframe-data volume-name coupling — are also still open and likewise untouched by this commit.)
Not modified, not pushed — review only.
Review (re-review after the PM2-cutover commit)This is a solid migration — CI-builds-then-pulls is the right fix for the original build-on-server problem, and the third commit's fix (retiring PM2 by name before the containers bind) is a real, verified-on-the-box fix for the actual staging failure. The compose loopback-only binding, the idempotent DB-volume migration, and the audit-gate relocation into the image build all check out on inspection. Two things flagged by the earlier GLM review are still present in the current Still open — major: production's pre-deployment backup step will fail on the very first production deploy
git rev-parse HEAD > ${TMP_BACKUP}/previous_commit.txt
git log -1 --format="%H %s" >> ${TMP_BACKUP}/previous_commit.txtunder Still open — minor: production's health check can false-negative regardless of deploy success
ports:
- "127.0.0.1:14400:14200"but both health-check steps ( Both of these are specific to the production path, which is why the extensive local/staging verification in the PR description didn't catch them — production genuinely can't be exercised from a PR per #1143's own preflight gate. Worth fixing before the first real production run rather than discovering them the way the PM2-port conflict was discovered on staging. Also still open from my prior review (unchanged by the latest commit)
Nothing new to flag in the third commit itself — the PM2-retirement logic (name-checked via |
Staging is containerised, verified on the boxThe acceptance criterion CI cannot satisfy. Deploy run 31569173089: 44 seconds, down from ~9 minutes — because nothing is built on the server any more. $ curl https://dev.codeframe.sh/health → 200
$ curl https://dev.codeframe.sh/ → 200
$ curl https://dev.codeframe.sh/api/v2/settings/keys → 401 (auth intact)
$ ssh staging 'docker compose ... ps'
backend ghcr.io/frankbria/codeframe-backend:35ef536… Up (healthy)
frontend ghcr.io/frankbria/codeframe-frontend:35ef536… Up (healthy)
$ ssh staging 'pm2 list | grep -c codeframe'
0The first attempt failed, which is the pointThe PM2 processes were still holding the ports. A state copy was not enough — the migration needed a cutover. That is precisely the class of thing the issue said could not be validated from a PR, and it would have shipped as a broken first deploy. The deploy now retires the two legacy processes by name (from the The database migration carried real data$ docker run --rm -v codeframe_codeframe-data:/data alpine ls -ln /data
-rw-r--r-- 1 10001 10001 155648 codeframe.db ← owned by the container's uid
$ docker compose exec backend python -c "…select count(*) from users"
users 1That is the operator's own login account, which a fresh volume would have silently dropped. Rollback, on the box, no rebuild$ export IMAGE_TAG=6579b27… # the previous commit
$ $COMPOSE pull && $COMPOSE up -d
backend …codeframe-backend:6579b27… Up (healthy)
frontend …codeframe-frontend:6579b27… Up (healthy)
health: 200
$ export IMAGE_TAG=35ef536… # forward again
users after rollback+forward: 1 ← volumes untouched by either directionTwo image swaps, both healthy in under 20 seconds, database intact through both. Every acceptance criterion
One more thing shellcheck caughtWhile writing the cutover comment I put backticks inside the unquoted heredoc — the exact #1130 bug, in the same file, hours after fixing it. |
…ives no checkout (#1121) Two bot review findings, both real. **The pre-deployment backup ran git on a host that no longer has a checkout.** Since the deploy pulls an image, `git rev-parse HEAD` legitimately finds no repository — and under `set -e` that aborted the entire backup step on a fresh production host. The commit is now recorded when it happens to be knowable, and the image list (which IS the version now) is recorded always. **Production hardcoded the published port to 14400 while the verify step curled ${{ secrets.API_PORT }}.** An operator setting API_PORT to anything else got a container on one port and a health check on another. Both overrides now read ${BACKEND_PORT}/${FRONTEND_PORT}, and the deploy exports them from the generated .env's API_PORT — one secret drives both. Fixing that surfaced a real bug in my own compose layout: compose APPENDS port lists across files rather than replacing them, so the base mapping plus the override meant production tried to bind BOTH 14200 and 14400. Verified with `compose config`. The base file now declares no ports at all and each environment override owns its mapping.
|
Claude finished @frankbria's task in 7m 32s —— View job Precision bug-hunting review — PR #1154 (Docker deploy)Bug-hunting pass on the new synchronized commit
✅ No new defects in
|
| Prior finding | Status | Why it's actually fixed |
|---|---|---|
major — deploy.yml backup step git rev-parse HEAD / git log -1 aborted under set -e on a checkout-less host |
resolved | The git calls are now wrapped in { git rev-parse HEAD 2>/dev/null || echo "no-checkout"; git log -1 … 2>/dev/null || true; }. Under set -e (L728, note: -e only, no pipefail), commands participating in a || list are exempt from errexit, and the group's exit status is the last command (git log … || true → 0). The added docker compose … images … 2>/dev/null || true is likewise safe. First production deploy no longer dies at the backup step. |
minor — production published port hardcoded 14400, verify step curled secrets.API_PORT |
resolved | The verify step curls secrets.API_PORT (L882); the deploy now export BACKEND_PORT="$(grep '^API_PORT=' .env.production | cut -d= -f2-)" (L859), and the override binds 127.0.0.1:${BACKEND_PORT:-14400}:14200. .env.production's API_PORT is itself generated from secrets.API_PORT (L682) and is in the REQUIRED fail-fast list (L554), so published-port == verify-port, both rooted in the one secret. Staging is symmetric (L372, default 14200). |
What I checked in the new code and cleared
grep \| cut \|\| trueinside$(…)underset -euo pipefail(L372, L859): the\|\| trueis inside the command substitution, so a no-matchgrep(exit 1) cannot trip pipefail and abort —BACKEND_PORTjust lands empty, then the:-14200/:-14400default applies. Correct.- The in-container listen port is the load-bearing assumption — and it holds. The backend
CMDis… --port 14200(Dockerfile:53,EXPOSE 14200), an explicit CLI flag that overridesserver.py:956'sBACKEND_PORT/PORTdefault. So the container always listens on 14200 regardless of any env. The override's right-side port stays:14200and the in-container healthcheck (docker-compose.yml:49) curls127.0.0.1:14200— both match the fixed bind. This is what makes "vary only the host port" safe. API_PORTvsBACKEND_PORTconflation is intentional, not a bug.API_PORT(from the secret →.env) drives the host publication + verify curl; the server ignores it for binding. The deploy bridges the two by reading.env'sAPI_PORTinto theBACKEND_PORTcompose-substitution var. The host-shellexport BACKEND_PORT=…is NOT passed into the container (not inenvironment:orenv_file; the generated.envcontainsAPI_PORT, notBACKEND_PORT), so it cannot perturb the in-container bind.- Compose substitution precedence.
--env-file .env.<env>does not defineBACKEND_PORT, so the shell-exported value wins uncontested (shell > env-file). No stale value can shadow it. - Base
docker-compose.ymldroppingports:(the append-double-bind fix): verified correct. With no override the service is simply unpublished (documented design); with either override, exactly one mapping applies. The frontend'sdepends_on: service_healthystill works because the backend healthcheck uses the in-container port, which needs no host mapping.FRONTEND_PORTis never exported, so it always takes the override default (14300/14100) — harmless, and there is no frontend verify curl to drift against. set -escope: the backup step and the deploy step are separate ssh heredocs (L727/820, L844/872); eachset -eis local. The backup'sdocker compose imagesmay emit an emptyprevious_images.jsonon a first deploy (no containers / compose files not yet scp'd), but the\|\| truemakes that a non-issue.
Notes
- The general-review concerns from prior rounds (
scripts/health-check.sh+ its systemd unit left dangling on PM2 retirement, theecho "${GHCR_TOKEN}"argv-exposure window, the hardcodedcodeframe_codeframe-datavolume-name coupling) are out of scope for this precision pass and were untouched by7278f85; still open as previously reported. - Not modified, not pushed — review only.
Both bot findings fixed, and one of them exposed a bug of mineThe pre-deployment backup ran Production hardcoded the published port to Fixing that surfaced a real bug in my own compose layout. Compose appends port lists across files rather than replacing them, so the base mapping plus the override meant production would try to bind both 14200 and 14400: $ docker compose -f docker-compose.yml -f docker-compose.production.yml config | grep published
published: "14200" ← from the base
published: "14400" ← from the override
published: "14300"The base file now declares no ports at all; each environment override owns its mapping: $ … -f docker-compose.staging.yml config | grep -E "host_ip|published"
host_ip: 127.0.0.1 published: "14200"
host_ip: 127.0.0.1 published: "14100"
$ … -f docker-compose.production.yml config | grep published
published: "14400"
published: "14300"Staging never hit this because both its mappings named the same port. Production would have failed on its first deploy — the environment that cannot be tested. Re-deploying to staging to confirm the port refactor against the live stack; will report. |
Re-review — latest commit (
|
|
Re-deploy after the port refactor: ✓ 41s. Both containers healthy, single loopback mapping each, database intact. $ docker compose ps
backend Up 44 seconds (healthy) 127.0.0.1:14200->14200/tcp
frontend Up 38 seconds (healthy) 127.0.0.1:14100->14100/tcp
$ curl https://dev.codeframe.sh/health → 200
$ curl https://dev.codeframe.sh/ → 200
$ curl https://dev.codeframe.sh/api/v2/settings/keys → 401
$ …exec backend python -c 'select count(*) from users' → 1Exactly one published port per service now — the double-bind the base+override append would have caused in production is gone, and staging is unaffected by the change. All checks green, no unresolved threads. |
Closes #1121.
What changed
The deploy SSHed in,
git reset --hard,uv venv --clear && uv sync,npm ci && npm run build, thenpm2 delete && pm2 start. A failure anywhere in that sequence left the box holding new code and a dead process, with no previous build to fall back to — and no artifact had ever been proven to build before it reached a host.CI now builds two images and pushes them to GHCR tagged by commit SHA. The deploy is
docker compose pull && docker compose up -d. Nothing is built on the server.Dockerfilepython:3.11-slim,uv sync --frozen, non-root, git present (workspaces and worktrees need it at runtime)web-ui/Dockerfiledocker-compose.yml+.staging.yml/.production.ymlDecisions the issue asked to be made explicitly
One image per environment.
NEXT_PUBLIC_*are inlined into the client bundle at build time, so they are build args.BACKEND_ORIGINturned out to be the same — standalone output snapshotsnext.config.jsintorequired-server-files.json, so a runtime value is ignored. Found by watching the container still diallocalhost:8000and 500 on/api/*, not by reading docs.Guard, don't delete, for production. Both paths are converted so nothing is half-migrated, but production has never run (#1143's preflight still refuses it) and the compose override says so rather than implying otherwise.
Delegated coding agents are not in the image.
claude-code/codex/opencodeare absent from the container, so those engines will not work on a containerised host. The built-in ReAct engine is unaffected. This was the migration risk the issue flagged; it is documented, not silently broken.Safety properties carried over, not assumed
127.0.0.1:14200/127.0.0.1:14100.0.0.0.0inside a container is fine; publishing on0.0.0.0is not — a test asserts every mapping in both overrides ([P1.20] Terminate TLS in front of the services (they bind 0.0.0.0 over plaintext HTTP/ws) #747).DATABASE_PATH=/data/codeframe.db,WORKSPACE_ROOT=/workspaces. Anything written inside the image is gone the moment a new tag is pulled.--audit-level=highran as part of build-on-server; deleting that step would have deleted the gate with it. It now runs in the image build, where it is stronger — an image carrying a high advisory cannot be built at all.all#912's shared-VPS invariant is carried over.pm2 delete allonce stopped every unrelated app on this box.docker system pruneanddocker stop $(docker ps -q)are the same blunt instrument, so the scanner now catches both — verified against four host-wide forms and three scoped ones.Two P1s from review, both real
The deploy jobs could not pull what the build jobs pushed. They inherit
permissions: contents: read, and GHCR packages are private by default even for a public repo — so the images would build and the deploy would fail atcompose pull. Fixed withpackages: read.The first containerised deploy would have come up with an empty database. Staging holds 152K of real SQLite on the host, including the operator's own login account. The deploy now seeds the volume from
.env.<env>'sDATABASE_PATHwhen the volume has none. Verified on the actual box, not reasoned about:The
chownis not decoration: Docker applies the image directory's ownership only when it initialises an empty named volume, and this one is pre-populated — so without it the DB lands root-owned and the non-root backend cannot write to it. Also caught on the box.Retired
ecosystem.{staging,production}.config.js, the rootpackage.json(it existed only for PM2'sdotenv),systemd/codeframe-staging.service,scripts/{deploy,start}-staging.sh.Verified locally, end to end, before any CI change
Both images build; the stack comes up healthy;
/health200; the API is auth-gated (401); the frontend serves and its/apiproxy reaches the backend (401, not 500);/datasurvives adown/up.ruff,actionlint(shellcheck on)Still to do on this PR
A real staging deploy is the acceptance criterion CI cannot satisfy — including a rollback to a prior SHA. I will run it from this branch and report the result here before merging.
A note on my own testing
Three times in this PR I wrote a test that grepped the raw workflow for a forbidden command and matched the comment explaining why the command is gone. Same substring-as-classifier mistake as #1113/#1116/#1064. The deploy assertions now parse the YAML and strip comments via a shared
_deploy_commands()helper.