100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Docker & Containers
65 minbeginner

Production Practice — CI/CD Pipeline with Docker

What You'll Build

In this exercise you will build a complete, production-grade CI/CD pipeline for the cricket scorecard API using GitHub Actions, bringing together everything from Module 5. The pipeline will trigger on every push and pull request, build the image once with BuildKit layer caching, run unit and integration tests against a real database, scan the image for vulnerabilities and fail on fixable critical issues, and — only on the main branch — push an immutably tagged image to a registry. You will tag with the commit SHA for traceability, gate the deployment-ready push behind all checks passing, and structure the workflow so the exact artifact that was tested is the one published. By the end you will have a pipeline that turns every code change into a verified, traceable, deployable image automatically, which is the backbone of how professional teams ship containerised software.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as a player's first practice session puts the fundamental skills together in sequence — take guard, play some shots, run between wickets, review — rather than in isolation, this exercise puts the Docker fundamentals together in sequence: pull, run, manage, inspect, clean up. The insight is that fluency comes from rehearsing the basics as a connected flow: the practice session links the skills into real play, exactly as this exercise links the Docker commands into a real workflow.

Prerequisites

  • A GitHub repository you can push to, with GitHub Actions enabled and permission to create workflow files under .github/workflows.
  • Completion of Module 5 lessons on CI/CD, BuildKit, production patterns, resource limits and debugging.
  • The cricket scorecard API from earlier modules — a Flask app with a Dockerfile, a test suite, and a /healthz endpoint.
  • Basic familiarity with git branches and pull requests, since the pipeline behaves differently for PRs versus main.
  • Access to a container registry (GitHub Container Registry ghcr.io works out of the box with the built-in token).

Setup & Project Structure

You will add a workflows directory and a couple of test-support files to the existing API project. The workflow YAML defines the pipeline, a Compose file describes the test dependencies, and the application already carries its Dockerfile and tests. Keeping the pipeline definition in the repository means it is versioned and reviewed exactly like the application code, which is the whole point of pipeline-as-code. Create the layout below before writing the workflow.

Analogy🏏Cricket
🏏 Think of it like cricket: starting this practice with official nginx and alpine images is like turning up to nets with the ground's standard-issue kit already laid out — you need install nothing of your own. Just as a coach first confirms the nets are booked and the bowling machine is switched on before a session begins, you verify Docker is working before anything else. Just as a player draws the standard bat and pads from the club store rather than crafting gear from scratch, you pull ready-made images from Docker Hub — nginx as your web-server 'all-rounder', alpine as a tiny, nimble twelfth man. Then, just as a session moves methodically from knocking-in to full-pace deliveries to fitness cool-down, you will pull images, run containers, and manage them through their full lifecycle. The payoff: a friction-free, command-line-only foundation session where every fundamental container move gets rehearsed cleanly.
bash
scorecard-cicd/
 .github/
    workflows/
        ci.yml              # the pipeline definition
 api/
    Dockerfile              # hardened, exec-form CMD
    requirements.txt        # flask, psycopg2-binary, pytest
    app.py                  # scorecard API + /healthz
    test_app.py             # unit + integration tests
 compose.test.yaml           # spins up a real Postgres for tests

# Create the skeleton:
mkdir -p scorecard-cicd/.github/workflows scorecard-cicd/api
cd scorecard-cicd
# compose.test.yaml — a throwaway database for integration tests
#   services:
#     match-db:
#       image: postgres:16
#       environment: { POSTGRES_PASSWORD: test, POSTGRES_DB: cricket }
#       healthcheck: { test: ['CMD-SHELL','pg_isready -U postgres'], interval: 3s, retries: 5 }

Step 1 — Foundation

Step 1 establishes the trigger and the build stage with caching, the foundation every later stage depends on. The workflow runs on pushes and pull requests, checks out the code, sets up Buildx to enable BuildKit layer caching, and builds the image once into the runner. Building a single artifact here — rather than rebuilding at each stage — is the decision that guarantees the tested image and the shipped image are identical. Caching keeps this fast on every run.

