CI/CD Pipeline for a Dockerized API

Build a GitHub Actions pipeline that lints, tests, and ships a Docker image.

Introduction

30 Second Summary

Every time you update an app on your phone, someone had to package that code, test it, and ship it to you. In professional software teams, this entire process happens automatically through pipelines that build, test, and deliver code without human intervention.

In this project, you will build a Python API, package it inside a Docker container, and create a GitHub Actions pipeline that automatically tests and publishes your container every time you push code.

What You'll Build

A Python Flask health-check API containerized with Docker, paired with a fully automated CI/CD pipeline that lints, tests, builds, and pushes your Docker image to Docker Hub on every code push.

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

  • A working Python Flask API running inside a Docker container.
  • A GitHub Actions workflow that automatically lints, tests, builds, and pushes your Docker image on every push to main.
  • A Docker Hub repository with your automatically-pushed container image, proving you can ship containers through automated pipelines.
  • Secret Mission: Add a /metrics endpoint and integrate container security scanning with Trivy into your pipeline.

Are there any prerequisites?

No prior Docker or CI/CD experience is needed. This project walks you through installing and setting up every tool from scratch, including Docker Desktop, Python, and your Docker Hub account.

Install Docker Desktop and Set Up Accounts

Before you can build containers and automate pipelines, you need the right tools on your machine. Docker Desktop lets you build and run containers locally. Python powers the API you will containerize.

In this step, you will install Docker Desktop, Python, and create a Docker Hub account where your pipeline will automatically push images.

In this step, get ready to:
  • Install Docker Desktop and verify it is running.
  • Install Python 3.12+ and verify it is on your PATH.
  • Create a Docker Hub account and a public repository for your image.
Install Docker Desktop

Docker Desktop is the application that lets you build and run containers on your local machine. The installation process differs between macOS and Windows.

What is Docker Desktop?

Docker Desktop bundles the Docker engine, CLI, and a graphical dashboard into a single installer. It handles all the low-level setup so you can focus on writing Dockerfiles and running containers.

macOS

  • Open the Docker Desktop for Mac download page:
  • https://docs.docker.com/desktop/setup/install/mac-install/
  • Click the download button for your chip (Apple Silicon or Intel).
  • Double-click the downloaded Docker.dmg file to open the installer.
  • Drag the Docker icon to your Applications folder.
  • Open Docker.app from your Applications folder.
  • Accept the Docker Subscription Service Agreement when prompted.
  • Wait for the Docker engine to start. You will see a green "running" indicator in the Docker Desktop dashboard.

Windows

Docker Desktop on Windows requires WSL 2 (Windows Subsystem for Linux version 2) as its backend. You need to enable WSL 2 before installing Docker Desktop.

  • Open PowerShell as Administrator by right-clicking the Start menu and selecting Terminal (Admin).
  • Install WSL by running this command:
wsl --install

What does this command do?

This enables WSL 2 and installs a default Linux distribution. If prompted, restart your computer to complete the installation.

  • After restarting (if needed), verify WSL is ready by running:
wsl --version

You should see a WSL version number of 2.1.5 or later in the output.

WSL version too old?

If your WSL version is older than 2.1.5, update it by running:

wsl --update

Then run wsl --version again to confirm. Help me fix my WSL version

  • Open the Docker Desktop for Windows download page:
  • https://docs.docker.com/desktop/setup/install/windows-install/
  • Download the installer and run it.
  • Choose the per-user installation option (recommended, no admin required).
  • Follow the installer prompts to complete the installation.
  • Open Docker Desktop from the Start menu after installation finishes.
  • Wait for the Docker engine to start. You will see a green "running" indicator in the Docker Desktop dashboard.

With Docker Desktop running, verify it is accessible from your terminal.

  • Open your terminal and check the Docker version by running:
docker --version

✔️ I see a version number

Docker is installed and running. You are ready to move on.

ⓧ Command not found or Docker not running

  • Make sure Docker Desktop is open and the engine is running (green indicator in the dashboard).
  • On Windows, close and reopen your terminal after installing Docker Desktop so it picks up the new PATH entries.
  • On macOS, ensure you dragged Docker to the Applications folder and launched it at least once.
  • Try running docker --version again after Docker Desktop shows the green running indicator.

Help me troubleshoot Docker Desktop installation

Install Python 3.12+

Your Flask API requires Python 3.12 or higher. The official python.org installer is the simplest way to get the correct version.

macOS

  • Open the Python downloads page:
  • https://www.python.org/downloads/
  • Click the Download Python 3.12.x button (the latest 3.12 release).
  • Double-click the downloaded .pkg file to open the macOS installer.
  • Follow the installer prompts, clicking Continue and then Install.
  • After installation, open the Install Certificates.command file from the /Applications/Python 3.12/ folder to set up SSL certificates.

