CI/CD Pipeline with Docker and GitHub Actions

Build a hardened CI/CD pipeline that scans, tags, and publishes container images.

Introduction

30 Second Summary

Every developer knows the pain of "it works on my machine." Containers solved the consistency problem, but shipping them safely and quickly to production requires more than just a Dockerfile.

In this project, you will build a complete CI/CD pipeline that automatically builds, scans for security vulnerabilities, and publishes a containerized Python API every time you push code to GitHub.

What You'll Build

You push code to GitHub and within seconds your pipeline automatically lints, scans for vulnerabilities, and publishes a hardened container image that anyone can pull and run.

By the end of this project, you'll have:

  • A fully automated pipeline powered by GitHub Actions that lints your code, scans your Docker image for vulnerabilities with Trivy, and publishes safe images to GitHub Container Registry on every push to main.
  • A production-hardened Dockerfile using multi-stage builds, a non-root user, and health checks that passes a real security scan.
  • Measurably optimized build performance where you compare before-and-after build times and watch builds drop from minutes to seconds through layer caching and parallel execution.
  • Secret Mission: Add a manual approval gate using GitHub Environments so production deployments require human sign-off before executing.

Are there any prerequisites?

You need Docker, Python 3, and Git installed on your machine, plus a GitHub account. Basic familiarity with Docker commands and Python syntax is helpful, but the project walks through everything step by step.

Before We Start

Before diving into Docker and GitHub Actions, let's lock in what you're about to build and why it matters.

This step is your moment to commit to the project goals before touching any tools.

Create the App, Dockerfile, and Push to GitHub

Every CI/CD pipeline needs something to build, scan, and ship. In this step you will create a minimal Python API, wrap it in a Docker image, and push everything to GitHub so the pipeline has a target.

You will intentionally use a naive Dockerfile here. It uses the full python:3.12 base image and runs as root. Later, the pipeline will catch the problems this creates.

In this step, get ready to:
  • Scaffold a Python project with a FastAPI health-check API.
  • Write a naive Dockerfile and build the image locally.
  • Push your code to a new GitHub repository.
Create the project and write the API

You need a project folder, a Python virtual environment, and the FastAPI package installed before writing any code.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir production-pipeline
cd production-pipeline
  • Create a Python virtual environment by running:
python3 -m venv .venv

What is a virtual environment?

A virtual environment is an isolated Python installation inside your project folder. Packages you install here stay separate from your system Python, so different projects never conflict.

  • Activate the virtual environment. The command depends on your operating system.

macOS

  • Activate by running:
source .venv/bin/activate

Windows

  • Activate by running:
.venv\Scripts\activate