Analogy🏏Cricket
🏏 Think of it like cricket: before opening a stadium you first vet the players themselves — confirm their credentials, assign each a limited-access pass, and fit them with the tracking sensors that will feed the analytics. Nothing else works until the participants are both trusted and instrumented. Just as players are issued limited passes before anything else, the container is built to run as a non-root user from the start. Just as the tracking sensors are fitted before the match so data flows, the /metrics endpoint is built in before monitoring is wired up. Just as a player's credentials are checked privately, the password is read from a private secret file. This shows why foundation comes first: a stack can only be secured and observed if its core components are trustworthy and instrumented from birth.
yaml
# .github/workflows/ci.yml — trigger + cached build (foundation)
name: scorecard-cicd
on:
  push:
    branches: [main]
  pull_request:

jobs:
  pipeline:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write              # needed to push to ghcr.io
    steps:
      - uses: actions/checkout@v4

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

      - name: Build the image ONCE (with layer cache)
        uses: docker/build-push-action@v6
        with:
          context: ./api
          load: true               # load into the runner for testing
          tags: cricket/scorecard:ci
          cache-from: type=gha
          cache-to: type=gha,mode=max

Step 2 — Core Logic

Step 2 adds the verification gates: unit tests, integration tests against a real Postgres, and a vulnerability scan. Unit tests run inside the built image so they exercise exactly what will ship. Integration tests bring up a throwaway database with Compose so the code is tested against a real dependency, not a mock. The Trivy scan fails the job on fixable high or critical CVEs. Every gate runs against the single image built in Step 1, and any failure halts the pipeline so a broken or vulnerable change never advances.

Analogy🏏Cricket
🏏 Think of it like cricket: with vetted players ready, the organisers now design the venue's security plan — concentric access zones, a locked strongroom for valuables, and a rule that each role carries only the keys it needs. The match cannot be called secure until the whole ground is laid out this way. Just as the strongroom sits in the most restricted zone, the database sits on the isolated backend network. Just as each role carries only its necessary keys, each service is granted only its necessary capabilities. Just as only the main gate faces the public, only the proxy publishes a host port. This shows why core logic is the security plan: individual trust is not enough until the entire layout enforces least privilege between every part.
yaml
# Append to ci.yml steps: the verification gates (run against cricket/scorecard:ci)
      - name: Unit tests inside the built image
        run: docker run --rm cricket/scorecard:ci python -m pytest -q test_app.py -k unit

      - name: Integration tests against a real Postgres
        run: |
          docker compose -f compose.test.yaml up -d match-db
          # wait for the db healthcheck to pass
          until [ "$(docker inspect -f '{{.State.Health.Status}}' \
                $(docker compose -f compose.test.yaml ps -q match-db))" = healthy ]; do sleep 2; done
          docker run --rm --network host \
            -e DATABASE_URL=postgres://postgres:test@localhost:5432/cricket \
            cricket/scorecard:ci python -m pytest -q test_app.py -k integration
          docker compose -f compose.test.yaml down -v

      - name: Vulnerability scan (fail on fixable HIGH/CRITICAL)
        run: |
          docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
            aquasec/trivy image --exit-code 1 --ignore-unfixed \
            --severity HIGH,CRITICAL cricket/scorecard:ci

Step 3 — Integration & Enhancement

Step 3 adds the publish stage, gated so it runs only on the main branch and only after every check has passed. It logs in to the registry using the run-scoped built-in token, then pushes the image tagged with the immutable commit SHA plus a moving latest tag, reusing the build cache. Because this step is guarded by the branch condition and sits after all the gates, pull requests are fully verified but never publish, and only a merge to main produces a traceable, deployable artifact. This completes the build-test-scan-push spine.

Analogy🏏Cricket
🏏 Think of it like cricket: once the venue is secured, the final step before going live is the broadcast and analytics setup — cameras positioned, the stats engine wired to the scoreboard, and the analyst's dashboard switched on. Only then can everyone see and understand the match as it unfolds. Just as the stats engine pulls figures from the scoreboard at a steady cadence, Prometheus scrapes the API's metrics every fifteen seconds. Just as the analyst's dashboard turns raw numbers into readable trends, Grafana turns scraped metrics into graphs. Just as the analytics gear connects only to the official feed, the monitoring services sit on the internal network with the API. This shows why integration completes the stack: security keeps it safe, but observability is what makes it understandable and operable.
yaml
# Append to ci.yml steps: gated publish (main only, after all gates pass)
      - name: Log in to the registry
        if: github.ref == 'refs/heads/main'
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}   # run-scoped, masked

      - name: Push immutably tagged image (main only)
        if: github.ref == 'refs/heads/main'
        uses: docker/build-push-action@v6
        with:
          context: ./api
          push: true
          tags: |
            ghcr.io/${{ github.repository }}/scorecard:${{ github.sha }}
            ghcr.io/${{ github.repository }}/scorecard:latest
          cache-from: type=gha
      # Pull requests reach here but skip both steps -> verified, never published.

