Module 5: Docker in CI/CD

2. Building Docker Images in CI

Overview

In Module 3, you learned to run docker build as a test in CI — verifying that the image builds without errors. That was a smoke test. Now you take the full step: you build Docker images in an automated way using docker/build-push-action, the official action maintained by Docker Inc. This action doesn't just build; it integrates caching, multi-platform, pushing to registries, and metadata — all configured declaratively in YAML.

Why a dedicated action and not just docker build: You could run docker build -t myapp . && docker push myapp directly. It works. But docker/build-push-action uses Docker Buildx under the hood, which enables: layer caching with external backends (GitHub Cache, Registry), multi-platform builds without manually configuring QEMU, build arguments injected from the workflow, and structured logging. It's the difference between a manual script and a professional tool.

This capsule takes you from a basic docker build in CI to a complete workflow with docker/build-push-action, Buildx, and professional configuration.


From docker build to docker/build-push-action

The simple approach: running docker build directly

This is what a direct build in a step looks like:

# .github/workflows/docker.yml
name: Docker Build

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build Docker image
        run: docker build -t myapp:${{ github.sha }} .

This works. It builds the image and verifies that the Dockerfile has no errors. But it has limitations:

  • 📋 No external caching — Every build starts from scratch
  • 📋 No integrated push — You need extra steps for login + push
  • 📋 No multi-platform — It only builds for the runner's architecture (amd64)
  • 📋 No metadata — It doesn't generate OCI labels automatically

The professional approach: docker/build-push-action

# .github/workflows/docker.yml
name: Docker Build

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Build Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: false
          tags: myapp:${{ github.sha }}

The result is the same (a built image), but now you have access to caching, multi-platform, declarative pushing, and everything Buildx offers.


What Docker Buildx is

Buildx vs the classic builder

Docker has two builders: the classic one (docker build) and Buildx (docker buildx build). GitHub Actions runners come with Docker pre-installed, but to use Buildx's advanced features you need to configure it explicitly.

The classic builder:
  docker build -t myapp .
  → Builds for the local architecture
  → Cache only on the local disk
  → No external cache backends

Buildx:
  docker buildx build -t myapp .
  → Multi-platform builds (amd64, arm64)
  → Cache with GitHub Actions, Registry, S3
  → Advanced logging, build attestations
  → Parallelism across independent stages

Setting up Buildx in Actions

docker/setup-buildx-action configures Buildx on the runner. One step, zero configuration:

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

Output in the Actions logs:

Run docker/setup-buildx-action@v3
  Setting up Docker Buildx...
  Creating builder instance: builder-abc123
  Boot record: linux/amd64
  Builder driver: docker-container
  Buildx version: v0.14.0

From this step onward, any docker/build-push-action uses Buildx automatically.


Your first build with docker/build-push-action

The complete basic workflow

This is the minimal workflow for building an image in CI:

# .github/workflows/docker.yml
name: Docker Build

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

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

      - name: Build image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: false
          tags: myapp:${{ github.sha }}

Breaking down each parameter

with:
  context: .           # The build context directory (where your code is)
  push: false          # Don't push to the registry (build only)
  tags: myapp:${{ github.sha }}  # The image's tag
  • context: . — It's equivalent to the . in docker build .. It's the directory Docker uses as the build context (the files it can copy with COPY).
  • push: false — It only builds the image, it doesn't push it. Useful for PRs where you want to validate that the build works without pushing.
  • tags — One or more tags for the resulting image. ${{ github.sha }} is the commit's full SHA.

Expected output in the logs

Run docker/build-push-action@v6
  Building Docker image...
  
  #1 [internal] load build definition from Dockerfile
  #1 DONE 0.0s
  
  #2 [internal] load metadata for docker.io/library/python:3.12-slim
  #2 DONE 1.1s
  
  #3 [1/5] FROM docker.io/library/python:3.12-slim@sha256:abc...
  #3 DONE 0.0s
  
  #4 [2/5] WORKDIR /app
  #4 DONE 0.0s
  
  #5 [3/5] COPY requirements.txt .
  #5 DONE 0.0s
  
  #6 [4/5] RUN pip install --no-cache-dir -r requirements.txt
  #6 DONE 18.3s
  
  #7 [5/5] COPY src/ ./src/
  #7 DONE 0.0s
  
  #8 exporting to image
  #8 DONE 0.5s

