This episode discusses Docker integration into pipelines: running jobs inside a container for build environment isolation, then automating multi-platform image builds and pushes to GHCR with layer caching so the pipeline stays fast.

In episode 11 we learned to package pipeline logic so it isn't duplicated across repositories. Now comes the classic problem that annoys every team: "works on my machine". Code that passes tests on a developer's laptop often misbehaves once it reaches the production server — different Node.js version, different operating system, different system dependencies.
Episode 12 answers it with Docker: locking the build environment inside a container so what's tested in the pipeline is exactly what runs in production. We'll learn to run jobs inside a container, then automate building and pushing images to a registry (GHCR) with a production-grade workflow.
By default, GitHub Actions jobs run on the runner's operating system, which is preloaded with lots of software (that also keeps changing between versions). With the container key, you can command a job to run inside a specific container — a userland environment guaranteed identical every time:
jobs:
test:
runs-on: ubuntu-latest
container:
image: node:20-alpine
env:
NODE_ENV: test
options: --memory 512m --cpus 1
steps:
- uses: actions/checkout@v4
- run: node --version
- run: npm testThe runner VM still provides the kernel and Docker, but all job steps run inside the node:20-alpine image — a specific Node.js version, without unwanted toolchains. env inside container sets the image's environment variables, and options passes extra flags to docker run (for example memory limits). The alpine image is chosen because it's lightweight, so job startup is fast.
Note
It's important to distinguish: the container key is for running a job inside a container, not for building an image. Building and pushing images to a registry uses the suite of Docker actions below — the two are often confused by beginners.
The end goal of this chapter: every push to main produces a new Docker image, sent to the registry, ready for deployment. The registry we use is GHCR (ghcr.io), which integrates naturally with GitHub — its login can even use GITHUB_TOKEN, without storing a separate registry password.
Three key actions form the build chain:
docker/login-action@v3 — authenticates to the registry (GHCR or Docker Hub).docker/setup-buildx-action@v3 — enables BuildKit with multi-platform support.docker/build-push-action@v6 — builds and (if push: true) pushes the image.name: Build and Push Image
on:
push:
branches: [main]
tags: ["v*"]
permissions:
contents: read
packages: write
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/devvnull/app
tags: |
type=semver,pattern={{version}}
type=sha
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=maxLet's dissect from the top: the packages: write permission is a requirement for pushing to GHCR — this applies the least privilege from episode 9. Login uses GITHUB_TOKEN as the password, so no extra secret needs to be guarded. metadata-action generates image tags dynamically: a semver version tag when there's a v* git tag, and a commit hash tag for every push. platforms: linux/amd64,linux/arm64 makes the image run well on both x86 servers and ARM processors (for example Apple Silicon or ARM instances in the cloud). Finally, cache-from and cache-to with the type=gha backend wire up layer caching — we'll cover that in a moment.
Without cache, every build redoes the whole process from scratch: downloading the base image, copying code, installing dependencies — even when only one line of code changed. Docker stores intermediate results in layers, and unchanged layers can be reused from previous builds. It's like incremental save games: only the changed parts are replayed.
The type=gha backend stores build caches in the GitHub Actions cache, so they survive across runs:
cache-from: type=gha
cache-to: type=gha,mode=maxmode=max forces the cache to store all layers across platforms, not just the last layer. Without it, the cache for linux/arm64 could be lost and multi-platform builds wouldn't get the full benefit.
Warning
The type=gha cache uses the same cache storage quota as actions/cache (default 10 GB per repository). Large images can burn through this quota quickly — monitor the cache settings page, and consider only writing the cache for the default branch so quota isn't consumed for every feature branch.
Not every project uses GHCR. For Docker Hub, the flow is exactly the same, only the registry and credentials differ — the password is a personal access token, not a regular account password:
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}After login, images are pushed to docker.io with tags like devvnull/app:latest and devvnull/app:v1.2.3.
| Mistake | Symptom | Solution |
|---|---|---|
Forgetting the packages: write permission | Push to GHCR rejected with 403 | Add permissions: packages: write |
push: true not set | Image only exists on the runner, gone after the job | Set push: true on build-push-action |
Cache without mode=max | Multi-platform cache suboptimal | Use cache-to: type=gha,mode=max |
| Base image not pinned | Build breaks because the base tag changed | Pin the base image digest in the Dockerfile |
| Thinking container jobs build images | Image never created | Use buildx + build-push-action |
Docker closes the gap between build environments and production environments:
container: image: node:20-alpine) lock down the test and build execution environment.login-action@v3, setup-buildx-action@v3, build-push-action@v6 — automates building and pushing to GHCR.linux/amd64 and linux/arm64 from one pipeline.type=gha backend makes subsequent builds much faster.In the next episode 13, we enter the Continuous Deployment (CD) chapter: Deployment to Servers via SSH, Rsync, and Ansible — sending this built and packaged application to production servers automatically, without manual login to a VPS. From pipeline to production!