Windows

  • Open the Python downloads page:
  • https://www.python.org/downloads/
  • Click the Download Python 3.12.x button (the latest 3.12 release).
  • Run the downloaded installer.
  • Check the Add python.exe to PATH checkbox at the bottom of the first installer screen. This is critical.
  • Click Install Now and wait for the installation to complete.

Now verify Python is installed correctly. Open a new terminal window (important for PATH changes to take effect).

macOS

  • Check the Python version by running:
python3 --version
  • Check pip by running:
pip3 --version

Windows

  • Check the Python version by running:
python --version
  • Check pip by running:
pip --version

✔️ I see version 3.12 or higher

Python is installed and ready. Move on to the next substep.

ⓧ I see an older version

Your system has an older Python that is taking precedence over the one you just installed.

  • On macOS, the system ships its own python3 in /usr/bin/python3. The python.org installer places the new version at /usr/local/bin/python3. Open a fresh terminal and run python3 --version again. If it still shows the old version, run /usr/local/bin/python3 --version to confirm the new install exists, then add /usr/local/bin to the front of your PATH.
  • On Windows, uncheck any older Python versions from your system PATH in Environment Variables settings, or uninstall the older Python from Apps & features.

Help me fix my Python version

ⓧ Command not found

Python was not installed, or your terminal does not have it on PATH.

  • On Windows, re-run the installer and make sure you check Add python.exe to PATH on the first screen. Then close and reopen your terminal.
  • On macOS, re-download and run the .pkg installer from https://www.python.org/downloads/. After installation, open a new terminal and try again.

Help me install Python 3.12

Create a Docker Hub account and repository

Docker Hub is a cloud registry where you store and share container images. Your CI/CD pipeline will push images here automatically on every code push.

What is Docker Hub?

Docker Hub is the default public registry for Docker images. It stores your built images so they can be pulled and run on any machine with Docker installed.

The Personal (free) tier includes unlimited public repositories and 200 image pulls per 6 hours. This is more than enough for this project.

  • Open the Docker Hub sign-up page:
  • https://hub.docker.com/signup
  • Create a free account with your email address and a username you will remember.
  • Verify your email if prompted.
  • After signing in to Docker Hub, click Create repository on the dashboard.
  • Enter devops-api as the repository name.
  • Set the visibility to Public.
  • Click Create.

Now verify you can log in to Docker Hub from your terminal.

  • Log in to Docker Hub from your terminal by running:
docker login

What does this command do?

This prompts you for your Docker Hub username and password. Once authenticated, Docker stores a token locally so you can push images without logging in again.

  • Enter your Docker Hub username when prompted.
  • Enter your Docker Hub password (or a Personal Access Token if you have 2FA enabled) when prompted.

You should see Login Succeeded in the terminal output.

Login failed?

Make sure Docker Desktop is running before you attempt docker login. The Docker CLI needs the Docker engine to be active.

If you use two-factor authentication on Docker Hub, you need to generate a Personal Access Token from Account Settings > Security > New Access Token and use that instead of your password.

Help me log in to Docker Hub from the terminal

Your local environment is ready to go. Next up, you will build the Flask API that your pipeline will containerize and deploy.

Build the Flask API

Your development environment is ready with Docker Desktop running, Python installed, and a Docker Hub repository waiting for images. Now it's time to build the actual application that your CI/CD pipeline will deploy.

In this step, you'll create a Python Flask API with two endpoints that return JSON responses. Flask is a lightweight web framework that lets you build APIs without unnecessary complexity.

In this step, get ready to:
  • Create a project folder with a Python virtual environment and Flask API.
  • Write automated tests and verify they pass locally.
  • Run the API and confirm the endpoints respond correctly.
Create the project folder and Flask API

Every Python project should live in its own folder with its own virtual environment. A virtual environment isolates your project's packages from the rest of your system.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir devops-api
cd devops-api
  • Confirm the folder exists by running:
ls

You should see an empty directory (no output) since you just created it.

  • Create a Python virtual environment by running this command:

macOS

python3 -m venv .venv
  • Activate the virtual environment by running:
source .venv/bin/activate

Windows

python -m venv .venv
  • Activate the virtual environment by running:
.venv\Scripts\activate

Your terminal prompt now shows (.venv) at the beginning, confirming the virtual environment is active.

What is a virtual environment?

A virtual environment creates an isolated space for your project's Python packages. Without one, installing Flask here could conflict with packages in other projects on your machine.

Not seeing (.venv) in your prompt?