You should see (.venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.

  • Install FastAPI and its standard dependencies by running:
pip install "fastapi[standard]==0.139.2"

You should see download progress and a Successfully installed message listing fastapi and its dependencies.

Seeing a permission error?

Make sure (.venv) appears in your terminal prompt. If it does not, re-run the activate command above. Installing without an active virtual environment can cause permission errors.

help me fix a pip permission error when installing FastAPI

  • Create requirements.txt to pin the FastAPI version for Docker later. Create the file with this content:
fastapi[standard]==0.139.2
  • Create main.py with the following code:
from fastapi import FastAPI

app = FastAPI(title="Production API", version="1.0.0")


@app.get("/")
def root():
    return {"app": "production-api", "version": "1.0.0"}


@app.get("/health")
def health():
    return {"status": "healthy"}

What does this code do?

  • FastAPI(title=..., version=...) creates an API application instance with metadata.
  • @app.get("/") defines a root endpoint that returns the app name and version as JSON.
  • @app.get("/health") defines a health-check endpoint. Container orchestrators use this to know if the app is alive.
  • Confirm your project files exist by running:
ls

You should see main.py, requirements.txt, and the .venv folder in the output.

  • Verify the app runs by starting the development server:
fastapi dev main.py

This command starts a local server and takes over your terminal. Open your browser and visit http://localhost:8000/health.

You should see {"status":"healthy"} in the browser.

  • Press Ctrl+C in your terminal to stop the server.

Don't see the JSON response?

Check that you are visiting http://localhost:8000/health and not just localhost:8000 (the root endpoint returns different JSON).

If the terminal shows an error, make sure (.venv) is in your prompt and that pip install completed successfully.

help me fix my FastAPI dev server

Write the Dockerfile and build the image

Now you will wrap the app in a Docker image. The Dockerfile tells Docker how to package your code into a portable container.

  • Create a file called Dockerfile (no extension) in your production-pipeline folder with the following content:
FROM python:3.12

WORKDIR /app

COPY . .
RUN pip install --no-cache-dir -r requirements.txt

EXPOSE 8000

CMD ["fastapi", "run", "main.py", "--host", "0.0.0.0", "--port", "8000"]

What does this Dockerfile do?

  • FROM python:3.12 uses the full Python base image (about 1 GB). This is intentionally naive.
  • COPY . . copies everything from your project folder into the image.
  • RUN pip install installs your Python dependencies inside the image.
  • CMD defines the command that runs when the container starts.

This image runs as root and uses a bloated base. The security scanner in Step 2 will flag these issues.

  • Create a .dockerignore file to keep unnecessary files out of the image:
.git
.github
__pycache__
*.pyc
.venv
venv
.env
*.md

Why use a .dockerignore?

Without this file, COPY . . would include your .venv folder (hundreds of MB), your .git history, and other files the container does not need. The ignore file keeps the image small and the build fast.

Before you build, what do you think will happen when Docker processes this Dockerfile?

  • Build the Docker image by running:
docker build -t myapp:naive .

You should see Docker pull the python:3.12 base image, copy your files, and install dependencies. The final line shows the image tagged as myapp:naive.

  • Run the container by running:
docker run -p 8000:8000 myapp:naive

This command starts your container and maps port 8000 from the container to your machine. Open your browser and visit http://localhost:8000/health.

You should see {"status":"healthy"} just like before, but now it is served from inside the container.

  • Press Ctrl+C in your terminal to stop the container.

Container exiting immediately or port already in use?

If you see a port is already allocated error, make sure you stopped the earlier fastapi dev server with Ctrl+C.

If the container exits immediately, run docker logs $(docker ps -lq) to see the error output. A common cause is a typo in requirements.txt or main.py.

help me fix my Docker container

Initialize Git and push to GitHub

Your app works locally. Now you need the code in a GitHub repository so that GitHub Actions can trigger a pipeline on every push.

  • Initialize a Git repository and make your first commit by running these commands:
git init
git add .
git commit -m "Initial commit: FastAPI app with naive Dockerfile"

What do these commands do?

  • git init turns your folder into a Git repository.
  • git add . stages all files for the commit.
  • git commit -m saves a snapshot of your code with a message describing the change.

Next, create a new repository on GitHub to host this code.

  • Open your browser and go to github.com/new.
  • Enter production-pipeline as the repository name.
  • Leave the repository set to Public.
  • Do NOT check any of the initialization options (no README, no .gitignore, no license).
  • Click Create repository.

Why public?

Public repositories get unlimited GitHub Actions minutes and free access to GitHub Container Registry. Private repos have a 2,000 minutes/month free tier.

  • Connect your local repository to GitHub and push your code by running these commands. Replace your GitHub username with your actual GitHub username:
git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/production-pipeline.git
git branch -M main
git push -u origin main

If prompted, sign in with your GitHub credentials or a personal access token.

  • Refresh your repository page on GitHub. You should see main.py, Dockerfile, requirements.txt, and .dockerignore listed in the repository.

Push rejected or authentication failed?

If you see an authentication error, GitHub no longer accepts passwords for HTTPS pushes. You need a personal access token (PAT) or SSH key. Go to github.com/settings/tokens to create a token with repo scope.

If the push is rejected because the remote already has commits, double check you did NOT initialize the repository with a README on GitHub.

help me push my code to GitHub

✔️ Awesome, I've got everything!

Great. Double check you have saved all files and that your GitHub repository shows the code.

ⓧ I'd like to double check the full code

Here are all four files you should have in your production-pipeline folder.

from fastapi import FastAPI

app = FastAPI(title="Production API", version="1.0.0")


@app.get("/")
def root():
    return {"app": "production-api", "version": "1.0.0"}


@app.get("/health")
def health():
    return {"status": "healthy"}
fastapi[standard]==0.139.2
FROM python:3.12

WORKDIR /app

COPY . .
RUN pip install --no-cache-dir -r requirements.txt

EXPOSE 8000

CMD ["fastapi", "run", "main.py", "--host", "0.0.0.0", "--port", "8000"]
.git
.github
__pycache__
*.pyc
.venv
venv
.env
*.md

Your app is containerized and your code is on GitHub. Next up, you will add a CI pipeline that automatically lints your code and scans the Docker image for security vulnerabilities every time you push.

Add the CI Pipeline with Lint and Trivy Scanning

Your Docker image builds and runs locally. But does "it works on my machine" mean it is safe to deploy?

In this step, you will add automated quality gates that run every time you push code to GitHub. A linter catches style issues, and a vulnerability scanner inspects the container image for known security flaws. You are about to find out whether your naive Dockerfile passes muster.

In this step, get ready to:
  • Create a GitHub Actions workflow that lints your Python code with Ruff.
  • Add a Trivy vulnerability scan job that checks your Docker image for CRITICAL and HIGH severity issues.
  • Push to main and observe whether the pipeline passes or fails.
Create the workflow file with the lint job

GitHub Actions workflows live in a special directory inside your repo. Each YAML file in that directory defines a pipeline that GitHub runs automatically on triggers you specify.

  • Create the workflow directory by running this command:
mkdir -p .github/workflows
  • Confirm the directory exists by running:
ls .github/workflows

You should see an empty directory listed with no errors.

  • Create the CI workflow file by adding the following code to .github/workflows/ci.yml:
name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

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

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install linter
        run: pip install ruff

      - name: Run linter
        run: ruff check .

What does this workflow do?

The on: block triggers this pipeline on every push to main and on pull requests targeting main.

The env: block defines two variables you will use in later jobs: the container registry address and the image name (derived from your GitHub username and repo name).

The lint job checks out your code, installs Ruff (an extremely fast Python linter written in Rust), and runs ruff check . to scan every Python file for style and correctness issues.

Why Ruff instead of Flake8 or Pylint?

Ruff replaces Flake8, Pylint, isort, and several other tools with a single binary. It installs in seconds and lints an entire codebase in milliseconds. For a CI pipeline where speed matters, Ruff is the industry standard choice.

  • Save ci.yml.
Add the Trivy vulnerability scan job

Linting catches code quality issues, but it cannot tell you whether the container image itself contains known security vulnerabilities. That is the job of a scanner.

  • In .github/workflows/ci.yml, add the following scan job directly below the lint job (at the same indentation level as lint:):
  scan:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build image for scanning
        run: docker build -t ${{ env.IMAGE_NAME }}:scan .

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          image-ref: ${{ env.IMAGE_NAME }}:scan
          format: table
          exit-code: "1"
          ignore-unfixed: true
          vuln-type: os,library
          severity: CRITICAL,HIGH

What does the scan job do?

This job builds your Docker image on the runner (without pushing it anywhere), then runs Trivy against it. Trivy is an open-source vulnerability scanner that checks OS packages and application libraries inside the image.

The key setting is exit-code: "1". This tells Trivy to fail the job (exit with code 1) if it finds any CRITICAL or HIGH severity vulnerabilities. The pipeline will go red and block any downstream steps.

  • Save ci.yml.

YAML indentation errors?

YAML is whitespace-sensitive. The scan: key must be at the same indentation level as lint: (two spaces from the left edge of jobs:). If you see a parsing error when the workflow runs, check that you have not mixed tabs and spaces.

help me fix YAML indentation in my GitHub Actions workflow

✔️ Awesome, I've got everything!

Great. Double check you have saved ci.yml and that it contains both the lint and scan jobs.

ⓧ I'd like to double check the full code

Your .github/workflows/ci.yml should contain two chunks in order:

  • The workflow header (name, on, env) followed by the lint job with four steps: checkout, set up Python, install Ruff, run Ruff.
  • The scan job with three steps: checkout, build image for scanning, run Trivy vulnerability scanner.

Scroll up and confirm each chunk is present in that order. The lint: and scan: keys should be siblings under jobs:.

Push and observe the pipeline
  • Stage, commit, and push the new workflow file by running these commands:
git add .github/workflows/ci.yml
git commit -m "Add CI pipeline with lint and Trivy scan"
git push

Before you check the results, make a prediction: do you think both jobs will pass? The lint job checks your Python code. The scan job inspects the Docker image you built from python:3.12 (the full, unoptimized base image). What do you expect Trivy to find?

  • Open your repository on GitHub in a browser.
  • Click the Actions tab at the top of the page.
  • Click on the latest workflow run (it will be named after your commit message).

You will see two jobs listed: lint and scan. The lint job passes with a green checkmark. The scan job fails with a red X.

  • Click on the scan job, then expand the Run Trivy vulnerability scanner step to see the vulnerability table.

Trivy lists CRITICAL and HIGH severity vulnerabilities found in the python:3.12 base image. The full Python image contains hundreds of OS-level packages, many with known CVEs. That is why the pipeline went red.

Why did Trivy fail the pipeline?

The full python:3.12 image is approximately 1 GB and ships with system libraries like OpenSSL, curl, and various C libraries. These packages accumulate known vulnerabilities over time.

Your exit-code: "1" setting told Trivy to treat any CRITICAL or HIGH finding as a hard failure. The pipeline did exactly what you asked it to do: it caught a real security problem and blocked the build. This is the quality gate working as intended.

Workflow not appearing in the Actions tab?

Make sure you pushed to the main branch. The workflow only triggers on pushes to main or pull requests targeting main.

Confirm the file lives at exactly .github/workflows/ci.yml (not github/workflows/ci.yml without the leading dot). GitHub only recognizes workflows in the .github/workflows/ directory.

help me troubleshoot why my GitHub Actions workflow is not running

Your pipeline just proved something important: the naive Dockerfile you wrote works locally, but it is not production-safe. The full base image carries known vulnerabilities that Trivy caught. In the next step, you will harden the Dockerfile to pass the security gate.

Harden the Dockerfile and Pass the Security Gate

In the previous step, your pipeline did exactly what it was supposed to. It found real vulnerabilities in your Docker image and blocked the build.

The full python:3.12 base image contains hundreds of OS packages you do not need, and many have known CVEs. Now you fix the root cause: switch to a slim base image, separate build-time dependencies from runtime, add a non-root user, and include a health check. This is how production containers are built.

In this step, get ready to:
  • Rewrite the Dockerfile as a multi-stage build with a non-root user and health check.
  • Push the changes and watch the pipeline go green.
  • Compare image sizes between the naive and hardened builds.
Write the builder stage

A multi-stage build splits your Dockerfile into separate stages. The first stage installs dependencies. The second stage copies only what the app needs to run, leaving build tools and caches behind.

  • Open Dockerfile in your editor.
  • Delete all existing content and replace it with the builder stage by pasting this code:
# Stage 1: Builder
FROM python:3.12-slim AS builder

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

What does this code do?

  • FROM python:3.12-slim AS builder uses the slim variant of Python 3.12 (around 150MB instead of 1GB) and names this stage builder.
  • COPY requirements.txt . copies only the dependency file first, not the entire project.
  • --prefix=/install installs packages into a separate directory so you can copy them cleanly into the production stage without dragging along pip caches or build artifacts.
Write the production stage

The production stage starts from a fresh slim image. It copies the pre-built packages from the builder and runs the app as a non-root user with a health check.

  • Add the following code directly below the builder stage in your Dockerfile:
# Stage 2: Production
FROM python:3.12-slim

RUN useradd --create-home --shell /bin/bash appuser

WORKDIR /app

COPY --from=builder /install /usr/local
COPY main.py .

USER appuser

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1

CMD ["fastapi", "run", "main.py", "--host", "0.0.0.0", "--port", "8000"]

What does this code do?

  • RUN useradd --create-home --shell /bin/bash appuser creates a non-root user. Running as root inside a container is a security risk because a container escape would give the attacker root on the host.
  • COPY --from=builder /install /usr/local pulls the installed Python packages from the builder stage into the production image. No pip, no build tools, no caches come along.
  • USER appuser switches the runtime user so the app never runs as root.
  • HEALTHCHECK tells Docker (and orchestrators like Kubernetes) how to verify the container is alive. It hits your /health endpoint every 30 seconds.
  • Save Dockerfile.
  • Build the hardened image locally by running:
docker build -t myapp:hardened .

The build should complete without errors. You now have both images on your machine.

  • Compare the image sizes by running:
docker images

You should see myapp:hardened at roughly 150-200MB compared to myapp:naive at over 1GB.

Build failing with a COPY error?

Make sure Stage 1 has AS builder at the end of its FROM line. The COPY --from=builder in Stage 2 references that name. If it is missing, Docker cannot find the source stage.

Also confirm you deleted all the old Dockerfile content before pasting the new code. Leftover lines from the naive Dockerfile can cause unexpected errors.

Help me debug this error

✔️ Awesome, I've got everything!

Great. Double check you have saved your Dockerfile and that the build succeeded.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile you should have:

# Stage 1: Builder
FROM python:3.12-slim AS builder

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: Production
FROM python:3.12-slim

RUN useradd --create-home --shell /bin/bash appuser

WORKDIR /app

COPY --from=builder /install /usr/local
COPY main.py .

USER appuser

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1

CMD ["fastapi", "run", "main.py", "--host", "0.0.0.0", "--port", "8000"]
Push and verify the pipeline passes

Your hardened image builds locally. Time to let the pipeline re-evaluate it.

  • Commit and push the updated Dockerfile by running:
git add Dockerfile
git commit -m "Harden Dockerfile with multi-stage build and non-root user"
git push origin main

Before you check the result, think about what you expect to happen. Will Trivy still find CRITICAL or HIGH vulnerabilities in python:3.12-slim?

  • Open your repository on GitHub.
  • Click the Actions tab.
  • Click on the latest workflow run triggered by your push.

Both jobs should show green checkmarks. The lint job passes as before. The Trivy scan now passes because python:3.12-slim carries far fewer OS packages, eliminating the CRITICAL and HIGH vulnerabilities that were in the full image.

Why does the slim image pass?

The full python:3.12 image is based on Debian with hundreds of pre-installed system packages (compilers, network tools, libraries). Many of these have known CVEs. The slim variant strips out everything except what Python needs to run, dramatically reducing the attack surface.

Your Trivy scan is configured with ignore-unfixed: true, so it only flags vulnerabilities that have available patches. The slim image has so few packages that it typically has zero fixable CRITICAL/HIGH issues.

Pipeline still failing?

If the scan job still fails, click on the failed step in the Actions logs to see which package triggered it. Occasionally a newly disclosed CVE affects even slim images. Check that your FROM line says python:3.12-slim (not python:3.12 or python:slim).

If the lint job fails, make sure main.py has not been accidentally modified.

Help me debug this pipeline failure

Your pipeline is green. The image is hardened, slim, and running as a non-root user. Next up, you will add the CD half: automatically pushing the passing image to a container registry so it is ready to deploy anywhere.

Add Automated Image Push to GitHub Container Registry

In the last step, your hardened Dockerfile passed Trivy's security scan and the pipeline went green.

A passing security scan means the image is safe to publish. Now you add the CD half of your pipeline: on every push to main, the workflow automatically tags the image with the Git SHA and pushes it to GitHub Container Registry. SHA tags make rollbacks trivial because you can pull any previous commit's exact image with a single command.

In this step, get ready to:
  • Add a build-and-push job to your workflow that authenticates to ghcr.io.
  • Configure SHA-based image tagging for deterministic rollbacks.
  • Pull and run your published image locally to prove the full loop works.
Add the build-and-push job with authentication

The new job needs to run only after lint and scan both pass. It authenticates to ghcr.io using GITHUB_TOKEN, a secret GitHub automatically provides to every workflow run.

  • Open .github/workflows/ci.yml in your editor.
  • At the bottom of the file, after the scan job, add this new build-and-push job:
  build-and-push:
    needs: [lint, scan]
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

What does this code do?

  • needs: [lint, scan] ensures this job only runs after both quality gates pass. If either fails, no image is published.
  • The if: condition restricts publishing to pushes to main only. Pull requests trigger lint and scan but never publish.
  • permissions grants packages: write so the workflow can push images to the registry, and contents: read to check out the code.
  • docker/login-action@v4 authenticates to ghcr.io using the built-in GITHUB_TOKEN secret. No manual token setup needed.
Add the build, tag, and push steps

With authentication in place, you need three more steps: set up Buildx for advanced builds, generate SHA-based tags, and push the image.

  • Still in .github/workflows/ci.yml, add these steps directly below the login step you just added:
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Extract metadata (tags, labels)
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest

      - name: Build and push Docker image
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

What does this code do?

  • docker/setup-buildx-action@v4 enables Buildx, which supports advanced features like layer caching (you will use this in the next step).
  • docker/metadata-action@v6 generates two tags: type=sha,prefix=sha- produces a tag like sha-a1b2c3d tied to the exact commit, and type=raw,value=latest always points to the most recent build.
  • docker/build-push-action@v7 builds the image from your Dockerfile and pushes it to the registry with the generated tags.

Why SHA-based tags?

SHA tags are deterministic. Each tag points to exactly one commit. If a deployment breaks, you roll back by pulling the previous SHA's image. No manual version bumping, no guessing which build is which.

  • Save .github/workflows/ci.yml.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before pushing.

ⓧ I'd like to double check the full code

Your .github/workflows/ci.yml should have three jobs in this order. Scroll up and confirm each is present:

  • The lint job (checkout, set up Python, install ruff, run ruff).
  • The scan job (checkout, build image, run Trivy).
  • The build-and-push job with needs: [lint, scan], permissions, and five steps (checkout, login, buildx, metadata, build-push).
Push and verify the published image

Time to push your changes and watch the full pipeline build, scan, and publish your image.

  • Commit and push your changes by running these commands:
git add .
git commit -m "Add build-and-push job to CI pipeline"
git push origin main
  • Open your GitHub repository in the browser.
  • Click the Actions tab.

Before you look at the result, what do you expect to see? Do you think all three jobs will pass, or will one fail?

  • Click on the running workflow to watch its progress.

You should see all three jobs complete with green checkmarks: lint, scan, and build-and-push. The build-and-push job waits for the other two to finish, then runs.

  • Navigate to your repository's main page.
  • In the right sidebar, find and click on the package listed under Packages.

You should see your published image with two tags: a sha- prefixed tag and latest.

Don't see Packages in the sidebar?

  • Make sure the pipeline finished with a green checkmark on all three jobs. If build-and-push failed, click into the job logs to see the error.
  • If you see a permissions error in the logs, double-check that your build-and-push job has the permissions: block with packages: write.
  • Verify that the YAML indentation is correct. The permissions: key must be at the same indentation level as runs-on:.

Help me debug my build-and-push job failing in GitHub Actions

Now pull and run the published image locally to prove the full loop works.

  • Copy the SHA tag from your Packages page (it looks like sha-a1b2c3d).
  • Pull the image from ghcr.io by running this command:
docker pull ghcr.io/[[GHCR_IMAGE="your-username/your-repo"]]:[[SHA_TAG="sha-XXXXXX (your SHA tag from Packages)"]]
  • Run the pulled image by running this command:
docker run -p 8000:8000 ghcr.io/[[GHCR_IMAGE="your-username/your-repo"]]:[[SHA_TAG="sha-XXXXXX (your SHA tag from Packages)"]]
  • Open http://localhost:8000/health in your browser.

You should see {"status": "healthy"}. That response came from an image that was built, scanned, tagged, and published entirely by your pipeline. The full CI/CD loop is working.

Can't pull the image?

  • If the repo is private, you need to authenticate Docker locally first. Run docker login ghcr.io with your GitHub username and a personal access token that has read:packages scope.
  • Make sure you replaced the username, repo name, and SHA tag with the actual values from your Packages page.

Help me pull my image from GitHub Container Registry

  • Stop the running container by pressing Ctrl+C in the terminal.

Your pipeline now handles the entire lifecycle: lint, scan, and publish. Every push to main produces an immutable, tagged image in your registry. Next up, you will make it fast.

Optimize Build Performance

Your pipeline builds, scans, and publishes images automatically. That is a full CI/CD loop.

There is one problem: every push rebuilds everything from scratch. pip re-downloads packages, Docker rebuilds every layer, and tools get re-fetched each run. In production, a slow pipeline means slow deployments and frustrated developers. You are going to make it fast with caching and parallel execution.

In this step, get ready to:
  • Add Docker layer caching and pip dependency caching to the workflow.
  • Confirm optimal Dockerfile layer ordering and parallel job execution.
  • Push a code-only change and measure the speed improvement.
Add Docker layer caching and pip dependency caching

GitHub Actions can store build artifacts between runs so subsequent pipelines skip redundant work. You will enable two types of caching: pip dependency caching for the lint job, and Docker layer caching for the build-and-push job.

  • In .github/workflows/ci.yml, find the Set up Python step inside the lint job.
  • Find this block:
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
  • Add cache: "pip" below the python-version line, so the step now looks like this:
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"

What does this do?

The cache: "pip" input tells actions/setup-python@v5 to save pip's global cache directory between workflow runs. It automatically hashes your requirements.txt to create a cache key.

On the next run, if requirements.txt has not changed, pip restores packages from the cache instead of downloading them from PyPI.

  • Now scroll down to the Build and push Docker image step inside the build-and-push job.
  • Find this block:
      - name: Build and push Docker image
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
  • Add two lines below labels: so the step now looks like this:
      - name: Build and push Docker image
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

What does Docker layer caching do?

The cache-from: type=gha line tells Buildx to restore cached Docker layers from GitHub Actions' built-in cache storage. The cache-to: type=gha,mode=max line saves all layers (not just final-image layers) back to the cache after the build.

On subsequent builds, any layer whose inputs have not changed (like the dependency-install step) is pulled from cache instead of being rebuilt. This is what turns a 2-3 minute build into a sub-30-second build.

  • Save .github/workflows/ci.yml.
Verify Dockerfile layer ordering and parallel job execution

Caching only works well when your Dockerfile and workflow are structured to exploit it. Two things need to be true for maximum speed.

  • Open your Dockerfile and look at the builder stage. Confirm you see COPY requirements.txt . before COPY main.py . (in the production stage).

Why does layer ordering matter?

Docker caches each layer independently. When you copy requirements.txt and install dependencies BEFORE copying application code, Docker can reuse the expensive dependency-install layer every time you only change main.py.

If you copied everything at once, any code change would invalidate the dependency layer too, forcing a full reinstall every build.

  • Now switch back to .github/workflows/ci.yml and look at the lint and scan jobs.
  • Confirm that neither lint nor scan has a needs: key. Only build-and-push has needs: [lint, scan].

Why does parallel execution help?

Without a needs: dependency between them, lint and scan run at the same time on separate runners. The total pipeline time is the duration of the longest job, not the sum of all jobs.

The build-and-push job still waits for both to pass before publishing. This gives you the safety of gates without the cost of sequential execution.

Push a code-only change and measure the speed improvement

You need two pipeline runs to see the caching effect: the first run populates the cache, and the second run uses it. Your caching edits will be the first run. A small code-only change will trigger the second.

  • Open main.py and find this line:
app = FastAPI(title="Production API", version="1.0.0")
  • Change version="1.0.0" to version="1.1.0" so the line looks like this:
app = FastAPI(title="Production API", version="1.1.0")
  • Save main.py.
  • Commit and push all your changes to main by running these commands:
git add -A
git commit -m "Add caching and update version"
git push origin main
  • Open the Actions tab in your GitHub repository.
  • Wait for this first run to complete. It populates the cache.

Why do I need a second push?

The first run with caching enabled has no cached layers to restore. It runs at the same speed as before, but it saves layers to the cache on completion. The second run is where you see the real speed gain.

  • Once the first run finishes green, open main.py again and change version="1.1.0" back to version="1.0.0".
  • Save main.py, then push again by running:
git add -A
git commit -m "Trigger cached build"
git push origin main

Before you check the Actions tab, what do you expect? Do you think the build-and-push job will be faster or the same speed as before?

  • Open the Actions tab and click into the new workflow run.
  • Click on the build-and-push job and compare its duration to the previous run.

The optimized build-and-push job completes in under 30 seconds, compared to 2-3 minutes before.

  • Expand the Set up Docker Buildx step in the job logs to confirm you see cache hit messages.
  • Also note that the lint and scan jobs ran in parallel. The total pipeline time is shorter because they overlapped.

Build still slow on the second run?

  • Confirm you have both cache-from: type=gha and cache-to: type=gha,mode=max in the build-push-action step. Missing either one means the cache is never saved or never restored.
  • Check that docker/setup-buildx-action@v4 is present above the build step. GHA caching requires Buildx.
  • Verify your Dockerfile copies requirements.txt before main.py. If they are in the same COPY instruction, the dependency layer invalidates on every code change.

Still stuck? Help me figure out why my Docker layer caching isn't working in GitHub Actions.

✔️ Awesome, I've got everything!

Great. Double check you have saved .github/workflows/ci.yml with both caching additions.

ⓧ I'd like to double check the full code

Your .github/workflows/ci.yml is long, so confirm each section is present in order:

  • The lint job with cache: "pip" in the setup-python step.
  • The scan job with Trivy scanning (no needs: key, runs in parallel with lint).
  • The build-and-push job with needs: [lint, scan], login, Buildx setup, metadata, and the build-push-action containing cache-from: type=gha and cache-to: type=gha,mode=max.

Your Dockerfile should still have COPY requirements.txt . in the builder stage followed by RUN pip install, and COPY main.py . only in the production stage after the dependency copy.

Your pipeline is now production-fast. Cached builds complete in seconds, not minutes. Next up, you will take on the Secret Mission and add a manual approval gate for production deployments.

Secret mission

Add a Manual Approval Gate with GitHub Environments

Your pipeline builds, scans, and publishes automatically. But in production, you do not want every push to deploy without a human saying "yes." In this secret mission, you add a manual approval gate so the deploy job pauses and waits for your sign-off before running.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses GitHub Actions free tier (unlimited minutes for public repos) and GitHub Container Registry (free for public repos), so there are no ongoing costs if your repository is public.

Resources you used:

  • GitHub repository (contains workflow, code, and environment settings).
  • GitHub Container Registry images published to ghcr.io.
  • Local Docker images (myapp:naive, myapp:hardened, and any pulled ghcr.io images).
  • Local project directory on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building on this pipeline or plan to extend it soon.

  • Your images persist in ghcr.io indefinitely at no cost for public repos.
  • The pipeline only runs when you push code to main, so it will not consume Actions minutes while idle.
  • Local Docker images remain cached on your machine until you remove them.

Pause - I'll come back to this later

Free up local disk space while keeping your remote resources intact. The pipeline only triggers on push, so it is already paused when you stop pushing.

  • Remove local Docker images to reclaim disk space by running:
docker rmi myapp:naive myapp:hardened
  • Your GitHub repository, container images, and environment settings all remain in place for when you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. This removes your workflow, images, and environment configuration permanently.

  • Delete the GitHub repository to remove all workflow history, container images, and environment settings. In your repository on GitHub, go to Settings, scroll to the bottom of the General page, and click Delete this repository.
  • If you want to keep the repository but delete the container images, go to your GitHub profile and click the Packages tab. Click on your container package, click View and manage all versions, then select Delete version from the menu next to each version.
  • Remove local Docker images by running:
docker rmi myapp:naive myapp:hardened
  • Delete the local project directory. On macOS or Linux, run:
rm -rf ~/Desktop/production-cicd
  • On Windows, run:
Remove-Item -Recurse -Force ~\Desktop\production-cicd

Nice Work!

Nice Work!

You just built a production-grade CI/CD pipeline from scratch. Every push to main now triggers an automated workflow that lints, scans, and publishes a hardened container image to GitHub Container Registry.

You've learned how to:

  • Build a production-hardened Dockerfile using multi-stage builds, a non-root user, health checks, and optimized layer ordering.
  • Create a fully automated CI/CD pipeline with GitHub Actions that lints code, runs Trivy vulnerability scanning as a blocking security gate, and publishes SHA-tagged images to GitHub Container Registry for instant rollbacks.
  • Optimize pipeline performance with Docker layer caching, pip dependency caching, and parallel job execution, reducing build times from minutes to seconds.
  • (Secret Mission) Add a manual approval gate using GitHub Environments to control production deployments with human sign-off.

Ready to quiz yourself?