Loading
← All notes

Build once, promote by tag: how we cut deployment errors by 99%

If production runs an image that UAT never saw, you are testing one thing and shipping another. Here's the pipeline change that fixed it for the telco agent apps.

Filed underDocker, Jenkins and CI/CD

On the telco agent apps at DPL, releases were where things went wrong. Deployments failed, took too long, and sometimes shipped issues that had never appeared in testing and only surfaced at runtime in production. I automated the pipeline with Docker and Jenkins, and the change that mattered most was simple to state: the image that passes UAT is the image that goes to production. Deployment errors fell by 99%.

Rebuilding is the bug

If every environment gets its own build, every environment gets its own artifact. Even from the same commit, a rebuild can pull a newer base image, resolve a dependency differently, or bake in a different setting. The build that UAT signed off and the build that reached production were never the same thing, and "it worked in PreProd" stopped meaning anything.

Rebuild per environmentBuildsha a1f3PreProd · UAT≠Buildsha 7c20ProductionTwo builds, two artifacts.What reached production was neverthe thing UAT signed off.Build once, promoteBuildsha 4d2ePreProd · UAT ✓New tagProductionsha 4d2esha 4d2esame image
Fig. 1Rebuilding per environment produces a new artifact every time, so production runs something UAT never saw. Building once and promoting by tag means production runs the exact image that passed.

Make PreProd a replica of production

Promoting an image only works if the place it was tested looks like the place it's going. Our PreProd was a replica of production, so a UAT sign-off there was a sign-off for production too. If PreProd drifts, UAT is testing a different system, and promotion just moves the surprise later.

Promote, don't rebuild

The pipeline builds once and pushes the image with an identity that never changes. After UAT is signed off on PreProd, promotion is only a new tag on that same image:

# Build stage: build and push once, tagged with the build number
docker build -t registry.example.com/agent-api:build-$BUILD_NUMBER .
docker push registry.example.com/agent-api:build-$BUILD_NUMBER

# Promotion stage, after UAT sign-off on PreProd: no build, just a new tag
docker pull registry.example.com/agent-api:build-$BUILD_NUMBER
docker tag  registry.example.com/agent-api:build-$BUILD_NUMBER \
            registry.example.com/agent-api:release-$RELEASE_VERSION
docker push registry.example.com/agent-api:release-$RELEASE_VERSION

Production then deploys release-$RELEASE_VERSION, which points at exactly the bytes UAT tested. Nothing is compiled between sign-off and release, so nothing new can sneak in.

Tags are the release record

  • One immutable identity per build. The build tag is written once and never moved.
  • Release tags are names, not new builds. They make it obvious what's running, and rolling back is deploying the previous release tag.
  • Never reuse a tag. Moving a tag to a different image quietly rewrites history, which is the problem this whole setup exists to prevent.

Keep configuration out of the image

Build-once has one precondition: nothing environment-specific can be baked into the image. Connection strings, endpoints, and feature settings come from the environment at deploy time. If a setting forces a rebuild per environment, you're back to shipping untested artifacts.

What changed

Deployment errors dropped by 99%. Releases got faster, because there was nothing left to build at release time. And the runtime issues that used to appear only in production mostly stopped, because production was finally running the thing we had tested.

keep readingkeep reading

More notes

keep in touchkeep in touch

New notes go up here first, then on LinkedIn.

Short write-ups on problems I run into while building payment and backend systems. Follow along, or tell me what you'd like me to write about next.

Let's talk