Module 3: AI-Specific CI Checks
5. Docker Build Test in CI
Overview
If you completed the Docker guide (#15), you know how to build images and write efficient Dockerfiles. So why test the Docker build in CI? Because a Dockerfile that works today can break tomorrow without anyone touching the Dockerfile.
A docker build can fail for three reasons that have nothing to do with your Dockerfile:
- Dependencies changed. A
pip install openaithat yesterday installed 1.30.0 today installs 2.0 with breaking changes. - Your code changed. Someone renamed
src/api.pytosrc/service.py, but the Dockerfile'sCMDstill referencessrc.api. - The base image changed.
python:3.12-slimis a mutable tag that can change when Debian publishes an update.
Without an automated test in CI, you find out when you try to deploy — the worst possible moment.
Why the Dockerfile breaks without you touching it
Case 1: Unpinned dependencies
FROM python:3.12-slim
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"]
If requirements.txt says openai with no pinned version, tomorrow pip install may install a version with incompatible changes. The build finishes but the image doesn't work.
Case 2: Refactored code
A developer renames files and updates the imports in Python, but the Dockerfile still has:
CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"]
The build passes (COPY copies the new files), but when you run the image: ModuleNotFoundError: No module named 'src.api'.
Case 3: An updated base image
python:3.12-slim points to the latest build of Python 3.12 slim. If the base OS updates its packages, your apt-get install of a system dependency can fail.
The solution: a step in CI that runs docker build on every push.
The simplest Docker build test
Step 1: Verify that the build doesn't fail
# .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Test Docker build
run: docker build -t myapp:test .
If docker build fails, the step fails with exit code 1 and the PR shows a red check.
Step 2: Verify that the image works
The build can pass but the image may not work — a broken import only shows up when you run it:
- name: Test Docker build
run: docker build -t myapp:test .
- name: Verify image starts correctly
run: |
docker run --rm myapp:test python -c "from src.main import app; print('OK: app imported')"
Step 3: Verify that the server responds
For APIs, you can verify that the server starts:
- name: Test Docker build
run: docker build -t myapp:test .
- name: Verify server starts
run: |
docker run -d --name test-server -p 8000:8000 myapp:test
sleep 5
curl -f http://localhost:8000/health || (docker logs test-server && exit 1)
docker stop test-server
docker rm test-server
The import test is usually enough for CI on every push. The server test is better for pre-deploy workflows.
Multi-stage builds: docker build --target
A multi-stage Dockerfile splits the process into stages with different purposes:
# Stage 1: Base with dependencies
FROM python:3.12-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Stage 2: Tests
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/unit/ -v
# Stage 3: Production
FROM base AS production
COPY src/ ./src/
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
In CI, you build the stage you need:
- name: Run tests in Docker
run: docker build --target test -t myapp:test .
- name: Build production image
run: docker build --target production -t myapp:prod .
- name: Verify production image
run: |
docker run --rm myapp:prod python -c "from src.main import app; print('OK')"
When to use it: when you want the tests to run in the same environment as production, or when your app has system dependencies that affect its behavior.
When not to use it: if your tests are simple and don't depend on the container's environment, or if the Docker build is slow and you're in a fast development phase.
Step vs a separate job
Docker build as a step (in the same job)
jobs:
ci:
steps:
# ... setup, lint, tests ...
- name: Test Docker build
run: docker build -t myapp:test .
Less setup overhead, it shares the job's context.
Docker build as a separate job
jobs:
test:
steps:
# ... lint, tests ...
docker-build:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Test Docker build
run: docker build -t myapp:test .
- name: Verify image
run: |
docker run --rm myapp:test python -c "from src.main import app; print('OK')"
It runs in parallel with the tests. An independent failure in the PR.
| Situation | Recommendation |
|---|---|
| Fast build (<2 min) | A step in the same job |
| Slow build (>5 min) | A separate job in parallel |
| Heavy system dependencies | A separate job |
| Fast CI is the priority | A separate job in parallel |
The complete professional workflow
# .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Lint with ruff
run: ruff check src/ tests/
- name: Type check with mypy
run: mypy src/ --ignore-missing-imports
- name: Run tests with coverage
run: |
pytest tests/ \
-v \
--timeout=30 \
--junitxml=test-results/report.xml \
--cov=src \
--cov-report=term-missing \
--cov-fail-under=80
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results/
retention-days: 30
# Separate job runs in parallel with lint-and-test — total CI time = max(jobs), not sum(jobs)
docker-build:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build Docker image
# SHA tag gives each commit a unique, immutable image tag — avoids "latest" ambiguity in deploys
run: docker build -t myapp:${{ github.sha }} .
- name: Verify image starts
run: |
docker run --rm myapp:${{ github.sha }} python -c "
from src.main import app
from src.config import settings
print('Image verification: OK')
"
- name: Test server health
run: |
docker run -d --name test-server -p 8000:8000 myapp:${{ github.sha }}
# Retry loop because CI runners are slower than local — server may need several seconds to boot
for i in $(seq 1 10); do
if curl -sf http://localhost:8000/health > /dev/null 2>&1; then
echo "Server is healthy after ${i} seconds"
break
fi
if [ "$i" -eq 10 ]; then
echo "Server failed to start"
docker logs test-server
exit 1
fi
sleep 1
done
docker stop test-server
docker rm test-server
myapp:${{ github.sha }} tags the image with the commit SHA — every build gets a unique tag. The lint-and-test and docker-build jobs run in parallel.
Optimize: Docker build cache in CI
GitHub Actions runners start with a clean Docker daemon. For frequent builds with heavy dependencies:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build with cache
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: myapp:test
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha reuses layers between builds. If your build takes less than 30s, the cache overhead isn't worth it. If it takes more than a minute, it pays for itself.
Troubleshooting
"Docker build fails with 'no space left on device'"
Cause: The runner has limited space (~14GB). A large image + many steps fill up the disk.
Solution:
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
docker system prune -af
"The build passes but docker run fails with 'exec format error'"
Cause: The image was built for a different architecture (arm64 vs amd64).
Solution:
- name: Build for correct platform
run: docker build --platform linux/amd64 -t myapp:test .
"The health check fails but it works locally"
Cause: The server needs more time to start on the runner (slower than your local machine).
Solution: Use a loop with a longer retry (30s instead of a fixed 5s):
- name: Wait for server
run: |
docker run -d --name test-server -p 8000:8000 myapp:test
for i in $(seq 1 30); do
if curl -sf http://localhost:8000/health; then
echo "Ready after ${i}s"
break
fi
sleep 1
done
docker stop test-server && docker rm test-server
Exercises
Exercise 1: Add a Docker build test to an existing workflow
You have this workflow and a project with a Dockerfile, src/main.py (with app), and src/service.py (with AIService):
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest tests/ -v
Add a Docker build test and import verification.
See solution
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- run: pip install -r requirements.txt
- run: pytest tests/ -v
- name: Build Docker image
run: docker build -t myapp:test .
- name: Verify image imports
run: |
docker run --rm myapp:test python -c "
from src.main import app
from src.service import AIService
print('All imports verified')
"
The Docker build test comes after pytest — if the tests fail, you don't waste time building the image.
Exercise 2: A multi-stage Dockerfile with a test stage
Create a multi-stage Dockerfile with three stages: base, test (runs pytest), and production. The project has requirements.txt, requirements-dev.txt, src/, and tests/.
See solution
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/unit/ -v --tb=short
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 workflow:
- run: docker build --target test -t myapp:test .
- run: docker build --target production -t myapp:prod .
- run: docker run --rm myapp:prod python -c "from src.main import app; print('OK')"
The production stage inherits from base (not from test) — it doesn't include pytest or the tests in the final image.
Exercise 3: Docker build as a separate parallel job
Refactor this workflow so the Docker build runs in parallel with the tests:
name: CI
on: [push, pull_request]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: ruff check src/
- run: pytest tests/ -v
- run: docker build -t myapp:test .
See solution
name: CI
on: [push, pull_request]
jobs:
lint-and-test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- run: pip install -r requirements.txt
- run: ruff check src/
- run: pytest tests/ -v
docker-build:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t myapp:test .
- name: Verify image
run: |
docker run --rm myapp:test python -c "
from src.main import app
print('Docker image verified')
"
Without needs: between the jobs, GitHub Actions launches them at the same time. If the original took 3 min (2 tests + 1 docker), now it takes ~2 min (the max of the two).
Exercise 4: A health check with a retry loop
Write a step that: (1) builds the image, (2) starts the server, (3) waits up to 30s with retries, (4) verifies health, (5) cleans up. It must show the logs if the server doesn't respond.
See solution
- name: Build and verify server health
run: |
docker build -t myapp:test .
docker run -d --name test-server -p 8000:8000 myapp:test
SERVER_READY=false
for i in $(seq 1 30); do
if curl -sf http://localhost:8000/health > /dev/null 2>&1; then
echo "Server ready after ${i} seconds"
SERVER_READY=true
break
fi
sleep 1
done
if [ "$SERVER_READY" = false ]; then
echo "ERROR: Server failed to start within 30 seconds"
echo "=== Container Logs ==="
docker logs test-server
echo "=== Container Status ==="
docker inspect test-server --format='{{.State.Status}} (exit: {{.State.ExitCode}})'
docker stop test-server 2>/dev/null || true
docker rm test-server 2>/dev/null || true
exit 1
fi
RESPONSE=$(curl -s http://localhost:8000/health)
echo "Health: $RESPONSE"
docker stop test-server
docker rm test-server
docker logs shows the container's stdout/stderr for debugging. The cleanup with 2>/dev/null || true prevents errors if the container already died.
Summary
- ✅ A Dockerfile can break without you touching it — dependencies, refactored code, or an updated base image
- ✅ The simplest test is
docker build -t myapp:test .— it catches build failures in CI - ✅ Import verification (
docker run python -c "import src") catches missing or renamed modules - ✅ A health check with retries verifies that the server starts and responds correctly
- ✅ Multi-stage builds let you run tests inside Docker with
--target test - ✅ A separate job vs a step: fast build (<2 min) → step; slow build → parallel job
- ✅
${{ github.sha }}as the image tag guarantees a unique tag per commit - ✅ Always define
timeout-minuteson jobs with a Docker build to avoid infinite builds
Additional resources
- Docker build reference — Official documentation
- Multi-stage builds — Official guide
- docker/build-push-action — Action for building and pushing with a cache
- docker/setup-buildx-action — Docker Buildx setup
- GitHub Actions runner specs — The runners' specs