Containerize a Python Microservice

Build a secure CI/CD pipeline that containerizes and scans a Python Flask app.

Introduction

30 Second Summary

Every time a developer pushes code at work, something needs to check that the code is safe, package it up, and ship it to where it can run. If that process is manual, mistakes slip through and deployments take hours instead of minutes.

In this project, you will containerize a Python microservice with Docker and build an automated CI/CD pipeline that builds, scans for vulnerabilities, and publishes your container image every time you push code.

What You'll Build

A fully containerized Flask REST API with a GitHub Actions pipeline that automatically builds a production-optimized Docker image, scans it for security vulnerabilities with Trivy, and publishes it to GitHub Container Registry.

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

  • A multi-stage Dockerfile that shrinks your Python app from ~1GB down to ~150MB.
  • A GitHub Actions CI/CD pipeline that automatically builds, scans, and publishes your container image on every push.
  • A container security scanning gate using Trivy that blocks vulnerable images from ever reaching your registry.
  • Secret Mission: add a second microservice endpoint and implement build caching to achieve sub-30-second CI rebuilds.

Are there any prerequisites?

You should be comfortable with basic Python (variables, functions) and basic command-line usage. You do not need prior Docker or CI/CD experience.

Set Up Your Project Structure

Every containerized application starts with source code in a repository. Your CI/CD pipeline will trigger from this repository, so it needs to exist before you can automate anything.

In this step, you'll create a GitHub repository, write a minimal Flask microservice, and confirm Docker is ready on your machine.

In this step, get ready to:
  • Create a GitHub repository and clone it locally.
  • Write a Python Flask microservice with a health check endpoint.
  • Verify Docker Desktop is running.
Create a GitHub repository and clone it locally

Your GitHub Actions pipeline will trigger whenever you push code to this repository. The repository must be public so that GitHub Container Registry storage remains free.

  • Open github.com in your browser and sign in.
  • Click the + icon in the top-right corner.
  • Select New repository.
  • Enter docker-cicd-pipeline as the Repository name.
  • Select Public for visibility.
  • Check the box for Add a README file.
  • Click Create repository.

GitHub redirects you to your new repository page. Now clone it to your machine.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Git Bash (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone your repository by running this command:
git clone [[REPO_URL="https://github.com/YOUR-USERNAME/docker-cicd-pipeline.git"]]

Where do I find my clone URL?

On your repository page, click the green Code button. Copy the HTTPS URL shown in the dropdown.

  • Move into the cloned folder by running:
cd docker-cicd-pipeline
  • Confirm the clone worked by running:
ls

You should see README.md listed in the output.

Now open this folder in VS Code.

  • Open VS Code in the current folder by running:
code .

Getting 'command not found' for code?

In VS Code, press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) to open the Command Palette. Type shell command and select Install 'code' command in PATH. Then close and reopen your terminal.

Help me open my project folder in VS Code.

Create the Flask application

Your microservice is a Python Flask app with two endpoints. The /health endpoint lets monitoring tools check that the container is alive. The / root endpoint returns basic service information.

  • In the VS Code Explorer sidebar, right-click in the empty area below README.md.
  • Select New File and name it app.py.
  • Add the following code to app.py to set up the Flask app and the health check endpoint:
from flask import Flask, jsonify
import socket
import os

# Create the Flask application instance
app = Flask(__name__)


# Health check endpoint for monitoring tools
@app.route("/health")
def health():
    return jsonify({"status": "healthy", "service": "microservice-demo"})

What does this code do?

  • flask and jsonify are imported from the Flask library to create the web app and return JSON responses.
  • socket and os are standard Python modules for getting system info and environment variables.
  • app = Flask(__name__) creates your web application instance.
  • The /health route returns a simple JSON status. Container orchestrators like Kubernetes use endpoints like this to confirm your service is running.
  • Add the root endpoint and startup code below the health() function:

# Root endpoint returns service info
@app.route("/")
def root():
    return jsonify(
        {
            "message": "Microservice is running",
            "hostname": socket.gethostname(),
            "endpoints": ["/", "/health"],
        }
    )


# Run the app when executed directly
if __name__ == "__main__":
    port = int(os.environ.get("PORT", 8000))
    app.run(host="0.0.0.0", port=port)