On Windows PowerShell, you may need to allow script execution first. Run Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser and then try the activate command again.

On macOS, make sure you used source before the path. help me activate my Python virtual environment

Now you'll create the Flask application file. This API will have two endpoints that the CI/CD pipeline will eventually test and deploy.

  • Open the devops-api folder in VS Code by running:
code .
  • In VS Code, click File in the top menu bar.
  • Click New File.
  • Name the file app.py and press Enter.
  • Paste the following code into app.py:
import time
from flask import Flask, jsonify

# Create the Flask application
app = Flask(__name__)
start_time = time.time()


# Root endpoint returns a welcome message
@app.route("/")
def home():
    return jsonify({"message": "Welcome to the DevOps API", "version": "1.0.0"})


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


# Start the server on all interfaces, port 5000
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

What does this code do?

This file creates a Flask web application with two endpoints:

  • The / route returns a welcome message and API version number as JSON.
  • The /health route returns a health check with the current timestamp, which monitoring tools use to verify the API is alive.
  • The if __name__ == "__main__" block starts the server on port 5000, listening on all network interfaces (0.0.0.0). This is important for Docker containers later.
  • Save app.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).

VS Code not opening from the terminal?

If code . doesn't work, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar. Type VS Code and press Enter.

Then use File then Open Folder and select the devops-api folder on your Desktop. help me open my devops-api project in VS Code

Write tests and install dependencies

Automated tests verify your API works correctly before the pipeline deploys it. You'll write tests first, then install the packages needed to run them.

  • Create a new file in VS Code called requirements.txt and paste the following:
Flask==3.1.3
pytest==8.3.4
flake8==7.1.1

What does this file do?

This file pins exact package versions so every environment (your machine, CI/CD, Docker) installs identical dependencies. Flask is the web framework, pytest runs your tests, and flake8 checks code style.

  • Save requirements.txt.
  • Install the dependencies by running this command (make sure you see (.venv) in the prompt):
pip install -r requirements.txt

You should see output showing Flask, pytest, and flake8 being downloaded and installed.

Seeing permission errors or pip not found?

Make sure your virtual environment is active. You should see (.venv) at the start of your terminal prompt. If not, run the activation command from the previous substep again.

help me install my Python dependencies with pip

Now create the test file that verifies both endpoints return the correct responses.

  • Create a new file in VS Code called test_app.py and paste the following:
import pytest
from app import app


# Create a test client that simulates HTTP requests
@pytest.fixture
def client():
    app.config["TESTING"] = True
    with app.test_client() as client:
        yield client


# Verify the home endpoint returns the correct message and version
def test_home(client):
    response = client.get("/")
    assert response.status_code == 200
    data = response.get_json()
    assert data["message"] == "Welcome to the DevOps API"
    assert data["version"] == "1.0.0"


# Verify the health endpoint returns status and a timestamp
def test_health(client):
    response = client.get("/health")
    assert response.status_code == 200
    data = response.get_json()
    assert data["status"] == "healthy"
    assert "timestamp" in data

What does this test file do?

The client fixture creates a test version of your Flask app so tests can send requests without starting a real server.

Each test function sends a GET request to an endpoint and checks the response. test_home verifies the welcome message and version. test_health verifies the health status and that a timestamp is present.

  • Save test_app.py.
  • Run your tests by executing this command:
pytest test_app.py -v

You should see output showing both tests passing with green PASSED markers.

Tests failing?

If you see ModuleNotFoundError: No module named 'flask', your virtual environment may not be active or the install didn't complete. Activate it again and re-run pip install -r requirements.txt.

If you see assertion errors, double-check that app.py matches the code above exactly. help me fix my failing pytest tests

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files.

ⓧ I'd like to double check the full code

Here is the full app.py:

import time
from flask import Flask, jsonify

app = Flask(__name__)
start_time = time.time()


@app.route("/")
def home():
    return jsonify({"message": "Welcome to the DevOps API", "version": "1.0.0"})


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


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

Here is the full requirements.txt:

Flask==3.1.3
pytest==8.3.4
flake8==7.1.1

Here is the full test_app.py:

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 DevOps API"
    assert data["version"] == "1.0.0"


def test_health(client):
    response = client.get("/health")
    assert response.status_code == 200
    data = response.get_json()
    assert data["status"] == "healthy"
    assert "timestamp" in data
Run the API locally and test the endpoints

Tests confirm the logic is correct, but you should also run the real server to see it respond to actual HTTP requests.

  • Start the Flask development server by running:
python app.py

You should see output indicating the server is running on http://127.0.0.1:5000.

  • Open a second terminal in VS Code by clicking Terminal in the top menu, then New Terminal.
  • Test the health endpoint by running:
curl http://127.0.0.1:5000/health

You should see a JSON response like:

{"status":"healthy","timestamp":1718035200.123}

What should I see?

The exact timestamp value will be different on your machine since it reflects the current time. The important parts are that status is healthy and a timestamp number is present.

  • Stop the Flask server by pressing Ctrl+C in the first terminal (the one running python app.py).

Seeing "Connection refused" from curl?

Make sure the Flask server is still running in your first terminal. You need two terminals open: one running the server, and a second one for curl.

If you're on Windows and curl isn't recognized, try opening your browser and navigating to http://127.0.0.1:5000/health instead. help me test my Flask API endpoint

Your Flask API is built, tested, and running locally. Next up, you'll package it inside a Docker container so it can run identically on any machine.

Containerize with Docker

Your Flask API works locally, but it depends on your machine's Python version, installed packages, and OS quirks. Docker solves this by packaging your app and all its dependencies into a single, portable unit called a container.

In this step, you will write a Dockerfile that tells Docker exactly how to build your app's image. Then you will run that image as a container and confirm the API responds.

In this step, get ready to:
  • Write a multi-stage Dockerfile for the Flask API.
  • Build and run the Docker image locally.
  • Create a .dockerignore file to keep the image lean.
Create the Dockerfile

A multi-stage build uses one stage to install dependencies and a second stage to copy only what the app needs at runtime. This keeps the final image small and free of build tools.

  • Create a new file called Dockerfile (no file extension) in the same folder as app.py.
  • Add the following multi-stage build configuration:
FROM python:3.12-slim AS builder

WORKDIR /app

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

FROM python:3.12-slim

WORKDIR /app

COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY app.py .

EXPOSE 5000

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

What does this Dockerfile do?

  • The first FROM line creates a "builder" stage that installs all Python packages from requirements.txt.
  • The second FROM line starts a fresh, clean image. It copies only the installed packages and your application code from the builder.
  • EXPOSE 5000 documents which port the container listens on.
  • CMD sets the default command that runs when the container starts.
  • Save Dockerfile.
  • You won't see a visible change yet. The next substep builds this file into a runnable image.

Dockerfile not being recognized?

Make sure the file is named exactly Dockerfile with a capital D and no extension. Some editors add .txt automatically.

Help me check my Dockerfile name and location

Why a multi-stage build?

The builder stage pulls in pip caches and compilation tools that the running app never needs. By starting a second stage from a clean python:3.12-slim image, you discard all that overhead.

The result is a smaller, more secure image with fewer unnecessary packages that could introduce vulnerabilities.

✔️ Awesome, I've got everything!

Great. Double check you have saved Dockerfile in the same folder as app.py.

ⓧ I'd like to double check the full code

FROM python:3.12-slim AS builder

WORKDIR /app

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

FROM python:3.12-slim

WORKDIR /app

COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY app.py .

EXPOSE 5000

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

With the Dockerfile saved, you can now turn it into a runnable image and launch a container from it.

  • Build the Docker image by running this command:
docker build -t devops-api:latest .

What does this command do?

  • -t devops-api:latest tags the image with the name devops-api and the tag latest.
  • The . at the end tells Docker to look for the Dockerfile in the current directory.
  • You should see Docker processing each stage of your Dockerfile. The build finishes when your terminal prompt returns.
  • Verify the image was created by running:
docker images devops-api
  • You should see a table with your devops-api image listed, showing the latest tag and its size.

Build failing with an error?

  • Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your system tray.
  • Confirm you are in the same folder as your Dockerfile when running the command.

Help me debug my Docker build error

  • Run your new image as a container by running this command:
docker run -d -p 5000:5000 devops-api:latest

What do these flags mean?

  • -d runs the container in detached mode (in the background).
  • -p 5000:5000 maps port 5000 on your machine to port 5000 inside the container.
  • Test the API running inside the container by running:
curl http://127.0.0.1:5000/health
  • You should see a JSON response like {"status":"healthy","timestamp":...}. This confirms your app is running inside a Docker container.

Connection refused or empty reply?

  • If port 5000 is already in use from running python app.py earlier, stop that process first with Ctrl+C in its terminal.
  • Check that the container is actually running with docker ps. If it is not listed, run docker logs followed by the container ID from docker ps -a to see what went wrong.

Help me fix my Docker container connection issue

Create the .dockerignore file

Just like .gitignore keeps files out of Git, a .dockerignore file tells Docker which files to skip when copying your project into the image. This makes builds faster and the image smaller.

  • Create a new file called .dockerignore in the same folder as app.py.
  • Add the following exclusions:
.git
__pycache__
*.pyc
.venv
.github
test_app.py
README.md

Why exclude these files?

  • .git and __pycache__ add bulk without any runtime value.
  • .venv is your local virtual environment, which the container replaces with its own installed packages.
  • test_app.py and .github are for development and CI, not for the production container.
  • Save .dockerignore.
  • Stop the running container, then rebuild and check the image size by running these commands:
docker stop $(docker ps -q --filter ancestor=devops-api:latest)
docker build -t devops-api:latest .
docker images devops-api
  • You should see the devops-api image in the table. The size should be well under 200 MB thanks to the slim base image and the excluded files.

Docker stop command failing?

  • If the subshell command fails, run docker ps to see your running containers, then copy the container ID and run docker stop followed by that ID manually.

Help me stop and rebuild my Docker container

✔️ Awesome, I've got everything!

Great. Confirm you have saved .dockerignore and that your rebuilt image appears in the docker images output.

ⓧ I'd like to double check the full code

.git
__pycache__
*.pyc
.venv
.github
test_app.py
README.md

Your API now runs inside a portable Docker container that works identically on any machine. Next up, you will automate building and pushing this image with a GitHub Actions CI/CD pipeline.

Create the GitHub Actions CI/CD Pipeline

Your Docker image is built and running locally. The next goal is to automate the entire build-test-push cycle so it happens every time you push code.

GitHub Actions is a CI/CD platform built into GitHub. You define a workflow file in your repository, and GitHub runs it automatically on every push. In this step, you will write that workflow file and connect it to Docker Hub so your image gets published without any manual commands.

In this step, get ready to:
  • Initialize a Git repository and create a CI/CD workflow file.
  • Add Docker build and push steps to the pipeline.
  • Configure GitHub repository secrets for Docker Hub authentication.
Initialize a Git repository and create the workflow file

GitHub Actions workflows live inside a special folder in your repository called .github/workflows. Any YAML file in that folder becomes a workflow that GitHub can run.

  • Initialize a Git repository in your project folder by running this command:
git init
  • Create the workflow directory structure by running:
mkdir -p .github/workflows

What does mkdir -p do?

The -p flag creates parent directories if they don't already exist. This single command creates both the .github folder and the workflows folder inside it.

  • Create the workflow file by running:
touch .github/workflows/ci.yml
  • Confirm the file was created by running:
ls .github/workflows

You should see ci.yml in the output.

  • Open ci.yml in VS Code and paste the following workflow configuration:
name: CI/CD Pipeline

on:
  push:
    branches: [main]

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

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

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

      - name: Lint with flake8
        run: flake8 app.py --max-line-length=120

      - name: Run tests
        run: pytest test_app.py -v

What does this workflow do?

This workflow triggers every time you push code to the main branch. It runs on an ubuntu-latest virtual machine provided by GitHub.

  • Checkout code pulls your repository's files onto the runner so later steps can access them.
  • Set up Python installs Python 3.12 on the runner.
  • Install dependencies installs Flask, pytest, and flake8 from your requirements.txt.
  • Lint with flake8 checks your code for style issues, allowing lines up to 120 characters.
  • Run tests executes your pytest test suite. If any test fails, the pipeline stops.
  • Save .github/workflows/ci.yml.

YAML indentation errors?

YAML is sensitive to indentation. Every level must use exactly 2 spaces. If you see red squiggly lines in VS Code, check that your steps are indented with spaces, not tabs.

Help me fix YAML indentation issues in my ci.yml workflow file.

Add Docker build and push steps

The lint and test steps validate your code. Now you need steps that build the Docker image and push it to Docker Hub when tests pass.

  • In ci.yml, add the following lines directly below the Run tests step (at the same indentation level as the other - name: entries):
      - name: Login to Docker Hub
        uses: docker/login-action@v4
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ${{ secrets.DOCKERHUB_USERNAME }}/devops-api:${{ github.sha }}

What do these steps do?

docker/login-action@v4 authenticates to Docker Hub using secrets you will configure in the next substep. The workflow never sees your credentials in plain text.

docker/build-push-action@v7 builds your Dockerfile and pushes the resulting image. The image is tagged with the git commit SHA (${{ github.sha }}), so every push produces a uniquely traceable image.

  • Save .github/workflows/ci.yml.

Not sure where to paste?

The new lines go immediately after run: pytest test_app.py -v. Make sure each - name: starts at column 7 (6 spaces of indentation), matching the other steps above.

Help me position the Docker steps correctly in my ci.yml file.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file.

ⓧ I'd like to double check the full code

Here is the complete ci.yml file:

name: CI/CD Pipeline

