Module 5: Docker in CI/CD

8. Project — Docker CI Pipeline

Overview

This is Module 5's project. You integrate everything you learned into a complete Docker pipeline for CI: a build with docker/build-push-action, layer caching with GitHub Cache, tagging with SHA + semver via docker/metadata-action, vulnerability scanning with trivy, and a push to GHCR. The result is a workflow that, on every push to main, produces a traceable, scanned Docker image, available in the registry — ready for deployment.

What you build: A GitHub Actions workflow that automates the complete Docker cycle in CI for an AI application (FastAPI + OpenAI). It includes an optimized Dockerfile, a workflow YAML, and all the necessary configuration.

Why it matters: This pipeline is the direct prerequisite for Module 6 (Deployment Pipelines). Without an image in a registry, there's nothing to deploy. What you build here produces the images Module 6 takes to staging and production.


Prerequisites

Before starting, verify that you have:

  • ✅ A repository on GitHub (public or private)
  • ✅ A working Dockerfile (or you'll use the one provided here)
  • ✅ Knowledge of capsules 02-07 of this module
  • ✅ GitHub Actions enabled in your repo

Project structure

The files you're going to create/modify

your-ai-project/
├── .github/
│   └── workflows/
│       └── docker.yml              # The main workflow — Docker CI Pipeline
├── .dockerignore                    # Excluding unnecessary files from the build
├── .trivyignore                     # Ignored CVEs (documented)
├── Dockerfile                       # A Dockerfile optimized for CI
├── requirements.txt                 # Production dependencies
├── requirements-dev.txt             # Development dependencies
├── src/
│   ├── __init__.py
│   ├── main.py                      # FastAPI app
│   ├── config.py                    # Configuration
│   └── chain.py                     # LangChain/OpenAI pipeline
└── tests/
    ├── __init__.py
    └── test_main.py                 # Basic tests

Step 1: The Dockerfile

A Dockerfile optimized for CI

# Dockerfile

# === Stage 1: Base ===
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 --upgrade pip && \
    pip install --no-cache-dir -r requirements.txt

# === Stage 2: Production ===
FROM base AS production

COPY src/ ./src/

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/OWNER/REPO"
LABEL org.opencontainers.image.description="AI API Service"

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"]

Why this Dockerfile is optimal for CI

1. Multi-stage (base → production):
   - The base stage installs the dependencies — it gets cached when they don't change
   - The production stage copies the code — it only rebuilds on code changes

2. Layer order (least-changing → most-changing):
   - apt-get (it almost never changes)
   - requirements.txt (it changes occasionally)
   - src/ (it changes frequently)

3. OCI labels:
   - GIT_SHA and BUILD_DATE get injected from the workflow
   - They let you know which commit the image was built from and when

4. HEALTHCHECK:
   - Docker and orchestrators can verify that the app responds
   - curl is installed in the base for the health check

5. --no-cache-dir in pip:
   - It reduces the image's size (it doesn't store pip's cache)

Step 2: The .dockerignore

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

Step 3: The application (FastAPI + OpenAI)

requirements.txt

fastapi==0.109.2
uvicorn==0.27.1
openai==1.12.0
python-dotenv==1.0.1
pydantic==2.6.1
httpx==0.26.0

requirements-dev.txt

-r requirements.txt
pytest==8.3.4
pytest-cov==6.0.0
ruff==0.9.2
mypy==1.14.1
httpx==0.26.0

src/config.py

# src/config.py
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    app_name: str = "AI API Service"
    openai_api_key: str = ""
    environment: str = "development"
    log_level: str = "info"

    class Config:
        env_file = ".env"


settings = Settings()

src/main.py

# src/main.py
from fastapi import FastAPI
from pydantic import BaseModel

from src.config import settings

app = FastAPI(title=settings.app_name)


class HealthResponse(BaseModel):
    status: str
    environment: str


class ChatRequest(BaseModel):
    message: str
    model: str = "gpt-4o-mini"


class ChatResponse(BaseModel):
    response: str
    model: str
    tokens_used: int


@app.get("/health", response_model=HealthResponse)
async def health_check():
    return HealthResponse(
        status="healthy",
        environment=settings.environment,
    )


@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
    from openai import OpenAI

    client = OpenAI(api_key=settings.openai_api_key)

    completion = client.chat.completions.create(
        model=request.model,
        messages=[{"role": "user", "content": request.message}],
        max_tokens=500,
    )

    return ChatResponse(
        response=completion.choices[0].message.content,
        model=request.model,
        tokens_used=completion.usage.total_tokens,
    )

tests/test_main.py

# tests/test_main.py
from fastapi.testclient import TestClient

from src.main import app

client = TestClient(app)


def test_health_check():
    response = client.get("/health")
    assert response.status_code == 200
    data = response.json()
    assert data["status"] == "healthy"


def test_health_response_model():
    response = client.get("/health")
    data = response.json()
    assert "status" in data
    assert "environment" in data


def test_chat_endpoint_requires_body():
    response = client.post("/chat")
    assert response.status_code == 422

Step 4: The Workflow — Docker CI Pipeline

The complete workflow

# .github/workflows/docker.yml
name: Docker CI Pipeline

on:
  push:
    branches: [main]
    tags: ["v*"]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  # ─────────────────────────────────────────────
  # Job 1: Tests (validate the code before the build)
  # ─────────────────────────────────────────────
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    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-dev.txt

      - name: Lint with ruff
        run: ruff check src/ tests/

      - name: Run tests
        run: pytest tests/ -v --tb=short

  # ─────────────────────────────────────────────
  # Job 2: Docker Build, Scan, Tag, Push
  # ─────────────────────────────────────────────
  docker:
    runs-on: ubuntu-latest
    timeout-minutes: 25
    needs: test
    permissions:
      contents: read
      packages: write
      security-events: write

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

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

      # --- Login ---
      - name: Login to GHCR
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # --- Metadata (Tags + Labels) ---
      - name: Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=ref,event=branch
            type=semver,pattern=v{{version}}
            type=semver,pattern=v{{major}}.{{minor}}
            type=raw,value=latest,enable={{is_default_branch}}

      # --- Build ---
      - name: Build Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          load: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            GIT_SHA=${{ github.sha }}
            BUILD_DATE=${{ github.event.head_commit.timestamp }}

      # --- Verify ---
      - name: Verify image starts
        run: |
          IMAGE_TAG=$(echo "${{ steps.meta.outputs.tags }}" | head -1)
          docker run --rm "$IMAGE_TAG" python -c "
          from src.main import app
          from src.config import settings
          print('Image verification: OK')
          "

      # --- Scan ---
      - name: Scan for CRITICAL vulnerabilities (blocking)
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
          format: table
          exit-code: 1
          severity: CRITICAL

      - name: Full vulnerability report
        if: always()
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: CRITICAL,HIGH,MEDIUM

      - name: Upload SARIF to GitHub Security
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-results.sarif
        continue-on-error: true

      # --- Push ---
      - name: Push to GHCR
        if: github.event_name != 'pull_request'
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            GIT_SHA=${{ github.sha }}
            BUILD_DATE=${{ github.event.head_commit.timestamp }}

      # --- Summary ---
      - name: Job summary
        if: always()
        run: |
          echo "### Docker CI Pipeline Results" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "**Event:** ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY
          echo "**Commit:** \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "**Tags:**" >> $GITHUB_STEP_SUMMARY
          echo '```' >> $GITHUB_STEP_SUMMARY
          echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
          echo '```' >> $GITHUB_STEP_SUMMARY

Step 5: Breaking down the workflow

The complete flow

Push to main / Tag v* / PR
    ↓
┌──────────────────────────────────────────────────────────┐
│  Job: test                                                │
│  1. Checkout → Setup Python → Install deps                │
│  2. Lint (ruff) → Tests (pytest)                          │
│  ✅ Pass → continue                                       │
│  ❌ Fail → the pipeline stops (no Docker build)           │
└──────────────────────────────────────────────────────────┘
    ↓ (needs: test)
┌──────────────────────────────────────────────────────────┐
│  Job: docker                                              │
│  1. Setup Buildx + Login GHCR                             │
│  2. Generate tags (metadata-action)                       │
│  3. Build image (with cache)                              │
│  4. Verify image imports                                  │
│  5. Scan CRITICAL (trivy) → blocking                      │
│  6. Full scan → SARIF → GitHub Security                   │
│  7. Push to GHCR (only on push, not on a PR)              │
│  8. Summary → the tags in the workflow summary            │
└──────────────────────────────────────────────────────────┘

Behavior depending on the event

EventTestsBuildScanPush
PR✅ (local load)❌ (no push)
Push to main✅ → GHCR
Tag v1.2.3✅ → GHCR

The tags generated depending on the event

Push to main:
  ghcr.io/user/ai-api:sha-abc1234
  ghcr.io/user/ai-api:main
  ghcr.io/user/ai-api:latest

Tag v1.2.3:
  ghcr.io/user/ai-api:sha-def5678
  ghcr.io/user/ai-api:v1.2.3
  ghcr.io/user/ai-api:v1.2
  ghcr.io/user/ai-api:latest

PR:
  (a local build, no push, no tags in the registry)

Step 6: Additional configuration

.trivyignore (optional)

If your image has known vulnerabilities you have evaluated:

# .trivyignore
# Review: 2026-03-08

# CVE-XXXX-XXXXX: Description
# Severity: HIGH | Package: name
# Justification: Why you're not exposed
# Review: When to re-evaluate
# CVE-XXXX-XXXXX

pyproject.toml (for ruff)

# pyproject.toml
[tool.ruff]
target-version = "py312"
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "I", "W"]
ignore = ["E501"]

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]

