Skip to content

Deployment

Austin Stanfield edited this page Aug 25, 2026 · 4 revisions

Deployment

Pushing to main is the deploy — and what to do when Portainer's button returns a 500.

The pipeline

A merge to main publishes the image. There is no laptop step: GitHub Actions runs the build, and the "Wedding Planner" workflow (.github/workflows/ci-cd.yml) pushes to GitHub Container Registry.

Event What happens
Pull request (incl. forks) Image is built, never pushed — no credentials needed
Push to main Publishes :latest, :v<CHANGELOG version> and :sha-<short sha>
Push to develop Builds only
v* tag Publishes the version and sha tags — never moves :latest

:latest is what the Portainer stack pulls, so only main is allowed to move it. A tag marks a past release (git tag v0.9.5 <old sha>), and if tagging moved :latest the next redeploy would quietly ship old code.

The version in the tag comes from the topmost vX.Y.Z heading in CHANGELOG.md, the same way the admin panel decides what version it is — so stamp the changelog in the same commit. The workflow also runs npm run build and npm run check:changelog, so a broken commit or a malformed entry fails there rather than in a container.

A second workflow, "Demo Instance" (.github/workflows/demo-deploy.yml), waits for that image and then builds and publishes the seeder (ghcr.io/soccerbeats/weddingwebsite-seeder:latest), so the demo can reseed itself with data for whatever just shipped. See The demo instance.

So the deploy loop is:

  1. Commit with a stamped CHANGELOG.md entry, push to main.
  2. Wait for the run to go green — the image is published.
  3. Nothing. Both running instances pull it themselves within about five minutes. See How the image reaches the servers below.

How the image reaches the servers

Publishing an image and deploying it are separate problems, and this project solves the second by pulling, not pushing — nothing on either server has to be reachable from CI, and no credential for either machine sits in a public repository.

Each host runs a systemd timer that compares the digest of ghcr.io/soccerbeats/weddingwebsite:latest against the image its container is actually running, and redeploys only when they differ:

Host Unit Scope
docker-server (production) prod-autoupdate.timer --no-deps web — the database is never recreated because an app image moved. Curls the site afterwards and logs the status code
ovh-docker (the public demo) demo-autoupdate.timer The whole stack, so the one-shot seeder re-runs and the demo gains data for whatever shipped

Both live in ops/ with install steps. Two details make them safe to leave running:

  • Only-when-changed is load-bearing. A blind compose up -d every five minutes would re-run the demo's seed service continuously — three dozen photographs re-fetched each time, and a window where a visitor sees an empty site.
  • Comparing the running container against the freshly pulled image, rather than remembering what was last deployed, means an instance left behind by a half-finished update is noticed on the next tick instead of at the next release.

Compose does the recreate, never docker run — see the 500 section below for why that matters.

The Portainer webhook, and why it is not used

CI has a redeploy job that POSTs a stack's redeploy webhook when PORTAINER_PROD_WEBHOOK is set. It is not set, and the job says so on every run rather than going green having done nothing.

The reason is worth recording, because it looks like a Portainer limitation and is not one. Two webhook tokens generated in the UI for the production stack were both absent from Portainer's own database afterwards — stack 146 reads "Webhook":"" and "AutoUpdate":null — and the endpoint answers a real token and a made-up UUID identically:

404 {"message":"Unable to find the stack by webhook ID"}

But two other stacks on the same Portainer do have persisted webhooks, and both are the same kind of stack (Type=2, no Git config). So webhooks work here; they just would not save on this one. The most likely explanation is that the toggle persists when set while creating a stack and is dropped when set on an existing one — both working examples are long-standing stacks, both failed attempts were on an existing one.

Before trusting any webhook URL, check it actually persisted (Portainer runs in Proxmox LXC 210; docker-server only runs the agent):

ssh root@10.0.0.100 'pct exec 210 -- grep -a -o "THE-UUID" \
  /var/lib/docker/volumes/portainer_data/_data/portainer.db'

Silence means Portainer did not save it. Setting the secret to a dead URL would fail the job on every merge, so leave it unset until the grep prints the UUID.

A separate constraint: the OVH demo box can never have one. It is a Portainer edge environment, and edge stacks have no per-stack webhook at all — the option is genuinely absent, not hidden.

Building by hand is still possible and is occasionally the fastest way to test a Dockerfile change, but it is no longer how anything gets deployed:

docker build --target production -f docker/Dockerfile \
  -t ghcr.io/soccerbeats/weddingwebsite:latest .

⚠️ Always use image name ghcr.io/soccerbeats/weddingwebsite:latest — do not change it. The Dockerfile runs npm run build internally, so there is no local build step first.

If Portainer's "Pull and redeploy" returns 500

