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.indocker build .. It's the directory Docker uses as the build context (the files it can copy withCOPY).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
| Aspect | docker build directly | docker/build-push-action |
|---|---|---|
| Caching | Only the runner's local disk | GitHub Cache, Registry cache, S3 |
| Multi-platform | Only the runner's architecture | amd64, arm64, and more |
| Push | You need a separate docker push | push: true integrated |
| Metadata | Manual (labels in the Dockerfile) | docker/metadata-action, automatic |
| Build args | -build-arg on the CLI | Declarative in YAML |
| Logging | Standard output | Structured logging with GitHub Actions |
| Maintenance | Your responsibility | Maintained by Docker Inc |
push: false vs load: true vs neither
| Config | What it does | When to use it |
|---|---|---|
push: false, load: false | It only validates that it builds | PRs, smoke tests |
push: false, load: true | It builds and loads into the local daemon | You need to run the image afterward |
push: true, load: false | It builds and pushes to the registry | Push to main, releases |
A simple build vs a specific target
| Aspect | Without target | With target: production |
|---|---|---|
| What it builds | Every stage up to the last one | Only up to the stage you name |
| The final image | The Dockerfile's last stage | The stage you specify |
| Speed | It builds everything | It can be faster if it skips stages |
| Use | A single-stage Dockerfile | A 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@v3configures Buildx for the advanced featuresdocker/build-push-action@v6replaces the directdocker buildcontext: .indicates the build context directorypush: falseonly builds, it doesn't push${{ github.sha }}replaceslatestwith a unique, traceable tagtimeout-minutes: 20protects 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: trueloads the image into the local daemon so you can use it in later stepsdocker run --rmcleans up the container after the testdocker images'--formatshows 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:
ARGbefore theFROMis a global build-arg that can be used in theFROMARGafter theFROMdefines variables available inside the stage- Standard OCI labels (
org.opencontainers.image.*) are recognized by registries and tools docker inspectverifies 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: testbuilds up to the test stage — ifRUN pytestfails, the step fails- The
productionstage doesn't needtarget: testbecause it inherits frombase, not fromtest load: trueonly 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-actionis Docker's official action for builds in CI — use it instead of a directdocker build - ✅
docker/setup-buildx-actionconfigures Docker Buildx — necessary for advanced caching and multi-platform - ✅
push: falseonly builds without pushing — ideal for PRs and validation - ✅
load: trueloads 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 - ✅
targetbuilds a specific stage of a multi-stage Dockerfile - ✅
.dockerignorereduces 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
- docker/build-push-action — The official repository with complete documentation of the parameters
- docker/setup-buildx-action — Setting up Docker Buildx in GitHub Actions
- Docker Buildx docs — Docker Buildx's official documentation
- OCI Image Spec — Annotations — The label standard for images
- Dockerfile reference — The complete Dockerfile reference
- GitHub Actions contexts — Variables available like
github.sha,github.actor