Step 7: Running and verifying

The first push: triggering the pipeline

git add .
git commit -m "feat: add Docker CI pipeline"
git push origin main

Verifying on GitHub

  1. The Actions tab: Go to your repo → Actions → "Docker CI Pipeline" → verify that both jobs pass
  2. The Packages tab: Go to your repo → Packages → verify that the image appears with the right tags
  3. The Security tab: Go to your repo → Security → Code scanning → verify trivy's results

Verifying locally

# Pull the image from the registry
docker pull ghcr.io/your-user/your-repo:latest

# Verify that it works
docker run -p 8000:8000 ghcr.io/your-user/your-repo:latest

# In another terminal
curl http://localhost:8000/health
# {"status":"healthy","environment":"development"}

Creating a release with semver

# Tag and push
git tag v1.0.0 -m "Release v1.0.0: initial Docker CI pipeline"
git push origin v1.0.0

# Verify in Actions that v1.0.0, v1.0, latest, sha-xxx get generated

Step 8: Verifying the build times

The first build (no cache)

Job: test       → ~30s (lint + tests)
Job: docker     → ~120s (build from scratch + scan + push)
Total wall time → ~150s (2.5 min)

Subsequent builds (with a cache, only the code changed)

Job: test       → ~25s
Job: docker     → ~45s (cached layers, only COPY src rebuilds)
Total wall time → ~70s (1.2 min)