What does this code do?

  • The / route returns the container's hostname and a list of available endpoints. This helps you identify which container is responding when you scale to multiple instances.
  • os.environ.get("PORT", 8000) reads the port from an environment variable, falling back to 8000 if none is set.
  • host="0.0.0.0" tells Flask to listen on all network interfaces. This is required for the app to be reachable from outside a container.
  • Save app.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Seeing red squiggly lines in VS Code?

VS Code may show warnings about Flask not being installed locally. This is expected. You will run the app inside a Docker container, not directly on your machine. The warnings disappear once you build the container in a later step.

Help me understand why VS Code shows import errors for Flask.

Now create the file that tells Docker which Python packages to install.

  • In the VS Code Explorer sidebar, right-click and select New File.
  • Name it requirements.txt.
  • Add the following two dependencies to requirements.txt:
flask==3.1.1
gunicorn==23.0.0

Why gunicorn?

Flask's built-in server is for development only. Gunicorn is a production-grade WSGI server that handles multiple requests concurrently. Your Dockerfile will use gunicorn to serve the app, just like real production deployments.

  • Save requirements.txt.
  • Confirm both files exist by checking the VS Code Explorer sidebar. You should see app.py, README.md, and requirements.txt listed.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the full app.py for comparison:

from flask import Flask, jsonify
import socket
import os

app = Flask(__name__)


@app.route("/health")
def health():
    return jsonify({"status": "healthy", "service": "microservice-demo"})


@app.route("/")
def root():
    return jsonify(
        {
            "message": "Microservice is running",
            "hostname": socket.gethostname(),
            "endpoints": ["/", "/health"],
        }
    )


if __name__ == "__main__":
    port = int(os.environ.get("PORT", 8000))
    app.run(host="0.0.0.0", port=port)

And the full requirements.txt:

flask==3.1.1
gunicorn==23.0.0
Verify Docker Desktop is running

