Field NoteInfrastructureAutomation#Kubernetes#UAT#Production#GitHub Actions#Payload CMS#Next.js

How a change to this site reaches production

The release path of this site: an image built once per commit, tested in UAT against the live cluster state, then promoted to production without a rebuild.

Release path from a GitHub Actions build to a private registry, an import into containerd, a backup and drift check, a rollout with migrations, UAT checks and production
The same image tag travels the whole path. A failed UAT check sends the change back to a new commit.

This site is a Next.js application with Payload CMS on PostgreSQL. Its release path is short enough to run for every change, and production only ever receives an image that UAT has already run.

One image per commit

A push to the release branch starts a GitHub Actions workflow. It builds the production bundle and packages a container image tagged uat- followed by the commit hash. The image goes to a private registry, GitHub Container Registry, and an exported copy stays attached to the workflow run for a few days.

The tag never moves. The uat- prefix says where the image gets validated first. Production later runs the same tag, byte for byte.

Getting the image onto the server

The site's namespace holds no registry credentials. We import the image from the operator's workstation straight into the containerd store that k3s uses, and the pod is set to pull only when the image is missing. Since it is already there, the pod starts without contacting a registry.

Two checks before anything changes

  1. Back up the UAT database with a labelled dump, so the release has a restore point.
  2. Run kubectl diff on the environment overlay. The only expected difference is the new image tag. Anything else means the cluster and the repository have drifted apart, and the release waits until someone understands why.

Migrations run when the application starts

Payload applies pending database migrations when the application initialises. Schema changes and content changes go through the same mechanism: the redesign of this site is a migration that rewrites twelve pages, every service description and every offer, in three languages.

Request path from Cloudflare through Traefik and a Kubernetes Service to the pod and PostgreSQL, with a probe at each hop
The smoke test follows the route a visitor uses. The first probe that changes points to the failing hop.

What has to pass in UAT

  • The pod runs the expected tag. The check reads the image from the running pod, not from the manifest.
  • /api/health answers, and the logs since the rollout show no new errors.
  • The public pages render in French, Dutch and English through the real route: Cloudflare, Traefik, the Service, the pod and the database.
  • Content published in the CMS shows up on the site.
  • The contact form reaches n8n and produces the WhatsApp notification. That test sends a real message, so we run it on purpose rather than on every release.

Promotion changes one line

Production gets its own database backup first. Its overlay then receives the tag that passed UAT, the same kubectl diff runs, and the rollout follows. The previous tag is written down in the repository as the rollback target. Rolling back means applying that tag again, and restoring the database only if the release changed data in a way the previous version cannot read.

← All field notes