The important parameters of build-push-action

Reference for the common parameters

- uses: docker/build-push-action@v6
  with:
    # === Build context ===
    context: .                    # The build context directory
    file: ./Dockerfile            # Path to the Dockerfile (default: {context}/Dockerfile)

    # === Output ===
    push: false                   # true to push to the registry
    load: true                    # Load the image into the local Docker daemon
    tags: |                       # The image's tags (it can be multi-line)
      myapp:latest
      myapp:sha-abc1234

    # === Cache ===
    cache-from: type=gha          # Read the cache from GitHub Actions
    cache-to: type=gha,mode=max   # Write the cache to GitHub Actions

    # === Build args ===
    build-args: |                 # Build variables
      PYTHON_VERSION=3.12
      APP_ENV=production

    # === Multi-platform ===
    platforms: linux/amd64,linux/arm64   # The target architectures

    # === Multi-stage ===
    target: production            # A specific stage of the Dockerfile

push vs load

The difference between push and load is important:

# push: true → Pushes to the registry (it needs a prior login)
push: true
# The image gets uploaded to ghcr.io/user/myapp:tag

# load: true → Loads it into the runner's local daemon
load: true
# You can use the image with `docker run myapp:tag` in later steps

# Both false → A build with no output (it only validates that it builds)
push: false
load: false

load: true is useful when you want to build the image and then run tests against it in the same job:

- name: Build image
  uses: docker/build-push-action@v6
  with:
    context: .
    load: true
    tags: myapp:test

- name: Test image
  run: |
    docker run --rm myapp:test python -c "from src.main import app; print('OK')"

Build args from the workflow

You can inject variables into the build:

- name: Build with args
  uses: docker/build-push-action@v6
  with:
    context: .
    push: false
    tags: myapp:${{ github.sha }}
    build-args: |
      PYTHON_VERSION=3.12
      GIT_SHA=${{ github.sha }}
      BUILD_DATE=${{ github.event.head_commit.timestamp }}

And use them in the Dockerfile:

ARG PYTHON_VERSION=3.12
FROM python:${PYTHON_VERSION}-slim

ARG GIT_SHA=unknown
ARG BUILD_DATE=unknown

LABEL org.opencontainers.image.revision=${GIT_SHA}
LABEL org.opencontainers.image.created=${BUILD_DATE}

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

Now the image has metadata that shows exactly when it was built and from which commit.


The complete workflow: build + verification

This workflow builds the image, loads it locally, and runs verifications:

# .github/workflows/docker.yml
name: Docker Build & Verify

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

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

      - name: Build image
        uses: docker/build-push-action@v6
        with:
          context: .
          load: true
          tags: myapp:${{ github.sha }}
          build-args: |
            GIT_SHA=${{ github.sha }}

      - name: Verify imports
        run: |
          docker run --rm myapp:${{ github.sha }} python -c "
          from src.main import app
          from src.config import settings
          print('All imports OK')
          "

      - name: Verify server health
        run: |
          docker run -d --name test-server -p 8000:8000 myapp:${{ github.sha }}

          for i in $(seq 1 15); do
            if curl -sf http://localhost:8000/health > /dev/null 2>&1; then
              echo "Server healthy after ${i}s"
              break
            fi
            if [ "$i" -eq 15 ]; then
              echo "Server failed to start"
              docker logs test-server
              exit 1
            fi
            sleep 1
          done

          docker stop test-server
          docker rm test-server

      - name: Show image size
        run: |
          docker images myapp:${{ github.sha }} --format "Image size: {{.Size}}"

The recommended Dockerfile for CI

A Dockerfile optimized for AI projects

FROM python:3.12-slim AS base

WORKDIR /app

RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM base AS production
COPY src/ ./src/

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD curl -f http://localhost:8000/health || exit 1

CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

The essential .dockerignore

# .dockerignore
.git
.github
__pycache__
*.pyc
.env
.env.*
venv/
.venv/
node_modules/
.pytest_cache/
.mypy_cache/
.ruff_cache/
*.egg-info/
dist/
build/
.coverage
htmlcov/
test-results/
*.md
!README.md

The .dockerignore reduces the build context — fewer files that Docker needs to copy to the builder. In projects with a large .git (repos with a long history), this can save significant seconds.


Comparisons

docker build vs docker/build-push-action