Docker Desktop must be running for you to build container images in the next step. A quick version check confirms the Docker CLI is accessible from your terminal.

  • In your VS Code terminal (open it with Ctrl+` if it's not visible), check Docker is available by running:
docker --version

✔️ I see a version number

You should see output like Docker version 28.x.x, build ... with a version number and build hash. Docker is ready to go.

ⓧ Command not found

This means Docker Desktop is not running or the CLI is not in your system PATH.

  • Open Docker Desktop from your applications (search for Docker using Cmd+Space or the Windows key).
  • Wait for the Docker Desktop dashboard to show a green "running" status.
  • Close and reopen your terminal, then run docker --version again.

If it still does not work, reinstall Docker Desktop from docker.com/products/docker-desktop.

Help me get Docker working in my terminal.

Your project structure is ready. You have a working Flask microservice and Docker waiting to containerize it. Next up, you'll write a production-ready multi-stage Dockerfile that turns this app into a tiny, secure container image.

Write a Production-Ready Multi-Stage Dockerfile

Your Flask microservice and dependencies are ready to go. Now it's time to package everything into a container image that's lean and secure enough for production.

A standard single-stage Dockerfile for Python produces images around 1GB. Multi-stage builds solve this by separating the build environment from the runtime environment. You'll end up with an image around 150MB.

In this step, get ready to:
  • Write a two-stage Dockerfile that builds dependencies separately from the runtime.
  • Configure a non-root user and production-ready environment variables.
  • Create a .dockerignore file to keep your build context clean.
Write the builder stage

The first stage of a multi-stage Dockerfile handles all the heavy lifting. It installs your Python packages into a virtual environment, then gets discarded.

  • In VS Code, create a new file called Dockerfile in the root of your project folder (the same level as app.py).
  • Paste the following code into your new Dockerfile:
# Stage 1: Build dependencies
FROM python:3.12-slim AS builder

WORKDIR /app

COPY requirements.txt .

RUN python -m venv /opt/venv && \
    /opt/venv/bin/pip install --no-cache-dir --upgrade pip && \
    /opt/venv/bin/pip install --no-cache-dir -r requirements.txt

What does this code do?

  • FROM python:3.12-slim AS builder starts from a lightweight Python base image and names this stage builder.
  • WORKDIR /app sets the working directory inside the container.
  • COPY requirements.txt . brings in only the dependency list first. This lets Docker cache this layer so it doesn't reinstall packages every time you change app.py.
  • The RUN command creates a virtual environment at /opt/venv, upgrades pip, and installs all dependencies into that isolated environment.
  • Save Dockerfile.

You won't see a visible change yet. The next section completes the runtime stage that actually runs your app.

Write the production runtime stage

The second stage starts from a fresh image and copies only what's needed to run your app. Everything from the builder stage (pip cache, build tools, temporary files) gets left behind.

  • In Dockerfile, add the following code below Stage 1 (after the last RUN command):
# Stage 2: Production runtime
FROM python:3.12-slim

WORKDIR /app

# Copy virtual environment from builder
COPY --from=builder /opt/venv /opt/venv

# Copy application code
COPY app.py .

# Create non-root user
RUN useradd --create-home appuser
USER appuser

# Set environment variables
ENV PATH="/opt/venv/bin:$PATH" \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

EXPOSE 8000

CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]

What does this code do?

  • COPY --from=builder /opt/venv /opt/venv pulls only the installed packages from Stage 1 into this fresh image. No build tools come along.
  • RUN useradd --create-home appuser and USER appuser ensure the container runs as a non-root user. This is a critical production security practice.
  • ENV PATH="/opt/venv/bin:$PATH" makes Python and Gunicorn available without specifying the full path.
  • CMD starts Gunicorn, a production WSGI server, instead of Flask's built-in development server.

Why Gunicorn instead of Flask's dev server?

Flask's built-in server is designed for local development only. It handles one request at a time and has no production safeguards.

Gunicorn is a production-grade WSGI server that handles concurrent requests, manages worker processes, and restarts gracefully after crashes. Every production Flask deployment uses a proper WSGI server like Gunicorn.

  • Save Dockerfile.

Your Dockerfile is now complete. You'll build it into a container image in the next step.

Seeing a syntax error in your Dockerfile?

  • Make sure each \ at the end of multi-line commands has no trailing spaces after it.
  • Check that COPY --from=builder uses two dashes, not one.
  • Confirm the file is named exactly Dockerfile with a capital D and no file extension.

Help me fix my Dockerfile syntax error

✔️ Awesome, I've got everything!

Great. Double check you've saved your Dockerfile before moving on.

ⓧ I'd like to double check the full code

# Stage 1: Build dependencies
FROM python:3.12-slim AS builder

WORKDIR /app

COPY requirements.txt .

RUN python -m venv /opt/venv && \
    /opt/venv/bin/pip install --no-cache-dir --upgrade pip && \
    /opt/venv/bin/pip install --no-cache-dir -r requirements.txt

# Stage 2: Production runtime
FROM python:3.12-slim

WORKDIR /app

# Copy virtual environment from builder
COPY --from=builder /opt/venv /opt/venv

# Copy application code
COPY app.py .

# Create non-root user
RUN useradd --create-home appuser
USER appuser

# Set environment variables
ENV PATH="/opt/venv/bin:$PATH" \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

EXPOSE 8000

CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
Create a .dockerignore file

When Docker builds an image, it sends your entire project folder as the "build context" to the Docker engine. A .dockerignore file tells Docker which files to skip, keeping builds faster and images cleaner.

  • Create a new file called .dockerignore in the root of your project folder (same level as Dockerfile).
  • Paste the following into .dockerignore:
.git
.gitignore
__pycache__
*.pyc
*.pyo
.env
.venv
venv
README.md
.github

Why exclude these files?

  • .git and .github contain version control history and workflow files that don't belong in a container.
  • __pycache__, *.pyc, and *.pyo are compiled Python files from your local machine. The container generates its own.
  • .env, .venv, and venv could contain secrets or local virtual environments that should never enter an image.
  • Save .dockerignore.
  • Confirm your project files exist by running this in your terminal:
ls

You should see app.py, requirements.txt, Dockerfile, and .dockerignore in the output.

Hidden files in your terminal

If you don't see .dockerignore in the output, files starting with a dot are hidden by default. Run ls -a to show all files including hidden ones.

Don't see .dockerignore?

  • Make sure you named it .dockerignore with the leading dot. Without the dot, Docker won't recognize it.
  • Verify the file is at the same folder level as your Dockerfile. It won't work if nested inside a subfolder.

Help me find or create my .dockerignore file

Your Dockerfile and .dockerignore are locked in. Next up, you'll build this into an actual container image and watch multi-stage builds in action.

Build and Test Your Container Locally

Your Dockerfile and .dockerignore are ready to go. Now it's time to see your multi-stage build in action.

In this step, you'll build the Docker image, run it as a container, and verify that your Flask API responds to requests. You'll also see the dramatic size reduction that multi-stage builds provide.

In this step, get ready to:
  • Build a multi-stage Docker image locally.
  • Run the container and verify the API endpoints.
  • Compare the image size to a single-stage approach.
Build the Docker image

Your Dockerfile has two stages, but Docker processes them in a single build command. The builder stage installs your Python dependencies, then the runtime stage copies only what's needed to run the app.

  • Make sure your terminal is in your project folder (the one containing your Dockerfile).
  • Build your Docker image by running this command:
docker build -t microservice-demo .

What does this command do?

  • docker build reads your Dockerfile and executes each instruction to create an image.
  • -t microservice-demo tags (names) the image so you can reference it later.
  • . tells Docker to use the current directory as the build context (where it looks for files to COPY).
  • Wait for the build to finish. The first build takes longer because Docker needs to download the python:3.12-slim base image.
  • You should see progress lines for both stages. Look for lines referencing your builder stage and then your final runtime stage.

Build failing or hanging?

  • Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your system tray.
  • Make sure you're running the command from the folder that contains your Dockerfile. Run ls to confirm you can see it listed.
  • If you see a network error while pulling the base image, check your internet connection and try again.

Help me debug my Docker build error

Run the container and verify the endpoints

An image is a blueprint. A container is the running instance of that blueprint. Let's start your container and confirm the API responds.

  • Start your container by running this command:
docker run -d -p 8000:8000 microservice-demo

What does this command do?

  • -d runs the container in detached mode (in the background), so your terminal stays free.
  • -p 8000:8000 maps port 8000 on your machine to port 8000 inside the container. This is how you reach the Flask app from your browser or terminal.
  • You should see a long string of characters printed in your terminal. This is your container ID, which confirms the container started.
  • Test the health check endpoint by running:
curl http://localhost:8000/health
  • You should see this JSON response:
{"status":"healthy","service":"microservice-demo"}
  • Test the root endpoint by running:
curl http://localhost:8000/
  • You should see a JSON response containing a message field, a hostname field (a short hex string, which is your container's ID), and an endpoints list.

You can also test in your browser

Open http://localhost:8000/health in your browser to see the same JSON response. The curl command does the same thing as visiting a URL in your browser.

Getting "connection refused" or no response?

  • Check if the container is actually running by running docker ps. If you don't see your container listed, it may have crashed on startup.
  • If port 8000 is already in use by another application, the container won't start. Stop the other application or choose a different port mapping like -p 8001:8000.
  • If the container crashed, check its logs by running docker logs followed by the container ID from docker ps -a.

Help me figure out why my container isn't responding

Compare image sizes to see the optimization

One of the biggest benefits of multi-stage builds is image size reduction. A smaller image means faster downloads, faster deployments, and a smaller attack surface for security.

  • Check the size of your image by running:
docker images microservice-demo
  • Look at the SIZE column. Your multi-stage image should be approximately 150-180MB.

Why is this so much smaller?

Without multi-stage builds, you'd use the full python:3.12 base image which is over 1GB. It includes compilers, development headers, and tools your app doesn't need at runtime.

Your multi-stage approach uses python:3.12-slim (a minimal Debian image) and copies only the pre-built virtual environment from the builder stage. The build tools stay behind in the discarded builder stage. This takes you from approximately 1GB down to around 150MB.

Why does image size matter in production?

Smaller images pull faster when deploying to new servers. They also contain fewer packages, which means fewer potential security vulnerabilities for scanners like Trivy to flag.

Your containerized microservice is running and responding to requests. Next, you'll automate this entire build process with a CI/CD pipeline on GitHub Actions so every push triggers a fresh build automatically.

Create Your CI/CD Pipeline with GitHub Actions

Your Docker image builds and runs perfectly on your machine. The next challenge is making that happen automatically every time you push code.

GitHub Actions is a CI/CD platform built directly into GitHub. You define a workflow file that tells GitHub exactly what to do when code lands on your main branch. In this step, you will create that workflow.

In this step, get ready to:
  • Create the .github/workflows/ci.yml workflow file with push and pull request triggers.
  • Configure the build job with checkout, Docker Buildx, and registry login steps.
  • Push your code and watch the pipeline run on GitHub.
Create the workflow file with triggers

GitHub Actions looks for workflow files in a specific directory: .github/workflows/. Any YAML file in that folder becomes a workflow that GitHub can run.

  • Create the workflow directory by running this command in your terminal:
mkdir -p .github/workflows
  • In VS Code, create a new file inside .github/workflows/ called ci.yml.
  • Paste the following workflow trigger configuration into ci.yml:
name: CI/CD Pipeline

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

permissions:
  contents: read
  packages: write

jobs:
  build-scan-push:
    runs-on: ubuntu-latest
    steps:

What does this configuration do?

  • name gives your workflow a human-readable label that appears in the GitHub Actions tab.
  • on defines the triggers. This workflow runs on every push to main and on pull requests targeting main.
  • permissions grants the workflow read access to your code and write access to publish container packages.
  • runs-on: ubuntu-latest tells GitHub to spin up a fresh Ubuntu virtual machine for each run.
Configure the build job setup steps

Every CI/CD pipeline starts with setup. Your job needs to check out code, prepare the Docker build engine, and authenticate with the container registry.

  • In ci.yml, add the following steps directly below the steps: line (indented 6 spaces):
      - name: Checkout code
        uses: actions/checkout@v4

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

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

What do these steps do?

  • Checkout code clones your repository onto the runner so subsequent steps can access your files.
  • Set up Docker Buildx installs an advanced Docker build engine that supports features like layer caching and multi-platform builds.
  • Log in to GitHub Container Registry authenticates using the built-in GITHUB_TOKEN secret. You do not need to create any secrets manually.

Why GHCR instead of Docker Hub?

GitHub Container Registry (ghcr.io) uses the GITHUB_TOKEN already available in every workflow. No extra accounts, no additional secrets to configure. It is free for public repositories.

Add the Docker build step

The final piece is the step that actually builds your Docker image. This uses your multi-stage Dockerfile from the previous step.

  • In ci.yml, add the following step below the login step:
      - name: Build Docker image for scanning
        uses: docker/build-push-action@v6
        with:
          context: .
          load: true
          tags: ${{ github.repository }}:scan

What does this build step do?

  • context: . tells Docker to use the current directory (your repo root) as the build context.
  • load: true loads the built image into the runner's local Docker daemon instead of pushing it to a registry. This lets you scan the image before publishing.
  • tags labels the image with your repository name and a :scan tag so the security scanner can reference it in the next step.
  • Save ci.yml.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before continuing.

ⓧ I'd like to double check the full code

Here is the complete ci.yml file:

name: CI/CD Pipeline

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

permissions:
  contents: read
  packages: write

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

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

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

      - name: Build Docker image for scanning
        uses: docker/build-push-action@v6
        with:
          context: .
          load: true
          tags: ${{ github.repository }}:scan
  • Commit and push your code to GitHub by running these commands:
git add .
git commit -m "Add CI/CD pipeline workflow"
git push
  • Open your repository on GitHub in your browser.
  • Click the Actions tab near the top of the page.
  • You should see your CI/CD Pipeline workflow listed. Click it to see the run in progress (or queued).

What should I see?

In the Actions tab, you should see a workflow run triggered by your push. The run shows your commit message as its title. It may have a yellow dot (in progress) or a red X (failed because the Trivy scan step does not exist yet).

A failure at this stage is expected. The build step will succeed, but the pipeline is not complete until you add the security scan in the next step.

Don't see the workflow in the Actions tab?

  • Make sure ci.yml is saved inside .github/workflows/. GitHub only detects workflows in that exact directory path.
  • Confirm your push reached GitHub by refreshing your repository page and checking that .github/workflows/ci.yml appears in the file list.
  • Check that your default branch is named main. If it is named something else, the trigger will not match.

Why isn't my GitHub Actions workflow showing up?

Your pipeline is live. Every push to main now triggers an automated build. Next, you will add a security scanner that blocks vulnerable images from ever reaching your registry.

Add Security Scanning with Trivy

Your pipeline can now build a Docker image automatically. But building an image is only half the story.

In production, no container image should reach a registry without passing a security scan first. In this step, you'll add Trivy as a security gate that blocks vulnerable images, then wire up the final publish step so clean images land in GitHub Container Registry.

In this step, get ready to:
  • Add a Trivy scan step that blocks images with critical or high vulnerabilities.
  • Add a build-and-push step that publishes passing images to GitHub Container Registry.
  • Push your code and verify the full pipeline runs end to end.
Add the Trivy vulnerability scan step

Trivy is an open-source container scanner that checks your image for known vulnerabilities (called CVEs). By placing it between the build step and the publish step, you create a security gate. If Trivy finds serious issues, the pipeline stops and the image never reaches the registry.

  • In VS Code, open .github/workflows/ci.yml.
  • Find the last step (Build Docker image for scanning). Add the following step directly below it:
      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          image-ref: ${{ github.repository }}:scan
          format: table
          exit-code: "1"
          ignore-unfixed: true
          severity: CRITICAL,HIGH

What does this step do?

This step scans the image you built in the previous step (tagged ${{ github.repository }}:scan) for known security vulnerabilities.

  • format: table prints results in a readable table format in the workflow logs.
  • exit-code: "1" tells Trivy to fail the pipeline (exit with code 1) if it finds vulnerabilities matching the severity filter.
  • ignore-unfixed: true skips vulnerabilities that have no available fix yet. This keeps the scan actionable.
  • severity: CRITICAL,HIGH only flags the most dangerous vulnerability levels. Lower-severity issues won't block your pipeline.

Why Trivy instead of Docker Scout?

Trivy is fully open source, requires no additional account or API key, and works out of the box as a GitHub Action. Docker Scout requires a Docker Hub account with specific plan features. Trivy is also the most widely used scanner in GitHub Actions workflows.

Add the build and push step

The Trivy scan is your gate. Now you need the step that runs after the gate passes. This step rebuilds the image and pushes it to GitHub Container Registry, but only on pushes to the main branch.

  • In ci.yml, add this final step below the Trivy scan step you just added:
      - name: Build and push to GHCR
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:latest,ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

What does this step do?

  • The if: condition ensures this step only runs on direct pushes to main. Pull requests trigger the build and scan, but never publish.
  • push: true sends the built image to the registry (unlike the earlier scan step which used load: true to keep it local).
  • Two tags are applied: latest (always points to the most recent build) and a unique SHA tag tied to the specific commit.
  • cache-from: type=gha and cache-to: type=gha,mode=max use GitHub Actions cache to speed up future builds by reusing unchanged layers.
  • Save ci.yml.

YAML indentation issues?

YAML is whitespace-sensitive. Each step must start with exactly 6 spaces of indentation (matching the other steps in the file). The with: block under each step uses 10 spaces. If you see a red underline in VS Code, count your spaces carefully.

Help me fix my YAML indentation in ci.yml

✔️ Awesome, I've got everything!

Great. Double check you've saved ci.yml before moving on.

ⓧ I'd like to double check the full code

name: CI/CD Pipeline

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

permissions:
  contents: read
  packages: write

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

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

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

      - name: Build Docker image for scanning
        uses: docker/build-push-action@v6
        with:
          context: .
          load: true
          tags: ${{ github.repository }}:scan

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

      - name: Build and push to GHCR
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:latest,ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
Push to GitHub and verify the full pipeline

Your workflow file now has the complete pipeline: build, scan, and publish. Time to push the changes and watch it run end to end.

  • Open your terminal in VS Code (press Ctrl+` on both macOS and Windows).
  • Commit and push your changes by running these commands:
git add .
git commit -m "Add Trivy scan and GHCR publish step"
git push
  • Open your repository on GitHub in your browser.
  • Click the Actions tab at the top of your repository page.
  • Click the latest workflow run (it should say Add Trivy scan and GHCR publish step or your commit message).
  • Click the build-scan-push job to see the individual steps.

You should see all steps complete with green checkmarks. The Run Trivy vulnerability scanner step shows a table of any vulnerabilities found (or a clean pass). The Build and push to GHCR step confirms your image was pushed to the registry.

  • Verify your published image by clicking Code tab, then look for Packages in the right sidebar of your repository page.
  • You should see your container image listed with the latest tag.

What just happened?

Your pipeline ran the full "build gate" pattern that production teams use. GitHub Actions built your multi-stage Docker image, Trivy scanned it for CRITICAL and HIGH vulnerabilities, and because the scan passed, the image was published to ghcr.io. If Trivy had found a serious vulnerability, the pipeline would have stopped and nothing would have been published.

Pipeline failed at the Trivy step?

This actually means Trivy is working correctly. It found a CRITICAL or HIGH vulnerability in your base image. Expand the Run Trivy vulnerability scanner step in the Actions log to see the table of findings. Since we use python:3.12-slim (a minimal image), this is uncommon but can happen if an upstream vulnerability was recently disclosed.