Builds with no changes (everything cached)

Job: test       → ~20s
Job: docker     → ~30s (everything cached)
Total wall time → ~50s

Completeness checklist

Verify that your project meets every requirement:

Dockerfile

  • Multi-stage build (base → production)
  • An optimized layer order (system → deps → code)
  • OCI labels with GIT_SHA and BUILD_DATE
  • HEALTHCHECK configured
  • --no-cache-dir in pip install

Workflow

  • A trigger on push to main, v* tags, and PRs
  • A test job with lint + pytest
  • A docker job with needs: test
  • Buildx configured with docker/setup-buildx-action
  • A login to GHCR with GITHUB_TOKEN
  • Tags with docker/metadata-action (SHA, branch, semver, latest)
  • A build with docker/build-push-action and the GHA cache
  • Import verification after the build
  • A trivy CRITICAL scan (blocking)
  • A trivy full report (SARIF → GitHub Security)
  • A conditional push (only on push, not on PRs)
  • A job summary with the generated tags

Configuration

  • A .dockerignore that excludes .git, __pycache__, .env, tests
  • permissions: packages: write, security-events: write
  • timeout-minutes on both jobs
  • cache-from: type=gha and cache-to: type=gha,mode=max

Verification

  • The pipeline passes in GitHub Actions
  • The image appears in your repo's Packages
  • The correct tags (sha, branch, latest)
  • The trivy report is visible in the Security tab (or as an artifact)
  • The image can be pulled and run correctly

Optional extensions

Extension 1: Add Docker Hub as a second registry

- name: Login to Docker Hub
  if: github.event_name != 'pull_request'
  uses: docker/login-action@v3
  with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Docker metadata
  id: meta
  uses: docker/metadata-action@v5
  with:
    images: |
      ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
      ${{ secrets.DOCKERHUB_USERNAME }}/ai-api
    tags: |
      type=sha,prefix=sha-
      type=ref,event=branch
      type=semver,pattern=v{{version}}
      type=raw,value=latest,enable={{is_default_branch}}

Extension 2: Add multi-platform

