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:

  1. Dependencies changed. A pip install openai that yesterday installed 1.30.0 today installs 2.0 with breaking changes.
  2. Your code changed. Someone renamed src/api.py to src/service.py, but the Dockerfile's CMD still references src.api.
  3. The base image changed. python:3.12-slim is 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.

SituationRecommendation
Fast build (<2 min)A step in the same job
Slow build (>5 min)A separate job in parallel
Heavy system dependenciesA separate job
Fast CI is the priorityA 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-minutes on jobs with a Docker build to avoid infinite builds

Additional resources

  1. Docker build reference — Official documentation
  2. Multi-stage builds — Official guide
  3. docker/build-push-action — Action for building and pushing with a cache
  4. docker/setup-buildx-action — Docker Buildx setup
  5. GitHub Actions runner specs — The runners' specs