on:
  push:
    branches: [main]

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

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

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

      - name: Lint with flake8
        run: flake8 app.py --max-line-length=120

      - name: Run tests
        run: pytest test_app.py -v

      - name: Login to Docker Hub
        uses: docker/login-action@v4
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ${{ secrets.DOCKERHUB_USERNAME }}/devops-api:${{ github.sha }}
Add secrets to your GitHub repository

The workflow references secrets.DOCKERHUB_USERNAME and secrets.DOCKERHUB_TOKEN. These are encrypted values stored in your GitHub repository settings that GitHub Actions injects at runtime.

First you need a GitHub repository to push your code to, then you will add the secrets.

  • Open your browser and go to github.com/new.
  • Enter devops-api as the repository name.
  • Select **Public** visibility.
  • Leave all checkboxes unchecked (no README, no .gitignore, no license).
  • Click Create repository.

Why no README or .gitignore?

You already have a local Git repository with files in it. Adding a README on GitHub would create a conflicting commit history that requires a merge before you can push.

  • Back in your terminal, link your local repository to GitHub by running:
git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/devops-api.git

Now generate a Docker Hub access token so the pipeline can authenticate.

  • Open hub.docker.com in your browser and sign in.
  • Click your avatar in the top-right corner and select Account settings.
  • Select Personal access tokens from the left sidebar.
  • Click Generate new token.
  • Enter github-actions as the description.
  • Set the access permissions to Read & Write.
  • Click Generate.
  • Copy the token immediately and save it somewhere safe. You will not be able to see it again after leaving this page.

Copy your token now

Docker Hub only shows the token value once. If you navigate away without copying it, you will need to delete this token and generate a new one.

Now add both secrets to your GitHub repository.

  • Go to your devops-api repository on GitHub.
  • Click Settings under the repository name.
  • In the left sidebar under Security, select Secrets and variables, then click Actions.
  • Click the Secrets tab.
  • Click New repository secret.
  • In the Name field, enter DOCKERHUB_USERNAME.
  • In the Secret field, enter your Docker Hub username.
  • Click Add secret.
  • Click New repository secret again.
  • In the Name field, enter DOCKERHUB_TOKEN.
  • In the Secret field, paste the Docker Hub access token you just generated.
  • Click Add secret.

Why use secrets instead of hardcoding credentials?

GitHub encrypts repository secrets and only exposes them to workflow runs. Your Docker Hub token never appears in your code or logs. This prevents accidental credential leaks if you share or publish your repository.

  • Verify you see both DOCKERHUB_USERNAME and DOCKERHUB_TOKEN listed on the Actions secrets page.

Don't see the Settings tab?

The Settings tab only appears for repository owners. Make sure you are viewing the repository you created under your own GitHub account.

Help me find the Settings tab on my GitHub repository.

Your pipeline is fully configured. Next up, you will push your code to GitHub and watch the pipeline run through every stage automatically.

Push and Verify the Pipeline

Your code is ready, your secrets are configured, and your pipeline is waiting. All that is left is to push everything to GitHub and let the automation take over.

In this step, you will trigger the pipeline by pushing your code, watch it run through every stage, then pull the published image from Docker Hub to prove the full loop works.

In this step, get ready to:
  • Push your code and watch the pipeline run through lint, test, build, and push stages.
  • Pull your image from Docker Hub and run it locally to verify the full cycle.
  • Create a professional README with a pipeline status badge.
Push your code to GitHub

Your GitHub Actions workflow triggers on every push to the main branch. Pushing your code is all it takes to kick off the entire pipeline.

  • Stage, commit, and push all your files by running these commands:
git add .
git commit -m "Add Flask API with Docker and CI/CD pipeline"
git push -u origin main

What do these commands do?

  • git add . stages every file in the project for commit.
  • git commit -m "..." saves a snapshot of your staged files with a descriptive message.
  • git push -u origin main uploads your commit to the remote main branch on GitHub, which triggers the workflow.
  • Open your GitHub repository in a browser.
  • Click the Actions tab at the top of the repository page.

You should see a workflow run in progress with a yellow spinning icon next to your commit message.

Wait for the run to finish. A green checkmark means every stage passed successfully.

What stages are running?

Your pipeline runs through these stages in order:

  • Checkout code pulls your repository into the runner.
  • Set up Python installs Python 3.12 on the runner.
  • Install dependencies installs Flask, pytest, and flake8 from requirements.txt.
  • Lint with flake8 checks your code for style issues.
  • Run tests executes your pytest suite.
  • Login to Docker Hub authenticates using your secrets.
  • Build and push Docker image creates the image and pushes it to your registry.

