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
| Event | Tests | Build | Scan | Push |
|---|---|---|---|---|
| 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
- The Actions tab: Go to your repo → Actions → "Docker CI Pipeline" → verify that both jobs pass
- The Packages tab: Go to your repo → Packages → verify that the image appears with the right tags
- 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-dirin pip install
Workflow
- A trigger on push to main,
v*tags, and PRs - A
testjob with lint + pytest - A
dockerjob withneeds: 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-actionand 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
.dockerignorethat excludes.git,__pycache__,.env, tests -
permissions: packages: write, security-events: write -
timeout-minuteson both jobs -
cache-from: type=ghaandcache-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.ymlworkflow 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
- docker/build-push-action — The pipeline's main action
- docker/metadata-action — Automatic generation of tags and labels
- aquasecurity/trivy-action — Vulnerability scanning
- GitHub Container Registry — The destination registry
- GitHub Actions — Workflow syntax — The complete YAML reference
- OCI Image Spec — The standard for image labels and metadata