Repository navigation
Deployment
Pushing to main is the deploy — and what to do when Portainer's button returns a 500.
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:
- Commit with a stamped
CHANGELOG.mdentry, push tomain. - Wait for the run to go green — the image is published.
- Nothing. Both running instances pull it themselves within about five minutes. See How the image reaches the servers below.
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 -devery 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.
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 nameghcr.io/soccerbeats/weddingwebsite:latest— do not change it. The Dockerfile runsnpm run buildinternally, so there is no local build step first.
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-prodcreated-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 recreatewedding-web-prodwithdocker run. It produces a container with noconfig-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.
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 webIf 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 psThe image's own entrypoint/cmd are correct (docker-entrypoint.sh + sh -c "/app/init-db.sh && node server.js") — do not override them.
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, reseedsA 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 changesIf that second command does change something, DEMO_MODE was refused — check
the database in DATABASE_URL is named demo. See
The demo instance.
Setting it up
Using the admin panel
- Guests and RSVPs
- Seating and the Export
- Registry
- Finances
- Honeymoon Portal
- Content and Settings
- Offline
How it works