Step 4 — Testing & Verification

Now verify the pipeline behaves correctly in both scenarios it must handle. Push a feature branch and open a pull request, and confirm the pipeline builds, tests and scans but does not publish. Then merge to main and confirm the same checks run and an image tagged with the commit SHA appears in the registry. Also confirm a deliberately failing test or a vulnerable dependency halts the pipeline before any push. Each check proves one guarantee the pipeline is supposed to make.

Analogy🏏Cricket
🏏 Think of it like cricket: this verification step is the post-match review that confirms every part of the game went to plan. Just as a captain checks the scorecard to confirm the total was reached, the wickets fell as expected, and every fielder did his job, you confirm nginx is reachable at localhost:8080, that you observed it via logs, inspect and exec, and that you stopped and restarted it cleanly. Just as a rolling substitution is checked to have swapped a fresh player in without stopping play, you verify the restart-policy version took over and the self-cleaning interactive container left no trace. And just as a diligent groundsman clears the pitch and confirms nothing is left behind before locking up, you stop and remove the web container, prune leftover stopped ones, and check with `docker ps -a` that the field is truly empty. The payoff: proof the full lifecycle worked and the host is left spotless.
bash
# Verify both paths of the pipeline
# 1) PR path: build + test + scan, but NO publish
git checkout -b feature/run-rate-fix
git commit -am 'tweak run rate'; git push -u origin feature/run-rate-fix
#   Open a PR -> Actions runs build, unit, integration, scan; push steps are skipped.

# 2) main path: same checks, then an SHA-tagged image is published
git checkout main; git merge feature/run-rate-fix; git push
#   Actions runs all gates, then pushes:
#     ghcr.io/<owner>/<repo>/scorecard:<commit-sha>
#     ghcr.io/<owner>/<repo>/scorecard:latest

# 3) Confirm the published image is traceable to the exact commit:
docker pull ghcr.io/<owner>/<repo>/scorecard:$(git rev-parse HEAD)

# 4) Prove a gate blocks a bad change: break a test and push to a branch
#    -> the pipeline fails at the test step and the publish steps never run.
#    Check the Actions run: the job is marked failed before 'Push' executes.

Warning: Do not move the registry login or push steps above the test and scan steps, and do not remove the 'if: github.ref == refs/heads/main' guard. If the push runs before the gates, or runs on every branch, you will publish images that were never verified — or publish from pull requests, including ones from untrusted forks. The push must always come last and be gated on the main branch so only fully checked code on the trunk is ever released.

Extension Challenge: Harden and extend the pipeline. First, add a deploy job with 'needs: [pipeline]' and 'environment: production' so it runs only after CI is green and requires a manual approval, deploying the SHA-tagged image. Second, pin the deployment to the image digest rather than the SHA tag for supply-chain integrity, capturing the digest from the push step's output. Third, add an image-signing step with cosign and a matching verification step before deploy, so only signed images can be released.

  • A pipeline-as-code workflow lives in the repository, so the build process is versioned and reviewed exactly like the application code.
  • Build the image once with BuildKit layer caching, then reuse that single artifact for testing, scanning and publishing to avoid drift.
  • Run unit tests inside the built image and integration tests against a real database spun up with Compose, so you test exactly what ships.
  • Scan the built image and fail the job on fixable high/critical CVEs, making security an enforced gate rather than an optional step.
  • Guard the publish steps with a main-branch condition so pull requests are fully verified but only merges to the trunk release an artifact.
  • Tag published images with the immutable commit SHA for traceability and authenticate with a run-scoped, masked token, never a hard-coded secret.
Lesson 30 of 35
0% complete