Docker + GitHub Actions CI/CD Pipeline
Containerize a Python API and ship it through an automated CI/CD pipeline.
Introduction
30 Second Summary
Every time an app on your phone updates, code traveled from a developer's computer to a server automatically. Professional teams use automated pipelines to make this happen without any manual steps.
In this project, you will build a Python API, package it into a Docker container, and create a CI/CD pipeline that automatically tests and publishes your app every time you push code to GitHub.
What You'll Build
A production-style Python API packaged in a Docker container with a fully automated CI/CD pipeline powered by GitHub Actions.
By the end of this project, you'll have:
- A FastAPI application with automated tests running inside a Docker container.
- A GitHub Actions workflow that automatically runs tests and builds your Docker image on every push.
- A published Docker image on GitHub Container Registry that anyone can pull and run.
- Secret Mission: Optimize your pipeline with multi-stage Docker builds and layer caching for faster, leaner deployments.
Are there any prerequisites?
You should have basic Python knowledge and a GitHub account. All other tools (VS Code, Docker Desktop, Git) will be set up in Step 1.
Set Up Your Project
Every production application needs a clean, isolated environment to manage its dependencies. A virtual environment keeps your project's Python packages separate from your system Python, preventing version conflicts between projects.
In this step, you'll create your project folder, set up an isolated Python environment, install the libraries your app needs, and initialize version control with Git.
In this step, get ready to:
- Create a project folder with an isolated Python environment.
- Install FastAPI, pytest, and httpx.
- Initialize a Git repository with a proper .gitignore.
Create the project directory and virtual environment
Your project needs its own folder and an isolated Python environment. This keeps everything organized and ensures your dependencies don't interfere with other Python projects on your machine.
- In the VS Code terminal, move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir docker-cicd-pipeline
cd docker-cicd-pipeline
- Confirm the folder was created by running:
pwd
You should see a path ending in docker-cicd-pipeline.
- Open this folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Select the docker-cicd-pipeline folder on your Desktop, then click Open.
Why open the folder in VS Code?
Opening the folder (rather than individual files) gives VS Code full project context. The Explorer sidebar shows your file tree, and the integrated terminal automatically starts inside your project directory.
- Open a new terminal in VS Code by pressing Cmd+` (macOS) or Ctrl+` (Windows).
- Create a Python virtual environment by running:
python -m venv .venv
What does this command do?
python -m venv .venv tells Python to create a virtual environment in a folder called .venv. This folder contains its own copy of the Python interpreter and a clean pip for installing packages.
The dot prefix makes the folder hidden by default, keeping your project directory tidy.
- Activate the virtual environment:
macOS
- Run this command in your terminal:
source .venv/bin/activate
Windows
- Run this command in your terminal:
.venv\Scripts\activate
Your terminal prompt now starts with (.venv). This confirms the virtual environment is active.
- Verify your Python version by running:
python --version
You should see Python 3.14.0.
- Verify Docker is available by running:
docker --version
You should see a Docker version number in the output.
Not seeing the expected output?
- If python --version shows a different version, make sure (.venv) appears in your prompt. If not, re-run the activation command above.
- If docker --version fails, make sure Docker Desktop is running. Look for the Docker whale icon in your system tray.
Help me troubleshoot my Python or Docker version check.
Install dependencies
Your project depends on three libraries. FastAPI is the web framework for building your API. pytest runs automated tests. httpx powers FastAPI's test client.
- In VS Code's Explorer sidebar, click the New File icon next to your project folder name.
- Name the file requirements.txt and press Enter.
- Paste the following into requirements.txt:
fastapi[standard]==0.139.0
pytest==9.1.1
httpx==0.28.1
What does this file do?
requirements.txt is Python's standard way to declare project dependencies. The == pins each package to an exact version, ensuring everyone working on the project uses the same versions.
fastapi[standard] installs FastAPI along with its recommended extras, including uvicorn (the server that runs your app).
- Save requirements.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Install all three packages by running this command in your terminal:
pip install -r requirements.txt
You should see pip downloading and installing packages. This may take a minute.
- Verify FastAPI installed correctly by running:
python -c "import fastapi; print(fastapi.__version__)"
You should see 0.139.0 printed in your terminal.
Seeing an error during install?
- Make sure (.venv) appears in your terminal prompt. If it doesn't, your virtual environment is not active. Re-run the activation command from the previous substep.
- If you see a permissions error, you're likely installing into your system Python. Activate the virtual environment first.
Help me fix my pip install error.
✔️ Awesome, I've got everything!
Great. Double check you've saved requirements.txt and your install command completed without errors.
ⓧ I'd like to double check the full code
Here is the complete requirements.txt file for comparison:
fastapi[standard]==0.139.0
pytest==9.1.1
httpx==0.28.1
Initialize Git
Version control tracks every change you make to your code. You'll use Git throughout this project, and later push your code to GitHub to trigger your CI/CD pipeline.
- Initialize a Git repository by running:
git init
You should see a message that says Initialized empty Git repository.
Before making your first commit, you need a .gitignore file. This tells Git which files to ignore, keeping generated files and your virtual environment out of version control.
- In VS Code's Explorer sidebar, click the New File icon.
- Name the file .gitignore and press Enter.
- Paste the following into .gitignore:
.venv/
__pycache__/
*.pyc
.pytest_cache/
What are these entries?
- .venv/ is your virtual environment folder. It's large and can be recreated from requirements.txt at any time.
- __pycache__/ and *.pyc are compiled Python bytecode files. Python generates these automatically.
- .pytest_cache/ stores temporary test data. It's regenerated every time you run tests.
- Save .gitignore by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Verify Git is tracking the right files by running:
git status
You should see requirements.txt and .gitignore listed as untracked files. You should NOT see .venv/ in the list.
Seeing .venv in the git status output?
- Make sure you saved .gitignore before running git status. Unsaved files don't take effect.
- Check that the filename is exactly .gitignore (with the leading dot). Without the dot, Git won't recognize it.
Help me fix my .gitignore so Git ignores the right files.
✔️ Awesome, I've got everything!
Great. Double check you've saved .gitignore and your git status output looks clean.
ⓧ I'd like to double check the full code
Here is the complete .gitignore file for comparison:
.venv/
__pycache__/
*.pyc
.pytest_cache/
Your development environment is ready. You have an isolated Python environment, your dependencies are installed, and Git is tracking your project. Next up, you'll build a FastAPI app with three endpoints and write automated tests for it.
Build a FastAPI App with Tests
Your project environment is ready with Python, FastAPI, and pytest installed. Now it's time to write the application that you'll eventually ship through a CI/CD pipeline.
Every CI/CD pipeline needs two things: application code to deploy, and tests to prove that code works. In this step, you'll build both.
In this step, get ready to:
- Write a FastAPI application with three API endpoints.
- Write automated tests that verify each endpoint works correctly.
- Run the tests and confirm they all pass.
Write the API
FastAPI is a modern Python web framework that lets you define API endpoints using simple functions with type hints. It automatically generates interactive documentation for your API.
- Create a new file called main.py in your project folder.
- Paste in the following code:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"message": "Hello, World!"}
@app.get("/health")
def health_check():
return {"status": "healthy"}
@app.get("/items/{item_id}")
def read_item(item_id: int, name: str = "unnamed"):
return {"item_id": item_id, "name": name}
What does this code do?
app = FastAPI() creates your application instance. Every endpoint is a function decorated with a route like @app.get("/").
- read_root() handles GET requests to / and returns a greeting.
- health_check() handles GET requests to /health and reports the app's status.
- read_item(item_id: int, name: str = "unnamed") handles GET requests to /items/{item_id}. The type hints tell FastAPI that item_id must be an integer (path parameter) and name is an optional query parameter.
- Save main.py.
- Start the development server by running this command in your terminal:
uvicorn main:app --reload
What does this command do?
uvicorn is the web server that runs your FastAPI app. main:app tells it to look in main.py for the variable called app. The --reload flag automatically restarts the server when you change your code.
You should see output including Uvicorn running on http://127.0.0.1:8000. Your terminal will stay occupied while the server runs.
- Open your browser and visit http://localhost:8000/docs. You should see FastAPI's auto-generated interactive API documentation with your three endpoints listed.
Don't see the docs page?
- Check your terminal for error messages. If you see a ModuleNotFoundError, make sure your virtual environment is still activated (you should see (.venv) in your terminal prompt).
- If you see an address already in use error, another process is using port 8000. Stop it or try running with a different port: uvicorn main:app --reload --port 8001.
- Make sure you are running the command from the same folder where main.py lives.
Why can't I see my FastAPI docs page?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file and can see the docs page in your browser.
ⓧ I'd like to double check the full code
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"message": "Hello, World!"}
@app.get("/health")
def health_check():
return {"status": "healthy"}
@app.get("/items/{item_id}")
def read_item(item_id: int, name: str = "unnamed"):
return {"item_id": item_id, "name": name}
Write automated tests
Automated tests are scripts that verify your code works correctly without you manually clicking through the app. They are essential for CI/CD because the pipeline needs a way to check whether your code is safe to deploy.
Why does CI/CD depend on automated tests?
A CI/CD pipeline runs every time you push code. It needs an automated way to decide "is this code good enough to ship?" Without tests, the pipeline has no signal. It would either deploy everything blindly or require a human to check every change manually.
- Create a new file called test_main.py in your project folder.
- Paste in the following code:
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_read_root():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "Hello, World!"}
def test_health_check():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy"}
def test_read_item():
response = client.get("/items/1?name=widget")
assert response.status_code == 200
assert response.json() == {"item_id": 1, "name": "widget"}
What does this code do?
TestClient is FastAPI's built-in testing tool (powered by httpx). It simulates HTTP requests to your app without needing a running server.
- Each test_ function sends a request to one endpoint and checks that the status code is 200 (success).
- assert response.json() == {...} verifies the response body matches exactly what you expect.
- test_read_item() tests both the path parameter (1) and the query parameter (name=widget).
- Save test_main.py.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved. You'll run the tests in the next substep.
ⓧ I'd like to double check the full code
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_read_root():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "Hello, World!"}
def test_health_check():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy"}
def test_read_item():
response = client.get("/items/1?name=widget")
assert response.status_code == 200
assert response.json() == {"item_id": 1, "name": "widget"}
Run the tests
Your tests are written, but you haven't actually run them yet. Pytest is a test runner that automatically discovers and executes any file starting with test_ and any function inside it starting with test_.
- Switch back to your terminal. Press Ctrl+C to stop the uvicorn server.
- Run your tests by running this command:
pytest
You should see output ending with 3 passed in green. This means all three test functions ran and every assertion succeeded.
How does pytest discover tests?
You did not need to tell pytest which file to run. It automatically scans the current directory for files matching the test_*.py pattern, then runs every function inside them that starts with test_. This convention-based discovery means you never have to maintain a list of test files.
Tests not passing?
- If you see an ImportError, make sure main.py and test_main.py are in the same folder.
- If you see a command not found error, make sure your virtual environment is still activated (you should see (.venv) in your terminal prompt).
- If an assertion fails, double check that the return values in main.py match the expected values in test_main.py exactly (including capitalization and punctuation).
Why are my pytest tests failing?
Your API is built and verified by automated tests. Next up, you'll package this application into a Docker container so it can run anywhere.
Containerize with Docker
Your API is built and your tests are passing. The next challenge is guaranteeing that your app runs the same way on every machine, not just yours.
Docker solves this by packaging your app into a container. A container bundles your code, dependencies, and runtime environment into a single portable unit. In this step, you'll write a Dockerfile, build an image, and run your app inside a container.
In this step, get ready to:
- Write a Dockerfile and .dockerignore.
- Build a Docker image from your project.
- Run and verify the container.
Write the Dockerfile
A Dockerfile is a text file with instructions that tell Docker how to build an image of your app. Think of the image as a blueprint and the container as a running instance of that blueprint.
What is a Docker image vs. a container?
A Docker image is a read-only template containing your code, dependencies, and configuration. You build it once from a Dockerfile.
A container is a running instance of that image. You can start, stop, and delete containers without affecting the image itself. One image can spawn many containers.
- Create a new file called Dockerfile in your project folder (no file extension).
- Paste the following code into Dockerfile:
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
What does each line do?
- FROM python:3.14-slim starts from a lightweight Python base image. This gives you Python without the bulk of a full OS.
- WORKDIR /app sets the working directory inside the container. All following commands run from here.
- COPY requirements.txt . copies your requirements file into the container first. This lets Docker cache the dependency install layer separately from your code.
- RUN pip install --no-cache-dir -r requirements.txt installs your Python dependencies inside the image.
- COPY . . copies the rest of your project files into the container.
- EXPOSE 8000 documents that the app listens on port 8000.
- CMD sets the default command that runs when the container starts. It launches your FastAPI app with uvicorn.
- Save Dockerfile.
Editor adding a file extension?
Some editors may try to save the file as Dockerfile.txt. Make sure the file is named exactly Dockerfile with no extension. In VS Code, you can verify the filename in the tab at the top of the editor.
Help me fix my Dockerfile name.
Now you need a .dockerignore file to exclude unnecessary files from the Docker build context. This keeps your image small and avoids copying files Docker does not need.
- Create a new file called .dockerignore in your project folder.
- Paste the following into .dockerignore:
.git
.github
__pycache__
*.pyc
.pytest_cache
.venv
Why exclude these files?
The .git folder, __pycache__, and .venv are not needed inside your container. Excluding them makes the build faster and the image smaller because Docker does not have to copy those files into the build context.
- Save .dockerignore.
- Confirm both new files exist by running:
ls
You should see Dockerfile and .dockerignore alongside your existing files (main.py, test_main.py, requirements.txt).
✔️ Awesome, I've got everything!
Great. Double check you've saved both files.
ⓧ I'd like to double check the full code
Here is the full Dockerfile:
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
And here is the full .dockerignore:
.git
.github
__pycache__
*.pyc
.pytest_cache
.venv
Build and run the container
With your Dockerfile ready, you can build a Docker image and then run it as a container.
- Make sure Docker Desktop is running on your machine.
- Build your Docker image by running this command:
docker build -t my-fastapi-app .
What does this command do?
- docker build reads the Dockerfile and creates an image from it.
- -t my-fastapi-app tags the image with the name my-fastapi-app so you can reference it later.
- . sets the build context to the current directory. Docker looks for the Dockerfile and project files here.
The build takes a minute while Docker downloads the Python base image and installs your dependencies. You should see output ending with a line like Successfully tagged my-fastapi-app:latest.
Build failing?
- Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your system tray.
- Confirm you are running the command from your project folder (the one containing Dockerfile).
- Verify the file is named exactly Dockerfile with a capital D and no file extension.
Help me debug my Docker build error.
- Run your container by running this command:
docker run -d -p 8000:8000 my-fastapi-app
What do the flags mean?
- -d runs the container in detached mode (in the background), so you get your terminal back.
- -p 8000:8000 maps port 8000 on your machine to port 8000 inside the container. Without this, you cannot reach the app from your browser.
- Open your browser and visit http://localhost:8000/docs.
You should see the FastAPI interactive docs page. This is the same app you ran earlier, but now it is running inside a Docker container.
Port already allocated?
If you see an error containing port is already allocated, another process is using port 8000. This often happens if you forgot to stop the uvicorn server from the previous step.
- Switch to the terminal where uvicorn is running and press Ctrl+C to stop it.
- Re-run the docker run command above.
Help me fix the port conflict.
Verify and clean up
Your app is running in a container. Let's confirm it is there and then stop it so port 8000 is free for later steps.
- List your running containers by running:
docker ps
You should see a table with one row showing my-fastapi-app in the IMAGE column and 0.0.0.0:8000->8000/tcp in the PORTS column.
- Copy the CONTAINER ID from the first column of the docker ps output.
- Stop the container by running:
docker stop [[CONTAINER_ID="your container ID from docker ps"]]
The container stops and port 8000 is freed up.
What is the container lifecycle?
Containers follow a simple lifecycle: build an image, run a container from that image, stop it when you are done, and optionally remove it. The image stays on your machine so you can spin up new containers from it at any time.
You just completed the full cycle: build (docker build), run (docker run), and stop (docker stop). In the next step, you will push your code to GitHub so you can automate this entire process.
Container not stopping?
Make sure you are using the correct container ID from docker ps. You only need the first few characters of the ID.
Help me stop my Docker container.
Your app is containerized and verified. Next up, you will push everything to GitHub so you can build an automated pipeline around it.
Push to GitHub
Your app is containerized and running in Docker. The next step is getting your code to a place where an automated pipeline can access it.
GitHub is where your code will live and where your CI/CD pipeline will run. In this step, you'll create a repository and push your entire project to it.
In this step, get ready to:
- Create a public GitHub repository.
- Connect your local project and push your code.
- Verify all files appear on GitHub.
Create a GitHub repository
Your pipeline needs a remote repository to watch for code changes. You'll create one on GitHub now.
- Open your browser and go to github.com/new.
- Enter a repository name (for example, docker-cicd-pipeline).
- Select Public as the visibility.
- Leave all initialization options unchecked (no README, no .gitignore, no license).
- Click Create repository.
You'll land on a Quick Setup page showing your repository URL and some suggested commands. Keep this page open.
Why does the repository need to be public?
GitHub Actions runners on public repositories are free and unlimited. A private repository would use your monthly free-tier minutes (2,000 minutes on the GitHub Free plan).
GitHub Container Registry storage and bandwidth is also free for public packages. Making the repo public means zero cost for this entire project.
Connect your local project and push
Your local Git repository has no idea where to send code yet. You'll connect it to your new GitHub repository and push everything up.
- Link your local repository to GitHub by running this command:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/YOUR_REPO.git"]]
What does git remote add do?
A remote is a bookmark pointing to a repository hosted somewhere else (in this case, GitHub). The name origin is the conventional name for your primary remote.
This command does not send any code yet. It just tells Git where to push when you're ready.
- Stage all your files, commit them, and push to GitHub by running these commands:
git add .
git commit -m "Initial commit"
git branch -M main
git push -u origin main
What do these commands do?
git add . stages every file in your project folder (except those listed in .gitignore).
git commit -m "Initial commit" saves a snapshot of those staged files with a descriptive message.
git branch -M main ensures your branch is named main (matching what GitHub expects).
git push -u origin main uploads your commit to GitHub. The -u flag links your local main branch to the remote so future pushes only need git push.
GitHub sign-in prompt
When you push for the first time, Git Credential Manager may open a browser window asking you to sign in to GitHub. Sign in and authorize access. After that, your credentials are cached for future pushes.
- Confirm the push succeeded. You should see output ending with a line like main -> main in your terminal.
Push rejected or authentication failed?
If you see a 403 or "authentication failed" error, your Git credentials may not be set up. Make sure you signed in through the browser window that appeared. If no window appeared, run git credential-manager github login to trigger it manually.
If the push is rejected because the remote contains work you don't have, you may have initialized the repo with a README. Delete the repository on GitHub and recreate it with no initialization options checked.
Help me troubleshoot my git push to GitHub
Verify on GitHub
- Switch to your browser and refresh the repository page on GitHub.
- Confirm that you can see all of the following files listed in the repository:
- Dockerfile
- main.py
- test_main.py
- requirements.txt
- .gitignore
- .dockerignore
The Quick Setup page is gone, replaced by your project's file listing. Your code is now on GitHub and ready for automation.
Don't see all six files?
Files starting with a dot (like .gitignore and .dockerignore) can be easy to miss. Scroll down in the file list. GitHub sorts them at the top alphabetically.
If files are missing entirely, check that you ran git add . from inside your project folder (not a parent or child folder). Run git status to see if anything is untracked.
Help me figure out why files are missing from my GitHub repository
Your code is live on GitHub. Next up, you'll create the CI/CD pipeline that automatically tests and publishes your Docker image every time you push.
Create the CI/CD Pipeline
Your code is on GitHub, your tests pass, and your Docker container runs. But right now, every time you change your code, you would have to manually run the tests, rebuild the image, and push it again.
A CI/CD pipeline eliminates that manual work. In this step, you will write a GitHub Actions workflow that automatically runs your tests on every push. If the tests pass, it builds your Docker image and publishes it to GitHub Container Registry without you lifting a finger.
In this step, get ready to:
- Write a GitHub Actions workflow file that defines your pipeline.
- Push the workflow and watch it execute on GitHub.
- Pull and run the published Docker image from the registry.
Write the GitHub Actions workflow
GitHub Actions workflows live in a special directory inside your repository. GitHub automatically detects any YAML file in .github/workflows/ and runs it according to the triggers you define.
- Create the workflow directory by running this command:
mkdir -p .github/workflows
- Confirm the directory exists by running:
ls .github
You should see workflows listed in the output.
- Create a new file called ci.yml inside the .github/workflows/ directory. In VS Code, right-click the workflows folder in the Explorer sidebar and select New File.
- Name the file ci.yml and press Enter.
- Paste the following code into ci.yml to define the workflow triggers, permissions, and the test job:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
packages: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest
What does this code do?
The on: section defines when the pipeline runs. It triggers on every push to main and on every pull request targeting main.
The permissions: block grants the workflow read access to your code and write access to packages. Without packages: write, the pipeline cannot push Docker images to the container registry.
The test job runs on a fresh Ubuntu virtual machine. It checks out your code, installs Python 3.14, installs your dependencies, and runs pytest. If any test fails, the entire pipeline stops.
- Now add the build-and-push job directly below the test job. Paste the following code at the bottom of ci.yml, making sure build-and-push: is at the same indentation level as test::
build-and-push:
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest,ghcr.io/${{ github.repository }}:${{ github.sha }}
What does the build-and-push job do?
The needs: test line creates a dependency. This job only starts after the test job passes. If your tests fail, the image never gets built or published.
The if: condition adds a second gate. The image is only published on pushes to main. Pull requests still run the tests, but they do not publish a new image.
The job logs into ghcr.io using the built-in GITHUB_TOKEN. You do not need to create any secrets manually. GitHub provides this token automatically for every workflow run.
Finally, docker/build-push-action@v7 builds your Dockerfile and pushes the resulting image to GitHub Container Registry with two tags: latest and the commit SHA.
- Save ci.yml.
Seeing YAML indentation errors?
YAML is extremely sensitive to indentation. Make sure build-and-push: lines up exactly with test: above it (two spaces of indentation under jobs:). Each nested level uses two more spaces.
If you are unsure, use the full file reference in the tab below to compare your indentation line by line.
Help me fix my ci.yml indentation
✔️ Awesome, I've got everything!
Great. Double check you have saved ci.yml before moving on.
ⓧ I'd like to double check the full code
Here is the complete .github/workflows/ci.yml file. Make sure yours matches exactly:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
packages: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest
build-and-push:
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest,ghcr.io/${{ github.repository }}:${{ github.sha }}
Push the workflow and watch it run
The workflow file only takes effect once it reaches GitHub. Pushing this file triggers the pipeline for the first time.
- Stage, commit, and push the workflow file by running these commands:
git add .github/workflows/ci.yml
git commit -m "Add CI/CD pipeline"
git push
What happens when you push?
The moment your push lands on main, GitHub detects the new workflow file. It spins up a fresh virtual machine, runs the test job first, and if all tests pass, kicks off the build-and-push job to publish your Docker image.
- Open your repository on GitHub in your browser.
- Click the Actions tab under the repository name.
- In the left sidebar, click CI/CD Pipeline to see your workflow.
- Click the most recent workflow run (it will be named after your commit message).
- Under Jobs, click test to expand the test job log. You will see each step execute: checkout, Python setup, dependency install, and pytest.
- Once the test job shows a green checkmark, click build-and-push to see the Docker build and push steps.
Both jobs should complete with green checkmarks. The entire pipeline typically takes 1-3 minutes.
Pipeline failed?
Click the failed job to see the error log. Common causes:
- YAML syntax error: check indentation carefully. Every level is exactly two spaces.
- Test failure: if pytest fails in CI, make sure your test_main.py and main.py are both pushed to the repository.
- Permission denied on push: confirm your repository is public. Private repos require additional token configuration for GHCR.
Help me debug my GitHub Actions pipeline failure
Verify the published image
The pipeline published your Docker image to GitHub Container Registry. You can confirm it exists on GitHub and then pull it down to prove it works.
- On your repository page in GitHub, look for Packages in the right sidebar. Click it to see your published container image.
- You should see your repository name listed as a container package with the latest tag.
What is GitHub Container Registry?
GitHub Container Registry (ghcr.io) is a free package registry for public repositories. Anyone can pull your public images without authentication. It uses the same GITHUB_TOKEN your workflow already has, so there is no extra account or secret to configure.
- Pull your published image from the registry by running this command:
docker pull [[GHCR_IMAGE="ghcr.io/your-username/your-repo:latest"]]
- Run the pulled image by running this command:
docker run -d -p 8000:8000 [[GHCR_IMAGE="ghcr.io/your-username/your-repo:latest"]]
- Open http://localhost:8000/docs in your browser.
You should see the FastAPI interactive docs page. This image was built by your pipeline on GitHub and pulled from the cloud. It is the exact same app, but you did not build it locally this time.
- Stop the container when you are done verifying by running:
docker stop $(docker ps -q --filter ancestor=[[GHCR_IMAGE="ghcr.io/your-username/your-repo:latest"]])
Can't pull the image?
If Docker returns a "not found" error, the pipeline may still be running. Go back to the Actions tab and wait for both jobs to show green checkmarks before trying again.
If you see a permission error, make sure you are pulling from a public repository. Public GHCR images can be pulled without authentication.
Help me pull my Docker image from GitHub Container Registry
Your CI/CD pipeline is live. Every time you push code to main, GitHub automatically tests it and publishes a fresh Docker image. Next up, you will optimize this pipeline with multi-stage builds and layer caching to make it faster and leaner.
Secret mission
Multi-Stage Build + Layer Caching
Your pipeline works, but right now the production image ships with test dependencies (pytest, httpx) and test files that have no business running in production. In this secret mission, you will split your Dockerfile into separate stages for testing and production, then add GitHub Actions layer caching to dramatically speed up subsequent builds.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses GitHub Actions (free for public repos) and GitHub Container Registry (free for public packages), so there are no ongoing costs.
Resources you used:
- Local project folder on your Desktop (includes virtual environment, source code, Dockerfile, and workflow file).
- Local Docker images (my-fastapi-app and any pulled GHCR images).
- GitHub repository (public).
- Docker image published to GitHub Container Registry.
Keep everything running
No action needed. Choose this if you are still actively building on this project or plan to push more changes soon.
- Your GitHub Actions pipeline will continue to run automatically on every push to main. This costs nothing for public repositories.
- Your GHCR image stays published and pullable by anyone. Storage and bandwidth are free for public packages.
- Local Docker images remain on disk. They take up storage but consume no CPU or memory when not running.
Pause - I'll come back to this later
Free up local resources while keeping everything intact for when you return.
- Check for any running containers by running:
docker ps
- If you see any containers from this project, stop them by running:
docker stop $(docker ps -q)
- Deactivate your virtual environment by running:
deactivate
- Your GitHub repo and GHCR image remain untouched. The pipeline only runs when you push, so no action is needed there.
Delete - I don't want to use this again
Remove all project resources completely. Follow each step below.
- Remove local Docker images by running:
docker rmi my-fastapi-app
docker rmi ghcr.io/[[GITHUB_USERNAME="your-github-username"]]/[[GITHUB_REPO="your-repo-name"]]:latest
- Delete the GHCR package from GitHub:
- Go to your repository on GitHub.
- Click Packages in the right sidebar.
- Click the package name to open it.
- Click Package settings.
- Scroll to the Danger Zone section and click Delete this package.
- Delete the GitHub repository:
- Go to your repository on GitHub.
- Click the Settings tab.
- Scroll to the bottom to the Danger Zone section.
- Click Delete this repository and follow the confirmation prompts.
- Delete the local project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]
- On Windows, run:
cd ~\Desktop
rmdir /s /q [[PROJECT_FOLDER="your project folder name"]]
- This removes all source code, the virtual environment, and the .github workflow folder.
That's a Wrap!
That's a Wrap!
Nice work! You just shipped code like a professional engineer, building a Python API, packaging it in a Docker container, and automating the entire test-and-deploy cycle with a CI/CD pipeline.
You've learned how to:
- Build a FastAPI application with three endpoints and automated tests using pytest.
- Write a Dockerfile and run the app inside a Docker container with port mapping.
- Create a GitHub Actions CI/CD pipeline that automatically runs tests and publishes Docker images to GitHub Container Registry.
- Secret Mission: Optimized the pipeline with multi-stage Docker builds and GitHub Actions layer caching for faster, leaner production deployments.
Ready to quiz yourself?