fix(deploy): run the image QA reviewed rather than rebuilding production (#146)
Linting / lint (pull_request) Successful in 1m53s
SonarQube Analysis / sonarqube (pull_request) Successful in 18m27s

The production compose committed in #145 carried `build:` and `pull_policy: build`, copied from the QA stack without thinking about what they mean there. QA builds from this repository because QA is where a change is first assembled and reviewed. Production is not that; production is where the reviewed thing runs.

Worse, it contradicted the deploy this repository already documents. README's production steps promote the exact image QA reviewed — `docker tag redefined-designs:qa redefined-designs:latest` — with the stated reason that what ships is what was tested. A compose file that rebuilds instead quietly overrode that, and the two would have disagreed at the moment it mattered.

Rebuilding would be a defensible shortcut if a rebuild of the same commit produced the same image. It does not. The Dockerfile copies package.json without package-lock.json and installs with `npm install`, so both lockfiles in this repository are ignored in every build stage and every dependency range is resolved afresh. Two builds of one commit, minutes apart, can differ in any transitive dependency that published in between. "Same git ref" is therefore not "same image", and the reviewed bytes are the only thing that is.

So the service now declares `image: redefined-designs:latest` and nothing else. The image has to exist before the stack starts; a first deploy or a pruned NAS fails with "image not found" rather than silently building something new. That is the intended behaviour and is written into the file rather than left to be discovered.

The header records what removing `pull_policy: build` costs, because it is not free. That option exists in QA to stop a redeploy reusing a stale tag and appearing to succeed while running old code. Production reintroduces the same risk by a different route — a redeploy that reuses the previous `latest` because nobody re-tagged — so the promotion is load-bearing rather than a convenience, and the deploy has to say which image it is promoting.

Also corrects the README's claim that production runs from a stack outside this repository and so cannot be checked. That was true when it was written and stopped being true in #118; leaving it would have taught the next reader that the environment which just failed to boot is the one nothing watches.

The remaining half of #146 — copying the lockfiles and installing with `npm ci` so any rebuild means something — is not in this commit. It changes what CI and QA build as well as production, and belongs with its own verification that an unchanged commit produces an unchanged dependency tree.

Refs #146
This commit is contained in:
2026-08-23 16:41:08 -05:00
parent 50c9b0dd39
commit e48c7f585b
2 changed files with 29 additions and 12 deletions
+3 -1
View File
@@ -118,7 +118,9 @@ mkdir -p /tmp/redefined-uploads
`PUBLIC_URL` is required only alongside SMTP because its only job is building links in email; an environment that cannot send mail does not need it. `UPLOADS_DIR` has no such reprieve — its fallback of `/app/uploads` is correct inside the container and wrong everywhere else.
**Adding to the required list has reach beyond this repository.** A variable added to `ALWAYS_REQUIRED` must also be set in every environment that deploys, and there are two of those. `docker-compose.qa.yml` is checked automatically — `backend/tests/unit/composeEnvironment.test.ts` reads the validator's own list and fails if the compose file does not set something on it, which is what #107 existed to prevent from recurring. Production runs from a Portainer stack **outside this repository**, so nothing can check it: that one has to be updated by hand, before the deploy rather than during it.
**Adding to the required list has reach beyond this repository.** A variable added to `ALWAYS_REQUIRED` must also be set in every environment that deploys, and there are two of those. Both are checked automatically — `backend/tests/unit/composeEnvironment.test.ts` reads the validator's own list and fails if a compose file does not set something on it, which is what #107 existed to prevent from recurring. It runs over every deployment file in the repository and hands each one's entries to `validateEnv` itself, so a file is checked against exactly what the container checks at boot.
Production was outside that net until #118. It ran from a stack that existed only in Portainer's web editor, which no test could read — and on 2026-08-23 it refused to boot because `UPLOADS_DIR` had no line in it, while being set in Portainer's stack variables. `docker-compose.prod.yml` is now in the repository and covered like QA's, which is also why it must be deployed as a **git repository stack** rather than pasted into the web editor: a pasted copy drifts from the checked one and the guard goes back to being decorative.
Note also that setting a variable in Portainer's stack environment is not the same as giving it to the container. Stack variables are interpolated into the compose file as `${VAR}`; a service receives exactly what its own `environment:` block lists. A variable with no line there never arrives, however carefully it was set in the UI.
+26 -11
View File
@@ -20,10 +20,30 @@
# Reference: refs/heads/main
# Compose path: docker-compose.prod.yml
#
# `pull_policy: build` matters. Without it the stack reuses whatever is already
# tagged redefined-designs:latest, which is how a redeploy can appear to succeed
# while still running old code. Leave any Portainer option that re-pulls images
# turned OFF — there is no registry to pull this image from.
# THIS STACK DOES NOT BUILD. It runs the image tagged redefined-designs:latest,
# which has to already exist on the NAS before the stack starts. A first deploy,
# or a NAS that has pruned images, fails with "image not found" rather than
# quietly building one — see #146.
#
# That is deliberate. QA builds from this repository and is reviewed; production
# then runs the image that was reviewed, promoted by hand:
#
# docker tag redefined-designs:qa redefined-designs:latest
#
# Building here instead would look simpler and would ship something else. The
# Dockerfile copies package.json without package-lock.json and installs with
# `npm install`, so two builds of the same commit can resolve different
# transitive dependencies. "Same git ref" is therefore not "same image", and the
# reviewed bytes are the only thing that is.
#
# The promotion is load-bearing, not a convenience. Forgetting it means a
# redeploy that reuses the previous redefined-designs:latest and appears to
# succeed while running old code — the same failure `pull_policy: build` guards
# against in QA, arriving by a different route. State which image is being
# promoted as part of the deploy.
#
# Leave any Portainer option that re-pulls images turned OFF — there is no
# registry to pull this image from.
#
# WHY MOST VALUES ARE HARDCODED HERE RATHER THAN INTERPOLATED
#
@@ -59,14 +79,9 @@
services:
redefined-designs:
# Built from this repository by Portainer rather than pulled. The same
# Dockerfile QA uses, so the two images differ only in configuration.
build:
context: .
dockerfile: Dockerfile
# No `build:` — see the note at the top of this file. This tag is produced
# by promoting the image QA was reviewed against, not by rebuilding here.
image: redefined-designs:latest
# Always build; never reuse the existing tag.
pull_policy: build
container_name: redefined-designs-syn
environment:
- TZ=America/Chicago