Guides/DockerDocker/Docker in CI/CD Pipelines

Docker in CI/CD Pipelines

The build, tag, push, deploy flow for Docker images in CI: registry auth, immutable tags by commit SHA, promoting one digest, BuildKit caching, and multi-arch.


Getting a Dockerfile to build on your laptop is the easy part. The interesting problems show up when a pipeline builds the image on a fresh runner, pushes it to a registry, and something else pulls it to deploy - hopefully the exact bytes you tested, not a rebuild that quietly picked up a newer base image. This guide walks the full loop a CI/CD pipeline runs for a container: build, tag, push, deploy. Along the way it covers how to authenticate to a registry from CI, a tagging strategy that does not bite you later, why you build an image once and promote the same digest through environments, how to keep caches warm so pipelines stay fast, and multi-arch builds at a high level. The examples use GitHub Actions, but every idea maps onto GitLab CI, Jenkins, or anything else.

The loop: build, tag, push, deploy

A container pipeline is the same four steps every time, and it helps to name them clearly because each has its own failure mode.

  • Build - run docker build (or docker buildx build) against your Dockerfile on a CI runner. The output is an image: a stack of layers plus a manifest, addressed by a content hash called a digest (sha256:...).
  • Tag - attach human-friendly names to that image so people and deploy tooling can refer to it: myapp:1.4.0, myapp:git-a1b2c3d, myapp:latest. A tag is just a pointer; the digest is the real identity.
  • Push - upload the image to a registry (Docker Hub, GitHub Container Registry, Amazon ECR, Google Artifact Registry, a self-hosted Harbor). The registry is the handoff point between the pipeline that built the image and whatever deploys it.
  • Deploy - something (a kubectl apply, a docker compose pull, a cloud deploy API) pulls the image from the registry and runs it on the target.

The rest of this guide is really just those four steps done carefully. The two things that separate a robust pipeline from a fragile one are how you tag (so you can always say exactly what is running) and making sure the thing you deploy is byte-for-byte the thing you built and tested.

Authenticating to a registry from CI

Before a runner can push, it has to log in. On your laptop that is docker login typed once. In CI there is no human, so you feed credentials from the pipeline's secret store and log in non-interactively.

The mechanics are the same across registries: a username and a token piped into docker login on stdin, never echoed or hardcoded. In GitHub Actions the ecosystem provides a login action, but under the hood it is just this:

echo "$REGISTRY_TOKEN" | docker login registry.example.com -u "$REGISTRY_USER" --password-stdin

A few rules that save real pain:

  • Never hardcode credentials in the Dockerfile or the workflow file. Use the CI system's secret store (repo/org secrets, a vault) and reference them as environment variables.
  • Prefer short-lived, scoped tokens over long-lived passwords. Cloud registries (ECR, Artifact Registry) issue short-lived tokens via OIDC/workload identity, so CI never holds a static password at all. GitHub Container Registry accepts the built-in GITHUB_TOKEN, scoped to the job.
  • Give the token the least access it needs. A build job needs push to one repository, not admin on the whole registry.

Here is the login step in GitHub Actions, using the built-in token to push to GitHub Container Registry (ghcr.io):

- name: Log in to GHCR
  uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}

secrets.GITHUB_TOKEN is minted per run and expires when the job ends, which is exactly what you want - nothing static to leak.

A sane tagging strategy

Tags are where most people quietly hurt themselves, usually by leaning on latest. The problem with latest is that it is a moving pointer with no memory: it tells you nothing about what is actually running, two builds can both be latest at once, and a rollback is impossible because you cannot name the previous one. Use it for convenience, never as the thing you deploy.

The strategy that holds up is to tag every build with two kinds of tags:

  • An immutable tag tied to the commit - the Git short SHA, like myapp:git-a1b2c3d (or the full SHA). This tag is unique to exactly one build and should never be overwritten. It is how you say, without ambiguity, "production is running commit a1b2c3d." Rollback is trivial: deploy the previous SHA tag.
  • A moving tag for convenience - latest on the main branch, or a semantic version like 1.4.0 for a release. These help humans and are fine as long as you never treat them as the source of truth for what is deployed.

You push both. Deploy tooling references the immutable one.

- name: Compute tags
  id: meta
  run: |
    SHA=$(git rev-parse --short HEAD)
    echo "sha_tag=ghcr.io/${{ github.repository }}:git-$SHA" >> "$GITHUB_OUTPUT"
    echo "moving_tag=ghcr.io/${{ github.repository }}:latest" >> "$GITHUB_OUTPUT"

If you version releases, add a semver tag on tagged commits too. The rule to hold onto: there is always at least one tag that maps to exactly one build and never changes. That is the tag your deploys and your incident timeline depend on.

Build once, promote the same digest

Here is the mistake that looks harmless and causes the worst surprises: rebuilding the image for each environment. You build and test in staging, it passes, then your "promote to prod" job runs docker build again. That second build is not guaranteed to produce the same image. FROM node:20 may resolve to a newer patch, apt-get install may pull updated packages, a dependency may have shifted. Now prod is running an image nobody tested.