Root cause (confirmed 2026-07-29): Docker Compose discovers a project's containers by filtering on the presence of the com.docker.compose.config-hash label — a label compose writes only on containers it creates itself. A container made by hand with docker run can never have it (you cannot add a label to an existing container). So compose sees zero containers for service web, tries to create a fresh one, and collides with the name that is already taken:

Stack pull successful
Container wedding-web-prod  Error response from daemon: Conflict.
The container name "/wedding-web-prod" is already in use by container "..."

The image pull itself succeeds — only the container swap fails. Two side effects:

  • The web container keeps running the old image, so it looks like the deploy silently did nothing.
  • A failed redeploy can leave wedding-db-prod created-but-stopped, i.e. the database down. Always check after a failed attempt:
    docker ps -a | grep wedding-db-prod   # start it if it isn't running

Diagnose in one command — if wedding-web-prod is missing from this list, that is the bug:

docker ps -a --filter label=com.docker.compose.project=weddingwebsite \
             --filter label=com.docker.compose.config-hash --format '{{.Names}}'

Fix: the container must be created by compose. Adding labels by hand cannot work — see Manual deploy below, which uses docker compose for exactly this reason. After the fix, docker compose ... up -d --dry-run reports both containers as Running instead of Creating, and Portainer's button works again.

⚠️ Never recreate wedding-web-prod with docker run. It produces a container with no config-hash, which silently re-breaks Portainer's "Pull and redeploy" until someone recreates it with compose again.

Portainer-EE itself runs in Proxmox LXC 210, not on docker-server (which only runs portainer_agent). Direct SSH to 10.0.0.210 fails; reach it via ssh root@10.0.0.100 then pct exec 210 -- .... Stack id is 146; its compose file lives inside that container at /var/lib/docker/volumes/portainer_data/_data/compose/146/v1/docker-compose.yml, and the stack's env vars (DATABASE_URL, POSTGRES_USER, POSTGRES_DB) live in Portainer's own database, not in that file.

Manual deploy (bypassing Portainer)

Use docker compose, never docker run — this is what keeps the config-hash label intact so Portainer can still manage the stack afterwards.

A mirror of the Portainer stack lives on docker-server at /data/compose/146/v1/ (docker-compose.yml + stack.env, mode 600 — it holds the DB password and ADMIN_PASSWORD). The paths deliberately match the ones inside the Portainer container so the resulting labels line up.

ssh root@10.0.0.188
cd /data/compose/146/v1
docker pull ghcr.io/soccerbeats/weddingwebsite:latest

# --no-deps so the database is left running untouched
docker compose -p weddingwebsite --env-file stack.env -f docker-compose.yml \
  up -d --no-deps --force-recreate web

If the stack files are ever missing, re-copy them from Portainer:

ssh root@10.0.0.100 'pct exec 210 -- cat /var/lib/docker/volumes/portainer_data/_data/compose/146/v1/docker-compose.yml'
ssh root@10.0.0.100 'pct exec 210 -- cat /var/lib/docker/volumes/portainer_data/_data/compose/146/v1/stack.env'

Verify:

docker inspect wedding-web-prod --format '{{.Config.Labels}}' | grep -o 'config-hash:[^ ]*'  # must be present
curl -o /dev/null -w '%{http_code}\n' http://localhost:3000/             # 200
curl -o /dev/null -w '%{http_code}\n' http://localhost:3000/admin/rsvps  # 307 (login redirect)
docker compose -p weddingwebsite --env-file stack.env -f docker-compose.yml ps

The image's own entrypoint/cmd are correct (docker-entrypoint.sh + sh -c "/app/init-db.sh && node server.js") — do not override them.

Redeploying the demo

The public demo — weddingwebsitedemo.com — runs on the OVH box as the Portainer stack weddingwebsitedemo, with Nginx Proxy Manager on the same machine in front of it (forwarding to 172.17.0.1:3001, since the two stacks sit on different Docker networks). Redeploy it from Portainer, or by hand:

ssh root@40.160.231.11
cd /data/compose/160/v1
docker compose pull      # app image *and* seeder
docker compose up -d     # recreates web, reseeds

A second copy runs on docker-server at :3001 from /data/compose/demo/docker-compose.yml — not proxied, LAN only.

The seed runs on every up, so the demo comes back holding data for whatever just shipped. Verify:

curl -s https://weddingwebsitedemo.com/api/demo-status   # {"demo":true,...}
curl -s -o /dev/null -w '%{http_code}\n' -X PATCH \
  -H 'Content-Type: application/json' -d '{"id":1,"guest_name":"x"}' \
  localhost:3001/api/admin/guest-list                   # 200, and nothing changes

If that second command does change something, DEMO_MODE was refused — check the database in DATABASE_URL is named demo. See The demo instance.


← All pages

Clone this wiki locally