Pipeline showing a red X?

  • Click on the failed job to expand the logs. The failing step shows exactly what went wrong.
  • If the lint step fails, check that app.py has no lines longer than 120 characters and no trailing whitespace.
  • If the Docker login step fails, double check that your DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets are correctly saved in your repository settings.
  • Help me debug my failing GitHub Actions pipeline
Pull and run your image from Docker Hub

The pipeline pushed your Docker image to Docker Hub tagged with the git commit SHA. You can verify this by pulling the image and running it locally.

  • Open your Docker Hub repository in a browser at https://hub.docker.com.
  • Navigate to your devops-api repository.
  • You should see a new image tag that matches a long string of letters and numbers. This is your git commit SHA.
  • Copy the full image tag (the SHA string) from Docker Hub.
  • Stop any container using port 5000 from earlier by running:
docker ps -q | xargs -r docker stop
  • Pull your image from Docker Hub by running this command:
docker pull [[DOCKERHUB_USER="your-dockerhub-username"]]/devops-api:[[GIT_SHA="your-commit-sha-from-docker-hub"]]
  • Run the pulled image locally by running:
docker run -d -p 5000:5000 [[DOCKERHUB_USER="your-dockerhub-username"]]/devops-api:[[GIT_SHA="your-commit-sha-from-docker-hub"]]
  • Test the container responds correctly by running:
curl http://127.0.0.1:5000/health

You should see a JSON response with "status": "healthy" and a timestamp. This confirms the image was built, pushed, pulled, and runs correctly.

Why tag with the git SHA?

Each image tag maps to exactly one commit in your repository. If a deployment breaks, you can trace the image back to the exact code change that caused it.

This is a production best practice for traceability and rollback.

Seeing "port is already allocated"?

  • Run docker ps to see what containers are using port 5000.
  • Stop the conflicting container with docker stop followed by the container ID from the list.
  • Help me free up port 5000 for my Docker container
Create a professional README

A README is the first thing anyone sees when they visit your repository. A well-structured README with a status badge signals that you understand professional documentation standards.

  • Create a README.md file in your project folder by running:
touch README.md
  • Open README.md in VS Code and paste the following content:
# DevOps API

