The deploy loop for my self-hosted portfolio has no SSH step in it. GitHub
Actions builds a Docker image on every push to main and publishes it to a
registry. On the server, Watchtower polls that registry every 60 seconds and
restarts the container when a new image appears.
The server never checks out the repo and never runs a build. docker compose up
on any machine is a full deploy target.
# docker-compose.yml
web:
image: ghcr.io/ariefrsee/portfolio:latest
watchtower:
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
Push, CI, registry, live. Two footguns cost me a debugging session each, and they share a shape worth noticing.
Footgun 1: build-time env vars do not exist in CI
With a static export, NEXT_PUBLIC_* values are baked in at build time, not read
at runtime. My local builds had them. The CI workflow did not pass them as
build-args.
The result deployed green. Working site, working build, and a contact form that silently posted to nothing. Every visible signal said success, because every visible signal was about the build rather than the behaviour.
Footgun 2: Watchtower's Docker client is ancient
Watchtower's bundled client speaks Docker API 1.25. A current daemon rejects anything below 1.40. Every poll cycle errored before it ever reached the registry, so auto-deploy simply stopped happening.
time="2026-07-06T02:14:01Z" level=info msg="Watchtower polling registry"
time="2026-07-06T02:14:01Z" level=fatal msg="client version 1.25 is too old, minimum supported API is 1.40"
(repeats every 60s: container reports "healthy," site stays stale)
No crash. Container healthy. Deploys frozen. The fix is one environment variable:
# docker-compose.yml
watchtower:
environment:
- DOCKER_API_VERSION=1.44
The shape they share
Both failures ran the pipeline successfully and were invisible from the outside. Neither produced a red mark anywhere. In both cases the thing that was broken was downstream of everything that was being checked.
That pattern turned out to be general enough that I started collecting instances of it, and general enough that I eventually made CI verify the live site directly.