CI/CD Pipeline with Docker and GitHub

Automate testing, building, and publishing a Flask API with GitHub Actions.

Introduction

30 Second Summary

Every time a developer pushes code, there's a risk that something breaks. Professional teams solve this with automated pipelines that test, build, and deploy code without any manual steps.

In this project, you will build a complete CI/CD pipeline that automatically tests a Python API, packages it into a Docker container, and publishes it to a live container registry every time you push code.

What You'll Build

A GitHub Actions workflow that runs your test suite, builds a production Docker image, and pushes it to GitHub Container Registry on every push to main.

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

  • A Flask API with a health check endpoint and an automated test suite.
  • A production-grade Dockerfile using a non-root user, slim base image, and cache-optimized layers.
  • A fully automated CI/CD pipeline that tests, builds, and publishes your container image on every push.
  • Secret Mission: Add a multi-stage Docker build that uses tests as a build gate before producing the final image.

Are there any prerequisites?

You should be comfortable using a terminal and have a GitHub account. You'll also need Docker Desktop and Python installed on your machine. The first step walks you through verifying everything is ready.

Set Up Your Project

Every CI/CD pipeline needs a home. Your code, tests, Dockerfile, and workflow configuration all live in a GitHub repository. The repository is also where GitHub Actions detects and runs your pipeline automatically.

In this step, you will create a public GitHub repository, clone it to your machine, and set up a Python virtual environment with the project file structure. You will also verify that Docker is ready to go from your terminal.

In this step, get ready to:
  • Create a public GitHub repository and clone it locally.
  • Set up a Python virtual environment and create the project files.
  • Verify Docker is running from your terminal.
Create a new GitHub repository

Your pipeline will trigger on every push to this repository. Creating it as a public repo means GitHub Actions runs are free and unlimited, and your published container image will be visible on your profile.

  • In your browser, go to github.com/new.
  • Enter flask-cicd-pipeline in the Repository name field.
  • Select Public for the repository visibility.
  • Leave all checkboxes unchecked (no README, no .gitignore, no license).
  • Click Create repository.

Why skip the README?

You will be pushing code from your local machine. If GitHub creates a README on the remote, your first push will hit a merge conflict. Starting empty keeps things clean.

