CI/CD Course
CI/CD with Docker
In this lesson
Docker is the dominant container toolchain — the CLI, the image format, the registry protocol, and the build system used by the vast majority of CI/CD pipelines that produce container artifacts. Lesson 27 introduced containers conceptually and showed where they fit in the pipeline. This lesson goes deeper into Docker specifically: how to write Dockerfiles that build fast, stay lean, and hold no security surprises; how Docker's layer caching works and how to exploit it deliberately in a pipeline; how BuildKit unlocks build features that the legacy builder cannot provide; and how the full build-tag-push workflow is structured in a production GitHub Actions pipeline.
Dockerfile Best Practices for CI/CD
A Dockerfile that was written quickly to get something working often becomes a source of slow pipelines, oversized images, and security findings once it reaches CI. The most impactful improvements come from understanding how Docker layers work and ordering instructions to take advantage of caching.
Dockerfile Rules That Matter in a Pipeline
package.json and package-lock.json first, run npm ci, then copy source. The dependency layer is only rebuilt when the lock file changes — not on every source code change.FROM node:20-alpine@sha256:abc123 rather than FROM node:20-alpine. A mutable tag can silently update the base image between builds, breaking reproducibility and introducing unreviewed changes..dockerignorenode_modules, .git, test files, and local config from the build context. A large build context slows every build — Docker must transfer everything in the context to the daemon before the first instruction runs.USER node (or create a dedicated user) before the CMD instruction. A container running as root has host-level privileges if it escapes the container boundary — a common CVE finding that fails most security audits.RUN commandsRUN instruction creates a new layer. RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* in one instruction is smaller and cleaner than three separate RUN instructions.The Lasagne Layer Analogy
A Docker image is a stack of layers, each representing the filesystem changes from one Dockerfile instruction. Like lasagne, each layer sits on top of the one before it, and each layer is baked once — if you do not change the ingredients for a particular layer, Docker reuses the cached version rather than re-baking it. The trick is ordering the layers so that the ones that change least frequently sit lowest in the stack, and the ones that change with every code push sit highest. Put the cheese on top, not the pasta.
Layer Caching — The Biggest Pipeline Speed Lever
Docker's layer cache is the single most impactful performance optimisation available in a container build pipeline. When Docker builds an image, it checks whether each instruction has been executed before with the same inputs. If the layer is cached and its inputs have not changed, Docker reuses it instantly rather than re-executing the instruction. A cache hit on the dependency installation layer can save two to five minutes per pipeline run.
Cache invalidation happens when any instruction or its inputs change — and it cascades: once a layer is invalidated, every subsequent layer must also be rebuilt regardless of whether their own inputs changed. This is why instruction ordering matters so much. Copying all source files before installing dependencies means every source change invalidates the dependency layer, forcing a full npm ci on every build. Copying only the lock files first means the dependency layer is only invalidated when dependencies actually change.
GitHub Actions runners do not share Docker layer caches between jobs by default — each job starts with an empty cache. BuildKit's cache export feature solves this by exporting the layer cache to the container registry after each build and importing it at the start of the next. The GitHub Actions docker/build-push-action supports this natively with a single configuration option.
BuildKit — The Modern Docker Build Engine
BuildKit is the next-generation Docker build engine, enabled by default in Docker 23+ and available as an opt-in in earlier versions. It provides several capabilities that the legacy builder cannot: parallel stage execution in multi-stage builds, cache mounts that persist across builds without exporting to the registry, secret mounts that inject credentials into a build step without writing them to any layer, and significantly faster builds through improved scheduling.
In GitHub Actions, BuildKit is used through the docker/setup-buildx-action action, which configures a BuildKit builder instance. The docker/build-push-action then uses that builder for all subsequent builds, unlocking registry-backed caching, multi-platform builds, and secret mount support. These three actions together — setup-buildx, login, and build-push — are the standard pattern for Docker builds in production GitHub Actions pipelines.
Production Docker Build Pipeline — GitHub Actions with BuildKit
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up BuildKit
uses: docker/setup-buildx-action@v3 # Enable BuildKit builder
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for tags
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix= # Tag with commit SHA: abc1234
type=semver,pattern={{version}} # Tag with semver if triggered by a tag
type=raw,value=latest,enable={{is_default_branch}} # latest on main only
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:cache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:cache,mode=max
# Exports layer cache to registry after build — imported on next run
# mode=max exports all intermediate layers, not just the final stage
What just happened?
BuildKit is configured as the build engine. The metadata action generates all required tags automatically — SHA for traceability, semver for releases, latest for the default branch. The build imports its layer cache from the registry at the start and exports it back after completion, meaning the next pipeline run reuses any unchanged layers rather than rebuilding from scratch. The result is a fully tagged, cache-efficient, production-ready image in the registry with zero manual tagging commands.
Registry Strategy — Tagging, Retention, and Multi-Platform
A thoughtful registry strategy determines how images are named, tagged, retained, and distributed. The tagging strategy from Lesson 14 applies directly here: every image gets a unique, immutable SHA tag for traceability, and human-readable tags like latest or semantic version tags are applied as additional aliases — never as the sole reference for a deployment.
Multi-platform builds are increasingly relevant as teams run workloads on ARM infrastructure — Apple Silicon developer machines, AWS Graviton instances, and ARM-based Kubernetes nodes. BuildKit supports building for multiple CPU architectures in a single pipeline step using platforms: linux/amd64,linux/arm64 in the build-push-action. The result is a multi-platform manifest: a single image tag that serves the correct architecture to any pulling host automatically. The build takes longer, but every downstream consumer — developer laptop, CI runner, production cluster — pulls the right binary without any configuration change.
Warning: Copying Source Code Before Dependencies Defeats Layer Caching Entirely
A Dockerfile that begins with COPY . . followed by RUN npm ci invalidates the dependency cache on every single source code change — which is every build. The entire node_modules installation runs from scratch on every pipeline push, adding two to five minutes to every run regardless of whether any dependency changed. This is the most common Dockerfile performance mistake and one of the easiest to fix: copy only the package files first, install, then copy the rest of the source. One instruction reorder can halve build times for dependency-heavy projects.
Key Takeaways from This Lesson
cache-from and cache-to registry options persist the layer cache between runs automatically.
setup-buildx-action + build-push-action combination is the production-standard Docker pipeline pattern.
linux/amd64 and linux/arm64 means developer laptops, CI runners, and ARM production nodes all pull the correct binary without any consumer-side configuration.
Teacher's Note
Check your Dockerfile right now — if COPY . . appears before RUN npm ci or its equivalent, swap those two lines and measure the next pipeline run. The time saving is usually immediate and significant.
Practice Questions
Answer in your own words — then check against the expected answer.
1. What is the term for what happens when a Dockerfile instruction's inputs change — causing Docker to rebuild that layer and every subsequent layer regardless of whether their own inputs changed?
2. What GitHub Actions action configures a BuildKit builder instance — enabling registry cache export, multi-platform builds, and secret mounts for all subsequent docker/build-push-action steps in the job?
3. What file — analogous to .gitignore — prevents directories like node_modules, .git, and local config files from being transferred to the Docker daemon as part of the build context, reducing build startup time?
Lesson Quiz
1. A team's Docker build takes 8 minutes on every pipeline run, with 5 of those minutes spent on npm ci. Their Dockerfile begins with COPY . . followed by RUN npm ci. What single change would reduce most of those 5 minutes on runs where dependencies have not changed?
2. A team enables Docker layer caching in their Dockerfile but finds that pipeline build times are not improving because each GitHub Actions runner starts with an empty cache. What BuildKit feature solves this across ephemeral runners?
3. A team builds their image with platforms: linux/amd64,linux/arm64 in the build-push-action. A developer on an Apple Silicon Mac and a Kubernetes node running on AWS Graviton both pull the same image tag. What do they each receive?
Up Next · Lesson 29
CI/CD with Kubernetes
Docker builds the image. Kubernetes runs it at scale. Lesson 29 covers how CI/CD pipelines deploy to Kubernetes clusters — from kubectl apply to Helm charts to GitOps with Argo CD.