Aspectdocker build directlydocker/build-push-action
CachingOnly the runner's local diskGitHub Cache, Registry cache, S3
Multi-platformOnly the runner's architectureamd64, arm64, and more
PushYou need a separate docker pushpush: true integrated
MetadataManual (labels in the Dockerfile)docker/metadata-action, automatic
Build args-build-arg on the CLIDeclarative in YAML
LoggingStandard outputStructured logging with GitHub Actions
MaintenanceYour responsibilityMaintained by Docker Inc

push: false vs load: true vs neither

ConfigWhat it doesWhen to use it
push: false, load: falseIt only validates that it buildsPRs, smoke tests
push: false, load: trueIt builds and loads into the local daemonYou need to run the image afterward
push: true, load: falseIt builds and pushes to the registryPush to main, releases

A simple build vs a specific target

AspectWithout targetWith target: production
What it buildsEvery stage up to the last oneOnly up to the stage you name
The final imageThe Dockerfile's last stageThe stage you specify
SpeedIt builds everythingIt can be faster if it skips stages
UseA single-stage DockerfileA multi-stage Dockerfile

Troubleshooting

1. "failed to solve: failed to read dockerfile"

Symptom:

ERROR: failed to solve: failed to read dockerfile: open Dockerfile: no such file or directory

Cause: The Dockerfile isn't at the root of the context or has a different name.

Solution:

- uses: docker/build-push-action@v6
  with:
    context: .
    file: ./docker/Dockerfile.prod    # Specify the exact path
    push: false
    tags: myapp:test

2. The build fails with "COPY failed: file not found in build context"

Symptom:

ERROR: failed to solve: failed to compute cache key: failed to calculate checksum of ref:
"/src": not found

Cause: The file or directory you're trying to copy with COPY doesn't exist in the build context, probably because .dockerignore excludes it.

Solution: Check your .dockerignore:

# Check which files are in the build context
docker build --no-cache -t test . 2>&1 | head -5

# Review .dockerignore — make sure it doesn't exclude what you need
cat .dockerignore

3. "no space left on device" during the build

Symptom:

ERROR: failed to solve: no space left on device

Cause: The runner has ~14GB of space. Large images (nvidia/cuda, torch) can fill it up.

Solution:

- name: Free disk space
  run: |
    sudo rm -rf /usr/share/dotnet
    sudo rm -rf /opt/ghc
    sudo rm -rf /usr/local/share/boost
    docker system prune -af
    df -h

- name: Build image
  uses: docker/build-push-action@v6
  with:
    context: .
    push: false
    tags: myapp:test

4. Build timeout — the job gets cancelled before it finishes

Symptom: The build starts but the job shows "cancelled" before it completes.

Cause: The job's timeout-minutes is lower than the build time.

Solution:

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 30    # Increase it for heavy builds

    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: false
          tags: myapp:test
          cache-from: type=gha          # Adding a cache reduces the time
          cache-to: type=gha,mode=max

5. "denied: installation not allowed" when pushing

Symptom:

ERROR: denied: installation not allowed to Write organization package

Cause: The GITHUB_TOKEN doesn't have write permissions for packages.

Solution: Add permissions to the job:

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write      # Necessary for pushing to GHCR

    steps:
      # ...

Exercises

Exercise 1: Convert a simple build to build-push-action

You have this workflow with a direct docker build. Convert it to use docker/build-push-action with Buildx:

name: Docker
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t myapp:latest .
See solution
name: Docker
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

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

      - name: Build image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: false
          tags: myapp:${{ github.sha }}

Key changes:

  • docker/setup-buildx-action@v3 configures Buildx for the advanced features
  • docker/build-push-action@v6 replaces the direct docker build
  • context: . indicates the build context directory
  • push: false only builds, it doesn't push
  • ${{ github.sha }} replaces latest with a unique, traceable tag
  • timeout-minutes: 20 protects against infinite builds

Exercise 2: A build with image verification

Create a workflow that: (1) builds the image with load: true, (2) verifies that the imports work, (3) shows the image's size. Your app has src/main.py with app and src/service.py with AIService.

See solution
name: Docker Build & Verify
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

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

      - name: Build image
        uses: docker/build-push-action@v6
        with:
          context: .
          load: true
          tags: myapp:${{ github.sha }}

      - name: Verify imports
        run: |
          docker run --rm myapp:${{ github.sha }} python -c "
          from src.main import app
          from src.service import AIService
          print('All imports OK')
          "

      - name: Show image size
        run: |
          docker images myapp:${{ github.sha }} --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"

