"Build once, deploy the same artifact everywhere" sounds obvious until you notice how many pipelines quietly violate it — rebuilding from source at each deployment stage, so "what's running in staging" and "what's running in production" are two different builds that merely came from the same source code.
4 min read
A pipeline that builds the app fresh at each stage — build-and-test in CI, then a separate build step when deploying to staging, then another separate build step when deploying to production — looks reasonable at a glance. It has a real problem: each of those builds can differ. A dependency resolves to a slightly different version if a package's version range allows it and time has passed between builds. A build-time environment variable differs between CI and the deploy runner. The tests that passed were run against one artifact; the thing actually running in production is a different one that was never directly tested — it just came from the same source.
The fix is to build exactly once, then promote that same, unchanged artifact through each environment:
What passed the tests in CI is bit-for-bit what runs in production — not "a build from the same commit," but the literal same compiled bytes.
Two common tagging schemes solve different problems:
v2.4.1) — meaningful to humans, communicates "this is a minor release," useful for a public package or API consumers who need to reason about compatibility.a1b2c3d) — meaningless to humans, but unambiguous and automatic: every commit gets a distinct artifact, with zero manual versioning decisions, and "what commit is running in production" is answerable by reading the deployed image's tag directly.Most internal-service pipelines use the git SHA (or a build number) as the primary artifact tag specifically because it removes a manual step (nobody has to decide "is this a patch or a minor version bump" on every single merge to main) and because it's directly traceable back to an exact source state:
docker build -t my-registry/my-app:${GITHUB_SHA} .
docker push my-registry/my-app:${GITHUB_SHA}A separate, human-facing semantic version can still exist for public releases or changelogs — the two aren't mutually exclusive, they just serve different audiences.
A tag that can be overwritten (like :latest, or a semantic version pushed twice by mistake) breaks the entire premise of promoting a tested artifact — "deploy my-app:v2.4.1" stops meaning one specific, fixed set of bytes the moment v2.4.1 can be silently replaced by a different build. Many registries support enforcing this directly:
# AWS ECR — reject any attempt to push a tag that already exists
aws ecr put-image-tag-mutability \
--repository-name my-app \
--image-tag-mutability IMMUTABLECombined with git-SHA tagging (where every commit naturally produces a distinct tag), this makes "overwrite an existing artifact" structurally impossible rather than just a convention people are expected to follow.
A promotion-based deploy step doesn't rebuild — it just points the next environment at the artifact that already exists and already passed CI:
jobs:
deploy-staging:
steps:
- name: Deploy the exact artifact from CI
run: |
aws ecs update-service \
--cluster staging \
--service my-app \
--task-definition my-app:${{ github.sha }}
deploy-production:
needs: deploy-staging
# Manual approval gate here, per Continuous Delivery vs Deployment
steps:
- name: Promote the same artifact to production
run: |
aws ecs update-service \
--cluster production \
--service my-app \
--task-definition my-app:${{ github.sha }}Both deploy jobs reference the identical ${{ github.sha }} tag — the production deploy step contains no docker build at all. It couldn't rebuild something different even if it tried.
Every commit to main producing a new tagged image means the registry grows without bound unless something prunes it — and unlike the log-retention cost gotcha covered in the Cloud Computing domain, this isn't just a storage-cost problem; a registry with thousands of untagged or ancient images also makes "which of these are actually safe to delete" a much harder question to answer later. A retention policy — keep the last N images, or keep anything referenced by a currently-deployed environment plus a rollback window, delete the rest — keeps this bounded and answerable:
resource "aws_ecr_lifecycle_policy" "cleanup" {
repository = aws_ecr_repository.my_app.name
policy = jsonencode({
rules = [{
rulePriority = 1
description = "Keep only the last 20 images"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 20
}
action = { type = "expire" }
}]
})
}Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What problem does 'build once, promote the same artifact' solve that rebuilding at each stage doesn't?
2. Why do many internal-service pipelines tag artifacts with the git SHA rather than a semantic version?
3. Why does an overwritable tag like ':latest' undermine the promotion model?
4. Why does an artifact registry need an explicit lifecycle/retention policy?
CI/CD & Deployment Pipelines