![CI/CD Pipeline](https://github.com/[[GH_USERNAME="your-github-username"]]/[[REPO_NAME="your-repo-name"]]/actions/workflows/ci.yml/badge.svg)

A Python Flask health-check API containerized with Docker, paired with an automated CI/CD pipeline using GitHub Actions.

## Architecture

Every push to `main` triggers the following pipeline:

1. **Lint** - flake8 checks code quality
2. **Test** - pytest runs unit tests
3. **Build** - Docker builds a multi-stage production image
4. **Push** - Image is pushed to Docker Hub tagged with the commit SHA

## Endpoints

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/` | GET | Welcome message with API version |
| `/health` | GET | Health check with status and timestamp |

## Local Setup

```bash
# Clone the repository
git clone https://github.com/[[GH_USERNAME="your-github-username"]]/[[REPO_NAME="your-repo-name"]].git
cd [[REPO_NAME="your-repo-name"]]

# Run with Docker
docker build -t devops-api:latest .
docker run -d -p 5000:5000 devops-api:latest

# Test
curl http://127.0.0.1:5000/health
```

## Tech Stack

- **Python** + Flask
- **Docker** (multi-stage build)
- **GitHub Actions** (CI/CD)
- **Docker Hub** (image registry)

What does this README include?

  • The status badge at the top displays a live passing/failing indicator pulled directly from your pipeline.
  • The architecture section explains what the pipeline does at each stage.
  • The endpoints table gives anyone a quick reference for the API.
  • The local setup section lets someone clone and run your project in seconds.

The badge URL follows the format https://github.com/OWNER/REPOSITORY/actions/workflows/WORKFLOW-FILE/badge.svg which GitHub serves as a live SVG reflecting your latest workflow status.

  • Replace the placeholder GitHub username and repository name in the badge URL and clone URL with your actual values.
  • Save README.md.
  • Push the README to GitHub by running:
git add README.md
git commit -m "Add project README with pipeline badge"
git push

Visit your GitHub repository in the browser. You should see the README rendered with the green status badge at the top.

Badge showing "no status" or not appearing?

  • The badge only shows a result after at least one workflow run on the main branch completes. If your first push already ran successfully, it should appear within a minute.
  • Check that the workflow file name in your badge URL matches ci.yml exactly (case-sensitive).
  • Help me fix my GitHub Actions status badge in my README

✔️ Awesome, I've got everything!

Great. Double check you have saved all files and pushed them to GitHub.

ⓧ I'd like to double check the full code

Here is the full README.md for reference:

# DevOps API

![CI/CD Pipeline](https://github.com/[[GH_USERNAME="your-github-username"]]/[[REPO_NAME="your-repo-name"]]/actions/workflows/ci.yml/badge.svg)

A Python Flask health-check API containerized with Docker, paired with an automated CI/CD pipeline using GitHub Actions.

## Architecture

Every push to `main` triggers the following pipeline:

1. **Lint** - flake8 checks code quality
2. **Test** - pytest runs unit tests
3. **Build** - Docker builds a multi-stage production image
4. **Push** - Image is pushed to Docker Hub tagged with the commit SHA

## Endpoints

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/` | GET | Welcome message with API version |
| `/health` | GET | Health check with status and timestamp |

## Local Setup

```bash
# Clone the repository
git clone https://github.com/[[GH_USERNAME="your-github-username"]]/[[REPO_NAME="your-repo-name"]].git
cd [[REPO_NAME="your-repo-name"]]

# Run with Docker
docker build -t devops-api:latest .
docker run -d -p 5000:5000 devops-api:latest

# Test
curl http://127.0.0.1:5000/health
```

## Tech Stack

- **Python** + Flask
- **Docker** (multi-stage build)
- **GitHub Actions** (CI/CD)
- **Docker Hub** (image registry)

All other project files (app.py, requirements.txt, test_app.py, Dockerfile, .dockerignore, .github/workflows/ci.yml) remain unchanged from previous steps.

Your full CI/CD loop is complete. You pushed code, the pipeline linted, tested, built, and shipped a Docker image automatically. Next up, the Secret Mission adds a metrics endpoint and security scanning to level up this pipeline.

Secret mission

Add Metrics and Security Scanning

Your pipeline builds and pushes images, but it doesn't check them for security vulnerabilities before they reach Docker Hub. In this secret mission, you'll add a /metrics endpoint for observability and integrate Trivy container scanning to catch critical CVEs before they ship.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses free-tier services, so 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 devops-api project folder on your Desktop.
  • Local Docker images and containers.
  • Docker Hub devops-api repository.
  • Docker Hub Access Token.
  • GitHub repository with Actions secrets.

Keep everything running

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

  • Your GitHub Actions pipeline will continue to run on every push to main.
  • Your Docker Hub image stays available for pulling.
  • Docker Desktop and Python remain installed on your machine for future projects.

Pause - I'll come back to this later

Stop running containers to free up memory. Your files, GitHub repo, and Docker Hub image stay intact for when you return.

  • Stop any running containers by running:
docker stop $(docker ps -q)
  • Quit Docker Desktop to free up system resources.

Everything else stays in place. When you return, open Docker Desktop and you are ready to go.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to fully clean up.

  • Stop and remove all project containers and images by running:
# Stop all running containers
docker stop $(docker ps -q)

# Remove containers created from devops-api images
docker ps -a --filter "ancestor=devops-api" -q | xargs -r docker rm

# Remove the local devops-api image
docker rmi devops-api:latest
  • Delete the local project folder. On macOS or Linux, run:
rm -rf ~/Desktop/devops-api
  • On Windows, run:
Remove-Item -Recurse -Force "$env:USERPROFILE\Desktop\devops-api"
  • Delete your Docker Hub repository:
  • Sign in to Docker Hub.
  • Select My Hub then Repositories.
  • Select your devops-api repository.
  • Select the Settings tab.
  • Select Delete repository.
  • Enter the repository name to confirm, then select Delete Repository Forever.
  • Delete your Docker Hub Access Token:
  • Sign in to Docker Home.
  • Select your avatar in the top-right corner, then select Account settings.
  • Select Personal access tokens.
  • Select the actions menu on the token you created for this project, then select Delete.
  • Delete your GitHub repository:
  • Navigate to your repository on GitHub.
  • Select Settings under the repository name.
  • Scroll down to the Danger Zone section.
  • Select Delete this repository.
  • Type the repository name to confirm, then follow the prompts to delete.
  • Log out of Docker from your terminal by running:
docker logout

Nice Work!

Nice Work!

You just built a fully automated CI/CD pipeline that containerizes and publishes a Python API to Docker Hub on every push. This is the exact workflow that production DevOps teams use to ship code reliably.

You've learned how to:

  • Build a Python Flask API and containerize it with a production-style multi-stage Dockerfile.
  • Create a GitHub Actions CI/CD pipeline that automatically lints, tests, builds, and pushes Docker images on every commit.
  • Push a working Docker image to Docker Hub with git SHA tagging for precise version traceability.
  • (Secret Mission) Added a /metrics endpoint and integrated Trivy security scanning into the CI/CD pipeline to catch critical vulnerabilities before deployment.

Ready to quiz yourself?