diff --git a/README.md b/README.md index ef193ae..a22d78e 100755 --- a/README.md +++ b/README.md @@ -246,6 +246,8 @@ The middleware is attached to each admin **router** rather than to a path prefix Only after the change has been reviewed in QA. +This is the routine deploy, and it assumes the production stack already runs from `docker-compose.prod.yml` as a git repository stack. Moving it there in the first place is a different, one-time operation with a different order — see [docs/ops/production-stack-cutover.md](docs/ops/production-stack-cutover.md). + The scheduled backups in `docker-compose.prod.yml` do **not** replace step 1 below. They run while the stack runs, so they cannot cover a deploy that recreates it — and a nightly dump is up to a day old, where this one is seconds old. See [docs/ops/backup-and-restore.md](docs/ops/backup-and-restore.md) for what each covers and how to restore either. ```bash diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4348ed8..49342e2 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -8,17 +8,18 @@ # UPLOADS_DIR had no line here — while being set in Portainer's stack variables, # where it does nothing. Committing the file is what lets the guard cover it. # -# Name the Portainer stack `redefined-designs`, NOT `redefined-designs-qa`. -# The stack name becomes the compose project name, and reusing QA's would make -# compose reconcile the two against each other. -# # DEPLOY THIS AS A GIT REPOSITORY STACK, not from the web editor — otherwise the # file here and the file that actually runs drift apart again, which is the # whole problem this is solving. # -# Repository: https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs -# Reference: refs/heads/main -# Compose path: docker-compose.prod.yml +# Migrating production onto it is a one-time procedure with an order that +# matters, and it is written down rather than restated here: +# +# docs/ops/production-stack-cutover.md +# +# Routine deploys after that are README.md, "Promoting a reviewed change to +# production". The two are different operations and the runbook says which is +# which. # # 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, @@ -78,8 +79,9 @@ # USPS_CLIENT_SECRET validation; the app degrades gracefully rather than # failing, so an empty value is a working configuration. # -# If the existing stack in Portainer uses different names for any of these, -# rename them there to match — the names above are what this file reads. +# The names above are what this file reads. A stack variable under any other +# name is substituted nowhere and never reaches the container, so reconciling +# them is a step of its own in the cutover runbook. services: redefined-designs: diff --git a/docs/ops/production-stack-cutover.md b/docs/ops/production-stack-cutover.md new file mode 100644 index 0000000..640eb9b --- /dev/null +++ b/docs/ops/production-stack-cutover.md @@ -0,0 +1,207 @@ +# Cutting production over to the committed compose file + +A one-time migration: production stops running from a stack definition that exists only in Portainer's web editor and starts running from `docker-compose.prod.yml` in this repository, deployed as a git repository stack. + +This is not the routine deploy. For promoting a reviewed change to a stack that is already in this form, use [Promoting a reviewed change to production](../../README.md#promoting-a-reviewed-change-to-production) instead. Do that one often; do this one once. + +## What this actually changes + +Nothing about how production runs. Same image, same containers, same data, same port, same proxy in front of it. What changes is where the definition lives, and therefore whether anything can check it — `backend/tests/unit/composeEnvironment.test.ts` reads every compose file in this repository and hands its environment to the real validator. A stack in the web editor is invisible to that. On 2026-08-23 production refused to boot because `UPLOADS_DIR` had no line in it, and no test could have caught that. See #118. + +After the cutover, changing production means changing this file and merging it. + +## The two facts that decide whether this is safe + +**Your data is not in Docker.** Both services bind-mount from the NAS filesystem: + +``` +/volume1/configs/redefined-designs/postgres → the database +/volume1/configs/redefined-designs/uploads → product images +``` + +Those are paths on the NAS, not named Docker volumes. Deleting the stack removes containers; it does not touch either directory. This is why the cutover is recoverable at all — but step 2 verifies it rather than trusting it, because the whole procedure rests on it being true of the stack you have *now*. + +**Container names are fixed.** `redefined-designs-syn` and `redefined-designs-db-syn` are set explicitly in the compose file, so two stacks cannot hold them at once. The old stack must be gone before the new one comes up, or the deploy fails with a name collision that reads like a Portainer bug rather than a sequencing mistake. + +## Before you start + +Production will be down for the length of this, which is a few minutes if nothing surprises you. Pick a time when that is fine. + +You need SSH to the NAS and Portainer access. + +Read [backup-and-restore.md](backup-and-restore.md) first if you have not. The scheduled backups in the compose file do **not** cover this operation — they run while the stack runs, so they cannot cover a deploy that recreates it. + +--- + +## 1. Back up + +Not optional, and not the nightly dump. This is the one deploy that recreates everything, so take a backup that is seconds old rather than up to a day old. + +```bash +# Database +sudo docker exec -t redefined-designs-db-syn pg_dump -U redefined -d redefined \ + > /volume1/configs/redefined-designs/cutover-$(date +%Y%m%d-%H%M).sql +ls -lh /volume1/configs/redefined-designs/cutover-*.sql +``` + +The size must be plausible — a dump of a few kilobytes means it dumped an empty database and you should stop and find out why before going further. + +```bash +# Uploads +sudo tar -czf /volume1/configs/redefined-designs/cutover-uploads-$(date +%Y%m%d-%H%M).tar.gz \ + -C /volume1/configs/redefined-designs uploads +``` + +Copy both somewhere that is not `/volume1`. A backup on the same disk as the thing it protects is a convenience, not a backup. + +## 2. Record what exists today + +Everything here is lost when the stack is deleted, and the rollback in step 8 is only credible if you have it. Write it down somewhere outside Portainer. + +**The stack name**, exactly as Portainer shows it. If it is not `redefined-designs`, note that — the new stack must be created with that name, because the stack name becomes the compose project name and reusing QA's would make Compose reconcile the two against each other. + +**Every stack environment variable, name and value.** These are all secrets. Portainer shows them under the stack's editor. The database password is among them, and a new stack brought up with a different `DB_PASSWORD` than the data directory was initialised with will fail to authenticate against its own database. + +**The image the app container is running:** + +```bash +sudo docker inspect --format '{{.Config.Image}}' redefined-designs-syn +sudo docker images | grep redefined-designs +``` + +**The mounts** — this is the verification that the whole procedure rests on: + +```bash +sudo docker inspect --format '{{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' \ + redefined-designs-db-syn redefined-designs-syn +``` + +Every line must say `bind`, and the sources must be under `/volume1/configs/redefined-designs`. If any line says `volume`, **stop**. Your data is in a Docker-managed volume that stack deletion may remove, and this runbook does not cover that case — you would need to migrate that volume onto the bind-mount path first. + +**A record of what is in the database**, so step 7 can prove nothing was lost: + +```bash +sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined \ + -c "SELECT count(*) FROM items;" \ + -c "SELECT count(*) FROM customers;" \ + -c "SELECT name, run_on FROM pgmigrations ORDER BY run_on DESC LIMIT 3;" +``` + +## 3. Reconcile the variable names + +`docker-compose.prod.yml` reads a specific set of names. The authoritative list is in the header comment of that file, under "Required stack environment variables". If the stack you recorded in step 2 uses different names for any of them, the new stack must use the names the file reads. + +This matters more than it looks. Portainer's stack variables are substituted into the compose file as `${VAR}`; they are not handed to the container. A variable whose name does not match anything in the file is silently substituted nowhere, and the failure reads as "I set it and the app says it is not set". + +Write out the mapping now, while the old stack still exists to check against. + +## 4. Make sure the image exists + +The new stack has no `build:`. It runs `redefined-designs:latest`, which must already be on the NAS, or the deploy fails with "image not found" rather than quietly building one (#146). + +```bash +sudo docker images | grep 'redefined-designs.*latest' +``` + +If it is missing, or if you want production to run the image QA reviewed rather than whatever `latest` currently points at: + +```bash +sudo docker tag redefined-designs:qa redefined-designs:latest +sudo docker inspect --format '{{.Config.Cmd}}' redefined-designs:latest +# must print: [sh -c node migrate.js up && node dist/server.js] +``` + +That `Cmd` check is worth doing. It is what makes the container migrate before serving, so deployed code can never be ahead of the schema. + +## 5. Remove the old stack + +In Portainer: **Stacks → the stack from step 2 → Delete**. + +This removes the containers. It does not remove `/volume1/configs/redefined-designs/postgres` or `.../uploads`, which you verified as bind mounts in step 2. + +Confirm the names are actually free, because the next step fails on a collision: + +```bash +sudo docker ps -a | grep redefined-designs +``` + +Anything still listed for production must be removed before continuing: + +```bash +sudo docker rm -f redefined-designs-syn redefined-designs-db-syn +``` + +Leave QA's containers alone — they are `redefined-designs-qa-*` and are a different stack. + +## 6. Create the git repository stack + +Portainer: **Stacks → Add stack → Repository**. + +| Field | Value | +| --- | --- | +| Name | `redefined-designs` — not `redefined-designs-qa` | +| Build method | Repository | +| Repository URL | `https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs` | +| Reference | `refs/heads/main` | +| Compose path | `docker-compose.prod.yml` | + +Add the environment variables from steps 2 and 3, under the names the compose file reads. + +**Leave any "pull latest image" or re-pull option OFF.** There is no registry to pull this image from; it exists only on the NAS. Turning it on makes a present image report itself as a registry authentication failure. + +Deploy the stack. + +## 7. Verify + +In this order. Each step answers a different question, and the later ones are meaningless if an earlier one failed. + +```bash +# Did it start, and start once? +sudo docker logs redefined-designs-syn | head -40 +``` + +Migration output must appear *before* `listening on 3000`, and `listening on 3000` must appear exactly once. Repeats mean a crash loop. + +**Expected warnings.** These are correct and must not be silenced: + +- `MAIL_ALLOWLIST is not set` — production is the one environment that has to reach real customers. +- `UPLOADS_BASE_URL is not set` — the uploads origin is not configured yet (#103). Expected until an NPM host exists for it. + +**Warnings that mean something is wrong:** + +- `ADMIN_GATE_SECRET is not set` — the variable did not reach the container. Almost certainly a name mismatch from step 3. The admin API is protected only by the proxy until this is fixed. +- Any `[config]` error, which stops the container rather than warning. + +```bash +# Is the data the same data? +sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined \ + -c "SELECT count(*) FROM items;" \ + -c "SELECT count(*) FROM customers;" \ + -c "SELECT name, run_on FROM pgmigrations ORDER BY run_on DESC LIMIT 3;" +``` + +The counts must match what you recorded in step 2. If they are zero, the container came up against a fresh data directory rather than the existing one — stop, and check the mount paths before doing anything else. + +```bash +# Are the images still served? +sudo docker exec redefined-designs-syn ls /app/uploads | head +``` + +Then load the storefront in a **private window** — aggressive bundle caching on this project has produced false "still broken" reports after correct deploys. Check that product images render, sign in, and open `/admin` to confirm authentik and the admin gate still agree. + +## 8. If it goes wrong + +Nothing has been destroyed. The data directories were never touched, and you recorded the old definition in step 2. + +Delete the new stack, recreate the old one from that record with its original variables, and deploy it. Production comes back on the same data. + +Restoring the step 1 dump is a last resort, not a first move — reach for it only if the database itself is damaged, and follow [backup-and-restore.md](backup-and-restore.md). + +## After + +The compose file in this repository is now what production runs. Changing it means changing it here and merging, and `composeEnvironment.test.ts` checks it on every push. + +Two things worth doing while you are in there: + +- Confirm `ADMIN_GATE_SECRET` is genuinely set, rather than assumed. It is the application-layer half of the admin boundary (#63), and the boot log tells you plainly. +- Portainer will now show the stack as out of date when `main` moves. That is the point, but note that redeploying from git does **not** repull or rebuild the image — promoting a new image is still the separate, deliberate step the README documents.