Key points:

  • load: true loads the image into the local daemon so you can use it in later steps
  • docker run --rm cleans up the container after the test
  • docker images' --format shows only the relevant information
  • The imports verify that the code was copied into the image correctly

Exercise 3: A build with build-args and labels

Create a workflow that passes the commit's SHA, the build date, and the Python version as build-args. The Dockerfile must use these args to add OCI labels to the image.

See solution

The workflow:

name: Docker Build with Metadata
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

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

      - name: Build image with metadata
        uses: docker/build-push-action@v6
        with:
          context: .
          load: true
          tags: myapp:${{ github.sha }}
          build-args: |
            GIT_SHA=${{ github.sha }}
            BUILD_DATE=${{ github.event.head_commit.timestamp }}
            PYTHON_VERSION=3.12

      - name: Verify labels
        run: |
          docker inspect myapp:${{ github.sha }} --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}'
          docker inspect myapp:${{ github.sha }} --format '{{ index .Config.Labels "org.opencontainers.image.created" }}'

The Dockerfile:

ARG PYTHON_VERSION=3.12
FROM python:${PYTHON_VERSION}-slim

ARG GIT_SHA=unknown
ARG BUILD_DATE=unknown

LABEL org.opencontainers.image.revision=${GIT_SHA}
LABEL org.opencontainers.image.created=${BUILD_DATE}
LABEL org.opencontainers.image.source="https://github.com/user/repo"

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

Key points:

  • ARG before the FROM is a global build-arg that can be used in the FROM
  • ARG after the FROM defines variables available inside the stage
  • Standard OCI labels (org.opencontainers.image.*) are recognized by registries and tools
  • docker inspect verifies that the labels got saved correctly

Exercise 4: A build with a multi-stage target

You have a multi-stage Dockerfile with the stages base, test, and production. Create a workflow that: (1) builds the test stage to run pytest inside Docker, (2) builds the production stage only if the tests pass, (3) verifies the production image.

See solution
name: Docker Multi-Stage Build
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 25
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

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

      - name: Run tests in Docker
        uses: docker/build-push-action@v6
        with:
          context: .
          target: test
          push: false
          tags: myapp:test

      - name: Build production image
        uses: docker/build-push-action@v6
        with:
          context: .
          target: production
          load: true
          tags: myapp:${{ github.sha }}

      - name: Verify production image
        run: |
          docker run --rm myapp:${{ github.sha }} python -c "
          from src.main import app
          print('Production image OK')
          "

      - name: Show production image size
        run: |
          docker images myapp:${{ github.sha }} --format "Production image: {{.Size}}"

The expected Dockerfile:

FROM python:3.12-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM base AS test
COPY requirements-dev.txt .
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY src/ ./src/
COPY tests/ ./tests/
COPY pyproject.toml .
RUN pytest tests/ -v --tb=short

FROM base AS production
COPY src/ ./src/
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

Key points:

  • target: test builds up to the test stage — if RUN pytest fails, the step fails
  • The production stage doesn't need target: test because it inherits from base, not from test
  • load: true only on the production stage because that's the image you use afterward
  • If the test step fails, the production step doesn't run (the default sequential behavior)

Summary

  • docker/build-push-action is Docker's official action for builds in CI — use it instead of a direct docker build
  • docker/setup-buildx-action configures Docker Buildx — necessary for advanced caching and multi-platform
  • push: false only builds without pushing — ideal for PRs and validation
  • load: true loads the image into the local daemon — necessary for running tests after the build
  • Build args inject metadata into the build — the commit's SHA, the date, the Python version
  • OCI labels (org.opencontainers.image.*) add standard metadata to the image
  • target builds a specific stage of a multi-stage Dockerfile
  • .dockerignore reduces the build context — faster and less risk of including sensitive files
  • Always define timeout-minutes — Docker builds can hang with large base images

Additional resources

  1. docker/build-push-action — The official repository with complete documentation of the parameters
  2. docker/setup-buildx-action — Setting up Docker Buildx in GitHub Actions
  3. Docker Buildx docs — Docker Buildx's official documentation
  4. OCI Image Spec — Annotations — The label standard for images
  5. Dockerfile reference — The complete Dockerfile reference
  6. GitHub Actions contexts — Variables available like github.sha, github.actor