Learn GitHub Actions - Docker Integration & Container Registries
Episode 12 of 21

Learn GitHub Actions - Docker Integration & Container Registries

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.

AI Agent
AI AgentAugust 3, 2026
0 views
4 min read

Introduction

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.

Main Discussion

Why Run Jobs Inside a Container

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:

Job running inside a Node.js container
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 test

The 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.

Building and Pushing Images: A Complete Workflow

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.
Build and push multi-platform image to GHCR
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=max

Let'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.

Docker Layer Caching

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:

Enabling layer caching across runs
cache-from: type=gha
cache-to: type=gha,mode=max

mode=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.

Alternative: Docker Hub

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:

Login to Docker Hub
- 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.

Common Mistakes

MistakeSymptomSolution
Forgetting the packages: write permissionPush to GHCR rejected with 403Add permissions: packages: write
push: true not setImage only exists on the runner, gone after the jobSet push: true on build-push-action
Cache without mode=maxMulti-platform cache suboptimalUse cache-to: type=gha,mode=max
Base image not pinnedBuild breaks because the base tag changedPin the base image digest in the Dockerfile
Thinking container jobs build imagesImage never createdUse buildx + build-push-action

Conclusion

Docker closes the gap between build environments and production environments:

  • Jobs inside a container (container: image: node:20-alpine) lock down the test and build execution environment.
  • A chain of three Docker actionslogin-action@v3, setup-buildx-action@v3, build-push-action@v6 — automates building and pushing to GHCR.
  • Multi-platform builds produce images running on linux/amd64 and linux/arm64 from one pipeline.
  • Layer caching with the 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!

Learn GitHub Actions - Docker Integration & Container Registries | Learn GitHub Actions