The fix is build once, promote the same artifact. The pipeline builds and pushes exactly one image, and every environment after that refers to that same image by its digest - the sha256:... content hash the registry returns on push. A digest is immutable by definition: myapp@sha256:9f2c... will always be the identical bytes, forever, on any machine. Promotion becomes "deploy this digest to prod," not "build again and hope."

In practice, that means your build job records the digest as an output, and downstream deploy jobs consume it:

- name: Build and push
  id: build
  uses: docker/build-push-action@v6
  with:
    push: true
    tags: ${{ steps.meta.outputs.sha_tag }},${{ steps.meta.outputs.moving_tag }}

# steps.build.outputs.digest is now sha256:... - the immutable identity.
# Pass that digest to staging, then to prod. Same bytes, tested once.

Tags are pointers and can move; a digest cannot. When you want a guarantee about what is running, you talk in digests. Tags are for humans, digests are for the pipeline.

Layer caching so pipelines stay fast

A CI runner starts clean. Nothing is cached, so unless you do something about it, every build pulls the base image and re-runs every layer from scratch - reinstalling dependencies, recompiling - even when only one source file changed. That is the difference between a 40-second build and a 6-minute one, on every push.

Docker's build engine, BuildKit, can cache layers, but on an ephemeral runner the cache has to live somewhere that survives between runs. Two common approaches:

  • CI-provided cache - GitHub Actions exposes a cache backend you point BuildKit at with cache-from/cache-to. Layers are stored in the Actions cache and restored on the next run.
  • Registry cache - BuildKit writes cache metadata and layers to a registry (often a dedicated cache tag or repo) and reads them back. This works anywhere, across runners and even across different CI systems, because the cache lives in the same registry as your images.
- name: Build and push (with cache)
  uses: docker/build-push-action@v6
  with:
    push: true
    tags: ${{ steps.meta.outputs.sha_tag }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

mode=max caches intermediate layers too, not just the final ones, which helps a lot for multi-stage builds. Two things multiply the benefit: order your Dockerfile so slow, rarely-changing steps come first (copy the lockfile and install dependencies before copying application source), and pin your base images so cache keys stay stable. A cold cache is not a bug, it is just slow - the goal is to make cold caches rare.

Multi-arch builds, briefly

Your CI runners are almost certainly x86 (amd64), but plenty of targets are ARM (arm64) now: Apple Silicon laptops, AWS Graviton instances, Raspberry Pis. An amd64-only image will not run there. To ship one image that works on both, you build a multi-arch image - really a manifest list that points at a per-architecture image, so a pull on ARM gets the ARM variant automatically.

You do this with buildx (BuildKit's builder) plus QEMU emulation for the architectures the runner cannot run natively. In GitHub Actions it is two setup steps and a platforms argument:

- name: Set up QEMU
  uses: docker/setup-qemu-action@v3

- name: Set up Buildx
  uses: docker/setup-buildx-action@v3

- name: Build and push multi-arch
  uses: docker/build-push-action@v6
  with:
    push: true
    platforms: linux/amd64,linux/arm64
    tags: ${{ steps.meta.outputs.sha_tag }}

Emulated builds are slower than native ones, so only build the architectures you actually deploy to. If ARM performance matters, some teams run native ARM runners instead of QEMU. The payoff is one tag that just works everywhere, with the registry handing each host the right variant.

Pulling the exact digest at deploy

The last step closes the loop, and it is where "build once, promote the digest" pays off. At deploy time you do not pull a tag - you pull the digest the build job produced. This guarantees the target runs the identical image you tested, even if someone re-tagged latest in the meantime or a mutable tag drifted.

Referencing an image by digest looks the same everywhere the image is consumed:

# Instead of a tag (which can move)...
docker pull ghcr.io/acme/myapp:latest

# ...pull the exact, immutable image:
docker pull ghcr.io/acme/myapp@sha256:9f2c1e...b7

In a Kubernetes manifest, the container image field takes the same digest reference (image: ghcr.io/acme/myapp@sha256:9f2c...), so the pod runs precisely the audited bytes. This is also what makes rollbacks and incident forensics clean: a deploy record that says sha256:9f2c... is unambiguous forever, while "we deployed latest on Tuesday" tells you nothing. Tag for humans, deploy by digest.

Putting it together

A solid container pipeline is not complicated once the pieces line up. You authenticate to the registry with a short-lived, least-privilege token from CI secrets. You build once and tag every image with an immutable commit-SHA tag plus a moving convenience tag. You push both, but you record the digest, because the digest is the only reference that is guaranteed to mean the same bytes forever. You keep BuildKit's cache warm through a CI or registry cache so pipelines stay fast, and you build multi-arch with buildx only for the platforms you actually run. Then you promote and deploy that one digest through staging and prod, never rebuilding along the way. Get those habits right and the answer to "what exactly is running in production?" is always a single, verifiable hash.