- name: Set up QEMU
  uses: docker/setup-qemu-action@v3

# In the build step, add:
  with:
    platforms: linux/amd64,linux/arm64

Remember to increase timeout-minutes to 30+ if you add arm64.

Extension 3: Notify on a failed scan

- name: Notify on scan failure
  if: failure()
  run: |
    echo "### ⚠️ Security Scan Failed" >> $GITHUB_STEP_SUMMARY
    echo "" >> $GITHUB_STEP_SUMMARY
    echo "CRITICAL vulnerabilities found in the Docker image." >> $GITHUB_STEP_SUMMARY
    echo "Review the trivy report in the Security tab." >> $GITHUB_STEP_SUMMARY

Project troubleshooting

1. The docker job fails on "needs: test" but test passed

Cause: The test job can pass but docker fails in its own steps.

Solution: Review the docker job's logs — the error is in one of its steps, not in the dependency on test.

2. The image gets built but trivy can't find it

Symptom:

Error: unable to initialize a scanner: unable to load an image from the local Docker engine

Cause: The image was built with push: true but without load: true. Trivy needs the image in the local daemon.

Solution: Use load: true in the first build (for the scan), and push: true in a second build after the scan:

# The first build: load for the scan
- uses: docker/build-push-action@v6
  with:
    context: .
    load: true
    tags: ${{ steps.meta.outputs.tags }}

# Scan with trivy...

# The second build: push to the registry (it uses the cache, it's instant)
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ${{ steps.meta.outputs.tags }}

3. metadata-action generates tags with uppercase letters

Symptom: GHCR rejects the push because the package's name has uppercase letters.

Solution: Add a lowercase conversion in the environment variables:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

# Or use lowercase directly in metadata-action:
- name: Repo to lowercase
  id: repo
  run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT

- name: Docker metadata
  uses: docker/metadata-action@v5
  with:
    images: ${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}

4. The SARIF upload fails with "permission denied"

Cause: security-events: write is missing from permissions.

Solution:

permissions:
  contents: read
  packages: write
  security-events: write    # Necessary for the SARIF upload

5. The cache doesn't work between jobs

Cause: GitHub Cache is shared between jobs of the same workflow, but the Docker images built don't get shared between jobs (each job has its own Docker daemon).

Solution: The layer cache (type=gha) does get shared — it's build-push-action that rebuilds using the cache. If you need the image in another job, push it and pull it.


Connection with Module 6

Your pipeline produces an image in GHCR. Module 6 (Deployment Pipelines) takes it and deploys it:

Module 5 (this one):
  Push → test → build → scan → push to GHCR
  Result: ghcr.io/user/ai-api:sha-abc1234

Module 6 (the next one):
  ghcr.io/user/ai-api:sha-abc1234 → deploy to staging
  → approval gate
  → deploy to production

The transition is direct: you have an image in a registry. Now you automate its deployment.


Evidence of success

By completing this project, you should have:

  • A multi-stage Dockerfile optimized for CI
  • A docker.yml workflow that passes in GitHub Actions
  • Automatic tags: SHA, branch, semver, latest
  • A trivy scan integrated with a CRITICAL quality gate
  • The image available in GHCR (your repo's Packages)
  • You can pull and run the image locally
  • A build time < 2 minutes on cached builds
  • A job summary showing the generated tags

If you tick every one → you're ready for Module 6.


Summary

  • The Docker CI pipeline has 6 phases: build → verify → scan → push → tag → summary
  • The tests run first (needs: test) — if they fail, the image doesn't get built
  • Automatic tags with docker/metadata-action — SHA, branch, semver, latest
  • The trivy quality gate blocks the push if there are CRITICAL vulnerabilities
  • SARIF → GitHub Security gives you visibility of the vulnerabilities in GitHub's UI
  • A conditional push — only on a push to main and on tags, not on PRs
  • The GHA cache reduces subsequent builds from 2+ minutes to < 1 minute
  • Build args inject the SHA and the date as labels in the image
  • This image is Module 6's input — automatic deployment to staging and production

Additional resources

  1. docker/build-push-action — The pipeline's main action
  2. docker/metadata-action — Automatic generation of tags and labels
  3. aquasecurity/trivy-action — Vulnerability scanning
  4. GitHub Container Registry — The destination registry
  5. GitHub Actions — Workflow syntax — The complete YAML reference
  6. OCI Image Spec — The standard for image labels and metadata