If you see failures, wait a day for the base image to be patched and re-run the workflow, or temporarily change severity: CRITICAL,HIGH to severity: CRITICAL to only block the most severe issues.

Help me understand why my Trivy scan is failing and what to do about the vulnerabilities it found

Pipeline failed at the push step?

Check that your repository is public. GitHub Container Registry storage is free for public repositories. For private repositories, make sure you have not exceeded the free storage tier.

Also verify your permissions: block at the top of the workflow file includes packages: write. Without this, the GITHUB_TOKEN cannot push images to the registry.

Help me debug why my Docker image push to GHCR is failing

Secret mission

Build Caching and a Second Endpoint

Your pipeline works. But in a real team, every second of CI time adds up and every misconfigured Dockerfile costs hours of debugging. Can you optimize your builds for speed and add Dockerfile linting to catch issues before they reach production?

Clean Up Your Resources

Clean Up Your Resources

This project uses GitHub Actions and GitHub Container Registry, both of which are free for public repositories. There are no ongoing costs to worry about.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Local project folder with app.py, Dockerfile, .dockerignore, requirements.txt, and .github/workflows/ci.yml.
  • Local Docker images built during the project.
  • GitHub repository containing your source code and workflow.
  • Container image published to GitHub Container Registry (ghcr.io).