GitHub now shows a Quick Setup page with your repository URL. Keep this page open.

  • In VS Code, open the integrated terminal by pressing Cmd+` (macOS) or Ctrl+` (Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone your new repository and move into it by running these commands:
git clone https://github.com/[[GITHUB_USERNAME="your-github-username"]]/flask-cicd-pipeline.git
cd flask-cicd-pipeline

What does this command do?

  • `git clone` downloads the empty repository from GitHub to your machine, creating a flask-cicd-pipeline folder on your Desktop.
  • `cd flask-cicd-pipeline` moves your terminal into that folder so all future commands run inside the project.
  • Now open this folder in VS Code. Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the flask-cicd-pipeline folder, and click Open.

Trust the authors prompt

VS Code may ask if you trust the authors of this folder. Click Yes, I trust the authors to enable all editor features.

Set up a Python virtual environment

A virtual environment isolates your project's Python packages from the rest of your system. This ensures your local setup matches what the pipeline will install later.

  • Open a new terminal in VS Code by pressing Cmd+` (macOS) or Ctrl+` (Windows). It should already be inside the flask-cicd-pipeline folder.
  • Create and activate a virtual environment by running these commands:

macOS

python3 -m venv .venv
source .venv/bin/activate

Windows

python -m venv .venv
.venv\Scripts\activate

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

What is a virtual environment?

Think of it as a sealed box for your project's dependencies. Packages you install here stay separate from other Python projects on your machine. This prevents version conflicts.

  • Create the project file structure by running these commands:

macOS

touch app.py test_app.py requirements.txt Dockerfile
mkdir -p .github/workflows
touch .github/workflows/ci.yml

Windows

New-Item -ItemType File -Path app.py, test_app.py, requirements.txt, Dockerfile
New-Item -ItemType Directory -Path .github\workflows -Force
New-Item -ItemType File -Path .github\workflows\ci.yml

What are these files for?

  • `app.py` is your Flask API application code.
  • `test_app.py` holds the automated tests that become your quality gate.
  • `requirements.txt` lists the Python packages the project depends on.
  • `Dockerfile` tells Docker how to package your app into a container image.
  • `ci.yml` defines the GitHub Actions workflow that automates testing, building, and pushing.
  • Confirm the files exist by running:
ls

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

Don't see all the files?

  • Make sure your terminal is inside the flask-cicd-pipeline folder. Run pwd (macOS) or cd (Windows) to check your current directory.
  • On Windows, if New-Item printed errors, make sure you ran the commands in PowerShell, not Command Prompt.

Help me find my project files

Verify Docker is running

Docker Desktop is already running on your machine. This quick check confirms the Docker CLI can communicate with the Docker engine, which you will need for building container images in the next steps.

  • Check your Docker version by running this command:
docker --version
  • Verify the Docker engine is responding by running:
docker info

✔️ I see Docker version and server info

Docker is ready. You should see a version number from docker --version and server details (operating system, architecture, number of containers) from docker info.

ⓧ I see an error about connecting to the Docker daemon

This means Docker Desktop is not running or has not finished starting up.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Docker Desktop and press Enter to open it.
  • Wait about 30 seconds for Docker Desktop to fully start, then run docker info again.

Your project foundation is solid. You have a GitHub repository, a clean virtual environment, and Docker ready to build images. Next up, you will write the Flask API and its test suite.

Build the Flask API and Tests

Your project structure is in place and Docker is ready to go. Now it's time to build the actual application that your pipeline will test and deploy.

In this step, you'll create a Flask API with two endpoints, write automated tests using pytest, and run those tests locally to confirm everything works before containerizing.

In this step, get ready to:
  • Write a Flask API with a root endpoint and a health check endpoint.
  • Write automated tests that verify both endpoints.
  • Install dependencies and run tests locally to confirm they pass.
Write the Flask application

Your CI/CD pipeline needs something to test and deploy. You'll build a minimal API with two endpoints: one that returns a welcome message and one that reports the app's health status.

  • Open app.py in VS Code and add the following code:
from flask import Flask, jsonify

app = Flask(__name__)


@app.route("/")
def home():
    return jsonify({"message": "Welcome to the Flask CI/CD Pipeline API"})


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


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

What does this code do?

  • Flask(__name__) creates a new Flask application instance.
  • @app.route("/") registers the home endpoint, which returns a JSON welcome message.
  • @app.route("/health") registers the health check endpoint. Health endpoints are standard in production APIs so monitoring tools can verify the app is running.
  • The if __name__ == "__main__" guard runs the server on port 5000 when you execute the file directly. The host="0.0.0.0" makes the app accessible from outside a Docker container.
  • Save app.py.

You won't see any output yet because Flask isn't installed. The tests in the next substep will verify everything once we install dependencies.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

from flask import Flask, jsonify

app = Flask(__name__)


@app.route("/")
def home():
    return jsonify({"message": "Welcome to the Flask CI/CD Pipeline API"})


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


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
Write automated tests with pytest

Tests are the quality gate in your pipeline. If these tests fail, the pipeline stops and the broken code never gets deployed.

  • Open test_app.py in VS Code and add the following code:
import pytest
from app import app


@pytest.fixture
def client():
    app.config["TESTING"] = True
    with app.test_client() as client:
        yield client


def test_home(client):
    response = client.get("/")
    assert response.status_code == 200
    data = response.get_json()
    assert data["message"] == "Welcome to the Flask CI/CD Pipeline API"


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

What does this code do?

  • @pytest.fixture creates a reusable test client. Flask's built-in test client lets you simulate HTTP requests without starting an actual server.
  • app.config["TESTING"] = True enables test mode, which gives you better error messages when something fails.
  • Each test_ function hits an endpoint and asserts the response has the correct status code and JSON body. If any assertion fails, pytest reports the test as failed.
  • Save test_app.py.

These tests won't run until we install the dependencies in the next substep.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

import pytest
from app import app


@pytest.fixture
def client():
    app.config["TESTING"] = True
    with app.test_client() as client:
        yield client


def test_home(client):
    response = client.get("/")
    assert response.status_code == 200
    data = response.get_json()
    assert data["message"] == "Welcome to the Flask CI/CD Pipeline API"


def test_health(client):
    response = client.get("/health")
    assert response.status_code == 200
    data = response.get_json()
    assert data["status"] == "healthy"
Run tests locally

Before you containerize anything, you need to confirm the app and tests work on your machine. This catches issues early, before they hit the CI/CD pipeline.

  • Open requirements.txt in VS Code and add the following dependencies:
Flask==3.1.3
pytest==8.3.5

Why pin exact versions?

Pinning versions (Flask==3.1.3, pytest==8.3.5) ensures everyone who runs your code gets the same package versions. Without pins, a new release could introduce breaking changes and your pipeline would fail for reasons unrelated to your code.

  • Save requirements.txt.
  • Install the dependencies into your virtual environment by running this command:
pip install -r requirements.txt
  • You should see output showing Flask and pytest being downloaded and installed successfully.
  • Run your test suite by running this command:
pytest

You should see output showing 2 tests collected and 2 passed. Look for two dots (..) followed by 2 passed in the summary line.

Tests failing or command not found?

  • If you see ModuleNotFoundError: make sure your virtual environment is activated. You should see (.venv) at the start of your terminal prompt.
  • If you see command not found: pytest: run pip install -r requirements.txt again with your virtual environment activated.
  • If a test assertion fails, double check that the strings in test_app.py match the strings in app.py exactly.

help me debug my failing pytest tests

Your Flask API is built and tested locally. Next up, you'll wrap this application in a Docker container using production best practices.

Containerize with Docker Best Practices

Your Flask API works locally and your tests pass. Now the question is: how do you guarantee it runs the same way on every machine, every server, and every CI runner?

The answer is Docker. In this step, you'll package your app into a container image using production best practices like a slim base image, a non-root user, and cache-optimized layers.

In this step, get ready to:
  • Write a production Dockerfile with security and performance best practices.
  • Build and run the container locally to verify your API works inside Docker.
  • Add a .dockerignore file to keep the build context lean.
Write a production Dockerfile

A Dockerfile is a recipe that tells Docker exactly how to build your application image. You'll write one that follows Docker's official recommendations for Python apps.

  • Switch to your Dockerfile in VS Code (it should be empty from Step 1).
  • Add the base image, environment variables, working directory, and non-root user by pasting this code:
FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

WORKDIR /app

ARG UID=10001
RUN adduser \
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    appuser

What does this code do?

  • FROM python:3.12-slim starts from a minimal Python image. The slim variant strips out compilers and docs you don't need at runtime, cutting the image size significantly.
  • PYTHONDONTWRITEBYTECODE=1 stops Python from generating .pyc files inside the container. PYTHONUNBUFFERED=1 ensures log output appears immediately instead of being buffered.
  • The adduser block creates a dedicated user called appuser with no home directory, no password, and no shell access. This limits what an attacker can do if they exploit a vulnerability in your app.

You won't see a visible change yet. The next chunk completes the Dockerfile.

  • Below the appuser block you just pasted, add the dependency installation, user switch, and startup command:
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

USER appuser

COPY . .

EXPOSE 5000

CMD ["python", "app.py"]

What does this code do?

  • COPY requirements.txt . copies only the dependency file first. Because Docker caches each layer, your dependencies only reinstall when requirements.txt changes. This speeds up rebuilds dramatically.
  • USER appuser switches to the non-root user before copying app code. From this point on, the container runs without root privileges.
  • COPY . . copies the rest of your project files into the container.
  • CMD ["python", "app.py"] defines the default command that runs when the container starts.

Why a non-root user?

By default, containers run as root. If someone finds a way to break out of your app, they'd have full root access to the container's filesystem.

Running as a restricted user (appuser) means even a compromised process can't modify system files or install packages. This is a standard production security practice.

  • Save Dockerfile.

You won't see a change in the browser. The next substep builds and runs this image so you can verify it works.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

WORKDIR /app

ARG UID=10001
RUN adduser \
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    appuser

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

USER appuser

COPY . .

EXPOSE 5000

CMD ["python", "app.py"]
Build and run the container locally

With the Dockerfile written, you can now build an image from it and spin up a running container to confirm your API works inside Docker.

  • Build your Docker image by running this command in your terminal:
docker build -t flask-cicd-pipeline .

What does this command do?

  • docker build reads your Dockerfile and executes each instruction to produce an image.
  • -t flask-cicd-pipeline gives the image a human-readable name (a tag) so you can reference it later.
  • . tells Docker to use the current directory as the build context (the files Docker can access during the build).
  • Wait for the build to finish. You should see each layer being processed, ending with a confirmation that the image was exported successfully.

Build failing?

  • Make sure Docker Desktop is running. If you see "Cannot connect to the Docker daemon", open Docker Desktop and wait for it to fully start.
  • Check that your Dockerfile is saved and named exactly Dockerfile (capital D, no file extension).
  • If you see a permissions error on the adduser step, make sure the RUN adduser line appears before USER appuser.

Why is my Docker build failing?

  • Run your container by running this command:
docker run -p 5000:5000 flask-cicd-pipeline

What does this command do?

docker run creates and starts a container from your image. The -p 5000:5000 flag maps port 5000 on your machine to port 5000 inside the container. This lets you reach the Flask API at http://localhost:5000 just like you did when running locally.

  • Open your browser and navigate to http://localhost:5000/health.
  • You should see the JSON response {"status":"healthy"} displayed in your browser. Your Flask API is now running inside a Docker container.
  • Return to your terminal and press Ctrl+C to stop the container.

Not seeing a response in the browser?

  • Make sure the container is still running. If you pressed Ctrl+C too early, run the docker run command again.
  • Check that nothing else is using port 5000. If your local Flask server from the previous step is still running, stop it first.
  • Verify the URL is exactly http://localhost:5000/health (not https).

Why can't I reach my containerized Flask API?

Add a .dockerignore file

When Docker builds your image, it sends the entire directory (the build context) to the Docker daemon. Files like .git and .venv are large and unnecessary inside the container. A .dockerignore file tells Docker to skip them.

  • Create a new file called .dockerignore in your project root (the same folder as your Dockerfile).
  • Add the following exclusion rules:
.git
.github
__pycache__
*.pyc
.venv
.env
*.md

What does this file do?

Each line is a pattern that Docker will skip when copying files into the build context. This means your .git history, virtual environment, and GitHub Actions workflows never make it into the image.

The result is faster builds and smaller images. It also prevents sensitive files like .env from being accidentally baked into the container.

  • Save .dockerignore.
  • Confirm the file exists by running:
ls -a
  • You should see .dockerignore listed alongside your other project files.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file before moving on.

ⓧ I'd like to double check the full code

.git
.github
__pycache__
*.pyc
.venv
.env
*.md

Your Flask API is now containerized with production-grade security and performance settings. Next up, you'll create the GitHub Actions workflow that builds and pushes this image automatically on every push.

Create the GitHub Actions CI/CD Workflow

Your Flask API is containerized and running locally. Now it's time to automate the entire build-test-push cycle so it happens every time you push code.

In this step, you'll write a GitHub Actions workflow that runs your tests, builds your Docker image, and pushes it to GitHub Container Registry automatically on every push to main.

In this step, get ready to:
  • Write the workflow YAML with trigger and environment variables.
  • Add a test job that serves as an automated quality gate.
  • Add a build-and-push job that publishes your Docker image to GHCR.
Write the workflow YAML

Every GitHub Actions workflow lives in a YAML file inside the .github/workflows/ directory. The file defines what triggers the workflow, what environment variables are shared across jobs, and what jobs to run.

  • In VS Code, open the empty .github/workflows/ci.yml file.
  • Add the workflow header by pasting this code:
name: CI/CD Pipeline

on:
  push:
    branches: ["main"]

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

What does this code do?

The name field sets what appears in the Actions tab on GitHub.

The on: push: branches: ["main"] block tells GitHub to run this workflow every time someone pushes to the main branch.

The env block defines two shared variables. REGISTRY points to ghcr.io (GitHub Container Registry). IMAGE_NAME automatically resolves to your GitHub username and repo name.

  • Save ci.yml.

You won't see a visible change yet. The next chunk adds the test job that makes this workflow do real work.

Add the test job

The test job is your automated quality gate. If any test fails, the entire pipeline stops and the Docker image never gets built or published.

  • In ci.yml, add the following code below the env block:
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run tests
        run: pytest

What does this code do?

  • runs-on: ubuntu-latest tells GitHub to spin up a fresh Linux virtual machine for this job.
  • actions/checkout@v4 clones your repository code onto the runner so subsequent steps can access it.
  • actions/setup-python@v5 installs Python 3.12 on the runner.
  • The last two steps install your dependencies and run pytest. If any test fails, the job fails and the pipeline stops here.
  • Save ci.yml.

You won't see a visible change yet. The next chunk adds the build-and-push job that depends on this test job passing.

Seeing red squiggles in VS Code?

YAML is sensitive to indentation. Make sure you're using spaces (not tabs) and each nested level uses exactly 2 spaces. The jobs: key should be at the root level (zero indentation), and test: should be indented 2 spaces under it.

help me fix YAML indentation errors in my GitHub Actions workflow

Add the build-and-push job

This job handles the Docker side. It only runs after the test job passes, which is enforced by the needs: test field. It authenticates to GHCR, generates image tags, then builds and pushes the image.

  • In ci.yml, add the following code directly below the test job (at the same indentation level as test:):
  build-and-push:
    runs-on: ubuntu-latest
    needs: test
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

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

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

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

What does this code do?

  • needs: test creates a dependency chain. This job waits for the test job to pass before running.
  • permissions grants contents: read (to check out code) and packages: write (to push images to GHCR).
  • docker/login-action@v3 authenticates to GHCR using the built-in GITHUB_TOKEN secret. No extra credentials needed.
  • docker/metadata-action@v5 generates image tags (like main) and labels automatically from your repo's metadata.
  • docker/build-push-action@v6 builds the Docker image and pushes it to GHCR in one step.

Why does this use GITHUB_TOKEN instead of a personal access token?

GitHub automatically provides a GITHUB_TOKEN secret to every workflow run. It has just enough permissions to interact with the repository and its packages. You don't need to create or manage any tokens manually.

Publishing a package from a workflow using GITHUB_TOKEN also automatically links the container image to your repository on your GitHub profile.

  • Save ci.yml.

Your workflow file is now complete. The pipeline will run automatically when you push to GitHub in the next step.

Indentation looks off?

The build-and-push: key must be at the same indentation level as test: (2 spaces under jobs:). Each steps: item starts with a dash at 6 spaces of indentation. The with: block under each action is indented 2 more spaces beyond uses:.

help me verify my ci.yml indentation is correct

✔️ Awesome, I've got everything!

Great. Double check you've saved ci.yml and you're ready to push to GitHub.

ⓧ I'd like to double check the full code

Here's the complete .github/workflows/ci.yml file. Compare your file against this:

name: CI/CD Pipeline

on:
  push:
    branches: ["main"]

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

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run tests
        run: pytest

  build-and-push:
    runs-on: ubuntu-latest
    needs: test
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

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

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

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

Your pipeline is written and ready to go. Next up, you'll push everything to GitHub and watch the workflow run live in the Actions tab.

Push and Watch Your Pipeline Run

You have a complete Flask API, a production Dockerfile, and a CI/CD workflow file ready to go. The only thing left is to push everything to GitHub and let the automation take over.

When your code hits the main branch, GitHub Actions will detect the workflow file and execute your pipeline. Tests run first. If they pass, the image gets built and pushed to GitHub Container Registry. All without you lifting a finger.

In this step, get ready to:
  • Commit and push your code to GitHub.
  • Verify the published Docker image on GitHub Container Registry.
  • Trigger a second pipeline run with a code change.
Commit and push to GitHub

Your project files are all saved locally but not yet tracked by Git. You need to stage, commit, and push them to your remote repository so the workflow triggers.

  • Stage all your project files by running this command:
git add .
  • Commit the staged files with a descriptive message by running:
git commit -m "Add Flask API with Docker and CI/CD pipeline"
  • Push the commit to the main branch on GitHub by running:
git push -u origin main

What do these commands do?

  • git add . stages every file in the project folder for the next commit.
  • git commit -m saves a snapshot of your staged files with a message describing the change.
  • git push -u origin main sends your local commits to the remote repository on GitHub and sets main as the tracking branch.

The moment the push completes, GitHub Actions detects the workflow file and starts your pipeline.

  • Open your repository on GitHub in your browser.
  • Under your repository name, click Actions.
  • In the left sidebar, click CI/CD Pipeline (the workflow name).
  • Click the name of the run (your commit message) to watch it execute in real time.

You should see two jobs: test and build-and-push. The test job runs first, and build-and-push only starts after test passes.

Don't see the Actions tab?

Make sure your repository is set to Public. GitHub Actions is free and unlimited for public repos. If your repo is private, check that Actions is enabled under Settings > Actions > General.

If the push itself failed, verify your remote URL matches the repository you created. Run git remote -v to check.

Help me troubleshoot my GitHub Actions workflow not running.

Verify the published image

Once both jobs show a green checkmark, your Docker image is live on GitHub Container Registry. Time to confirm it is visible and pullable.

  • Wait for the workflow to complete. Both the test and build-and-push jobs should show green checkmarks.
  • Navigate back to the main page of your repository.
  • In the right sidebar, click Packages.
  • Click the name of the package (flask-cicd-pipeline) to view its details.

You should see your published container image with a main tag listed.

  • Verify you can pull the image locally by running:
docker pull ghcr.io/[[GITHUB_USERNAME="your-github-username"]]/flask-cicd-pipeline:main

What does this prove?

Pulling the image confirms that GitHub Actions successfully built your Docker image and pushed it to ghcr.io. Anyone with access to your public repo can now pull and run your containerized API with a single command.

The image is also automatically linked to your repository because you published it using GITHUB_TOKEN from a workflow. It shows up on your GitHub profile under Packages.

  • Confirm the pull succeeded. You should see Docker downloading image layers and reporting a successful pull.

Pull command failing?

If you see an authorization error, your repository might be set to private. Go to your repository Settings and confirm it is set to Public. Public GHCR packages can be pulled anonymously without authentication.

If the image tag is not found, wait a minute for the workflow to finish. Check the Actions tab to confirm both jobs completed with green checkmarks.

Help me pull my Docker image from GitHub Container Registry.

Trigger a pipeline run with a code change

The real power of CI/CD is the feedback loop. Every push automatically tests, builds, and publishes. Let's prove it by making a small change and watching the pipeline run again.

  • Return to VS Code.
  • Open app.py.
  • Find the home() function and change the welcome message to something different. For example, update Welcome to the Flask CI/CD Pipeline API to Welcome to the Flask CI/CD Pipeline API v2.
  • Update the matching assertion in test_app.py so the test still passes with your new message.
  • Save both files.
  • Commit and push the change by running:
git add .
git commit -m "Update welcome message"
git push
  • Switch back to the Actions tab in your browser.
  • Watch the new pipeline run appear and execute both jobs again.

Both jobs should complete with green checkmarks. Your updated image is now published to GHCR with the latest changes baked in.

What is the CI/CD feedback loop?

Every code change triggers the same pipeline: test, build, push. If you break something, the test job catches it before the bad code gets published. If everything passes, a fresh image is available within minutes.

This is exactly how production teams ship code. Developers push changes, automation handles the rest. No manual testing, no manual Docker builds, no manual pushes to a registry.

Second pipeline run failing?

If the test job fails, you likely updated the welcome message in app.py but forgot to update the assertion in test_app.py. The test checks for an exact string match. Update the test to match your new message, commit, and push again.

Help me fix my failing CI/CD pipeline test job.

Secret mission

Add a Multi-Stage Docker Build That Uses Tests as a Build Gate

Your CI pipeline already runs tests before building. But what if someone builds the Docker image locally without pushing to GitHub? Right now, nothing stops a broken image from being created on a developer's machine. Refactor your Dockerfile into a multi-stage build where the first stage runs your test suite. If tests fail, the build fails. No broken images, ever.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses free GitHub services (GitHub Actions for public repos, GitHub Container Registry for public packages). There are no ongoing costs.

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

Resources you used:

  • Local project folder (flask-cicd-pipeline on your Desktop).
  • GitHub repository (flask-cicd-pipeline).
  • Docker image published to GitHub Container Registry.
  • Local Docker images built during development.
  • Python virtual environment (inside the project folder).

Keep everything running

No action needed. Choose this if you're still actively building or want to keep the pipeline available for future pushes.

  • Your GitHub Actions workflow will run automatically on every push to main.
  • Your Docker image on GHCR remains publicly pullable.
  • Local Docker images stay cached on your machine (they use disk space but no CPU or memory when not running).

Pause - I'll come back to this later

Shut down running containers and free up local resources while keeping everything intact for later.

  • Stop any running containers by running:
  • Your GitHub repo and GHCR image stay as-is. No ongoing costs.
docker stop $(docker ps -q)
  • Deactivate your Python virtual environment by running:
deactivate

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent.

  • Delete local Docker images by running:
docker rmi flask-cicd-pipeline
docker image prune -f
  • Delete the GHCR package from GitHub:
  • Open your flask-cicd-pipeline repository on GitHub.
  • In the right sidebar, click Packages.
  • Click the package name.
  • Click Package settings on the right-hand side.
  • Scroll to the bottom and click Delete this package under the Danger Zone.
  • Type the package name and click I understand, delete this package.
  • Delete the GitHub repository:
  • On the repository page, click Settings.
  • Scroll down to the Danger Zone section.
  • Click Delete this repository.
  • Click I want to delete this repository.
  • Click I have read and understand these effects.
  • Type the repository name to confirm, then click Delete this repository.
  • Delete the local project folder:

macOS

rm -rf ~/Desktop/flask-cicd-pipeline

Windows

rmdir /s /q %USERPROFILE%\Desktop\flask-cicd-pipeline

Nice Work!

Nice Work!

You just built a fully automated DevOps pipeline from scratch. Every time you push code, your pipeline tests it, builds a production Docker image, and publishes it to a live container registry on your GitHub profile.

You've learned how to:

  • Build a Flask API with automated pytest tests that serve as a quality gate in your pipeline.
  • Write a production-grade Dockerfile with security best practices including a non-root user, slim base image, and cache-optimized layers.
  • Create a GitHub Actions workflow that automatically tests, builds, and publishes your container image to GitHub Container Registry.
  • Implement a multi-stage Docker build that enforces test passing at build time.

Ready to quiz yourself?