Keep everything running

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

  • Your GitHub repository stays as-is. Future pushes to main will trigger the CI/CD pipeline automatically.
  • Your container image on ghcr.io remains available. Storage for public packages is currently free.
  • Local Docker images stay on your machine. They consume disk space but no ongoing compute.

Pause - I'll come back to this later

Free up local disk space while keeping your cloud resources intact for later.

  • Remove local Docker images to reclaim disk space by running:
docker image prune -a
  • Your GitHub repository and GHCR container image remain untouched. You can clone the repo and rebuild any time.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below.

  • Delete your local project folder.

macOS

rm -rf ~/Desktop/your-project-folder

Windows

Remove-Item -Recurse -Force ~\Desktop\your-project-folder
  • Remove all local Docker images created during the project by running:
docker image prune -a
  • Delete the container image from GitHub Container Registry.
  • Go to your GitHub profile page.
  • Click the Packages tab in the header.
  • Click the name of your container package.
  • On the package landing page, click Package settings on the right-hand side.
  • Scroll to the bottom. Under Danger zone, click Delete this package.
  • Type the package name to confirm, then click I understand the consequences, delete this package.
  • Delete the GitHub repository.
  • Navigate to your repository on GitHub.
  • Click Settings in the repository navigation bar.
  • Scroll down to the Danger Zone section.
  • Click Delete this repository.
  • Click I want to delete this repository, then click I have read and understand these effects.
  • Type the repository name to confirm, then click Delete this repository.

That's a Wrap!

That's a Wrap!

Nice work. You just built the same Docker + CI/CD pipeline that production DevOps teams use every day.

You've learned how to:

  • Containerized a Python Flask microservice with a multi-stage Dockerfile that reduces image size from ~1GB to ~150MB.
  • Created a GitHub Actions CI/CD pipeline that automatically builds, scans, and publishes your container image to GitHub Container Registry.
  • Integrated Trivy container security scanning as a pipeline gate that blocks vulnerable images from ever reaching your registry.
  • Secret Mission: Added build caching for faster CI rebuilds and Dockerfile linting with hadolint.

Ready to quiz yourself?