CI/CD Made Simple Part 1: Deploying a Dockerized FastAPI App with GitHub Actions
Introduction
In this fast-paced world of modern software development, delivering software quickly and reliably is very crucial. Whether you’re creating a simple web application or a complex enterprise solution, ensuring that it gets from development to production - efficiently, without errors and on time - can be challenging, even for the best of developers. That’s where Continuous Integration (CI) and Continuous Deployment (CD) come to the rescue!
With CI/CD, you can focus on writing great code through automation of tedious tasks, ensuring code quality control. By setting up a CI/CD pipeline, you can automate the process of building, testing and deploying your application, ensuring that code changes are thoroughly tested and seamlessly pushed to production.
This tutorial will cover everything you need to know about CI/CD and walk you through implementing it with Github Actions, a popular and developer-friendly automation tool. You will not only learn about CI/CD concepts and principles but also get hands-on experience by creating a robust pipeline to build, test and deploy a dockerised FastAPI application to platforms like Docker hub, AWS and Kubernetes. Ready? Start your engine and let’s go!
Objectives
By the end of this tutorial, you will be able to:
Discuss the core principles and concepts of CI/CD.
Understand the structure and components of a Github Actions workflow file.
Write a CI/CD pipeline in GitHub Actions following a structured mental model.
Build and test a FastAPI application using a CI/CD workflow.
Automate the process of building Docker images and pushing them to Docker Hub.
Deploy a FastAPI application to a cloud service provider i.e. AWS.
Configure and execute deployments to a Kubernetes cluster.
Debug and optimize GitHub Actions workflows to improve pipeline reliability.
What is CI/CD ?
CI/CD collectively refers to the set of principles and tools developers use to integrate their code changes to a central repository and automate the delivery of the updates to end users.
Continuous Integration (CI): involves multiple developers committing their individual code changes frequently to a central repository, managed by a Version Control System e.g. Git. The code is subjected to automated build and test runs, triggered by a code merge. The output of CI is an artifact that can be deployed. Think of artifact as the ‘finished product‘ of the CI process, after your code has been built and tested. It’s like baking a cake; you put together your ingredients(your code), follow a recipe(build process) and taste it to ensure it tastes good(automated tests). The final product, a delicious cake(artifact), is now ready to eat(deploy).
Continuous Delivery (CD): involves manually deploying artifacts from CI process to a production environment. The aim of Continuous Delivery is to frequently deploy code with minimal effort while still allowing some level of human oversight. When the deployment process to production environments is fully automated, the process is known as Continuous Deployment.
Why is CI/CD Important for Modern Development?
Is CI/CD really necessary in modern development? To answer this, let’s take a quick trip down memory lane.
In traditional workflows, development cycles involved developers writing code for weeks or months before merging their changes to the main codebase. This was often followed by manual testing and infrequent deployments, making the journey to production, slow, risky and error-prone.
CI/CD changed the game completely by automating the integration, testing and deployment process. This shift has resulted in faster feedback loops, fewer errors in production and empowered teams to ship high quality software quickly and confidently.
Now let’s explore how Github Actions, a popular automation tool, can supercharge your CI/CD pipelines.
Understanding GitHub Actions
What is GitHub Actions?
GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform that allows you to automate your build, test, and deployment pipeline.
Building on the earlier analogy of baking a cake, Github Actions is like the expert baker. It takes care of gathering the requirements(your code), following the recipe(build process), tasting for deliciousness(automated tests) and packaging the cake(artifact), ready for delivery(deployment).
The Anatomy of a GitHub Actions Workflow File
A Github Actions workflow file is composed of different components each playing a significant role in the CI/CD process. Think of this workflow file like a recipe, breaking down the ingredients(components) and instructions(steps) to create a Michelin-star-worthy meal.
These key components include:
Name
Sets the name of the workflow, job or steps to make them more readable. This is the name that will be displayed on the repository’s Action tab.
name: fastapi-cicd-starter
Triggers
Define when a workflow should run (for example on push, pull_request, scheduled). The following line triggers the workflow every time a push occurs in the repository.
on: push
Job
A single unit of work specifying a series of steps that are executed by virtual machines known as runners. A workflow file can contain multiple jobs and a job can contain multiple jobs under it. The following example specifies a build job running on an ubuntu virtual machine.
jobs: build: runs-on: ubuntu-latest
Steps
Steps are the individual tasks or commands that make up a job, for example checking out the code, setting up Python, installing dependencies etc. Steps can either run scripts or reusable code packages known as actions.
uses: actions/setup-python@v5
tellssteps: - name: Checkout code uses: actions/checkout@v2
Actions
Actions are reusable code packages that help automate various steps. They perform tasks such as setting up Python.
uses: actions/setup-python@v5
specifies the action that will be executed. In this case,actions/setup-python@v5
is an official GitHub Action that sets up a Python environment in your runner.- name: Set up Python uses: actions/setup-python@v5
Have you used any other CI/CD tools before? How does GitHub Actions compare?
Project Setup
Clone the Starter Repository
To code along with this tutorial, clone the base project:
git clone https://github.com/MoigeMatino/fastapi-ci-cd-starter.git --branch base --single-branch
cd fastapi-ci-cd-starter
Understanding the FastAPI Repository
Quickly review the code and overall project structure:
fastapi-ci-cd-starter/
├── app/
│ ├── __init__.py # Entry point for the FastAPI app
│ ├── models.py # SQLModel definitions
│ ├── database.py # Database connection
│ ├── routes/
| | |--- __init__.py
│ │ └── items.py # Example API routes
├── tests/
| | |--- __init__.py
│ | └── test_items.py # Example test for the API
├── Dockerfile # Dockerfile for the FastAPI app
├── .gitignore # gitignore file
├── .dockerignore # Dockerignore file
├── compose.yaml # Docker Compose configuration
├── requirements.txt # Python dependencies
├── .env.example # Example environment file
└── README.md # Project documentation
Then, spin up the app:
docker-compose up --build
Navigate to http://localhost:8000 and you’ll see this:
Run the tests to ensure they pass:
docker-compose exec app pytest
Building the CI/CD Pipeline
How to Approach a CI/CD Workflow
Building a CI/CD pipeline for the first time may seem scary but here’s a structured approach to help you plan and implement your pipeline:
Define Goals
What are you automating? Integration, testing, or deployment?
What environments will you deploy to (e.g., staging, production)?
Break the Workflow into Stages
Build: Package your application (e.g., as a Docker image).
Test: Run automated tests to catch bugs early.
Deploy: Push the artifact (e.g., Docker image) to a registry and deploy it.
Understand Workflow Components
- i.e triggers, jobs, steps, actions etc as previously elaborated in the Anatomy of a Github Actions Workflow File section.
Start Small
Begin with automating the Build stage, then add Tests and finally Deployment.Iterate and Improve
Expand the pipeline as your project grows - optimize stages, add tests, and refine deployments.
Now that all that theory is out of the way, let’s finally get our hands dirty!
Writing the Build and Test Workflow
Using the structured template above, let’s come up with our pipeline:
Begin by creating a new directory in the project’s root and name it
.github
.Create another directory within
.github
and name itworkflows
. This directory will hold all of our workflow files.Create the actual workflow file within this folder and give it the name
fastapi-workflow.yml
. Add the following content to it:name: fastapi-cicd-starter on: push: branches: [main] jobs: build-and-test: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.10' - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Create .env file run: | echo "POSTGRES_DB=${{ secrets.POSTGRES_DB }}" >> .env echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env echo "POSTGRES_USER=${{ secrets.POSTGRES_USER }}" >> .env - name: Build and run Docker Compose run: | docker compose --env-file .env up -d --build - name: Wait for services to start run: sleep 30 - name: Run tests run: | docker compose exec app pytest - name: Shut down Docker Compose if: always() run: | docker compose down
Let’s break this down to understand what this workflow is doing:
We start off by defining the name of the workflow. In this case, the workflow is named
fastapi-cicd-starter
for easy identification.We then specify the trigger to run the workflow when a
push
event to themain
branch occurs.We define a single job named
build-and-test
to be executed on anubuntu-latest
virtual machine.Thereafter, we checkout the code i.e pull the latest code from the repository to the workflow environment.
Python 3.10 is installed.
pip
is upgraded and all dependencies fromrequirements.txt
are installed.Environment variables are dynamically generated using Github Secrets to securely store sensitive information like database credentials. To store your environment variables as Github secrets, navigate to your repository and go to Settings > Security > Secrets and variables > Actions > Repository secrets.
You can then create your environment variable name and its corresponding value.
These environment variables are stored in a
.env
file, which is used by the application and Docker Compose during the workflow. Pro tip: Never commit.env
files to source control—keep your secrets safe! Don’t be a noob.All services, including the application and database, are built and started using Docker Compose.
We then add a 30 second delay. Why? To ensure all services, including the database, have ample time to fully initialize. This is particularly important in Github Actions, as workflows run in a fresh environment and services may take time to start. Without this wait, the following steps like tests might fail prematurely, causing false negatives in the pipeline.
Tests are ran inside the running docker containers using
pytest
.Docker containers are stopped to release resources and ensure a clean environment for future runs. The
if: always()
condition ensures this cleanup step is executed whether or not the previous steps succeed or fail, preventing leftover resources from affecting future workflows or incurring unnecessary costs.
That was fun, we got to automate building and testing. With Continuous Integration now out of the way, let’s turn our focus on the deployment and dive into Continuous Delivery!
Deploying to Docker Hub
We’re now going to push our artifact - our app’s docker image - to Docker hub. Docker hub is a cloud-based repository where you can store and share your docker images. Why is this important? Well, it ensures that the latest version of your application is always available for deployment. Let’s begin:
You’ll need to create a Docker account if you do not already have one. Create account here.
Login to your account.
Click your avatar and Go to Account Settings > Security > Personal Access Token.
Click Generate new token.
Provide a descriptive name, set expiration date(optional) and select appropriate permissions. To ensure our token has the appropriate scope, the token should have
read
,write
, anddelete
permissions. Make sure you set those permissions to prevent this error when running the workflow:unauthorized: access token has insufficient scopes
Click Generate.
Next, add the token as a Github secret i.e
DOCKER_ACCESS_TOKEN
with the corresponding value.Also, add your docker username as a Github secret i.e
DOCKER_USERNAME
to facilitate login.
Let’s now update our workflow.
name: fastapi-cicd-starter
on:
push:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Create .env file
run: |
echo "POSTGRES_DB=${{ secrets.POSTGRES_DB }}" >> .env
echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
echo "POSTGRES_USER=${{ secrets.POSTGRES_USER }}" >> .env
- name: Build and run Docker Compose
run: |
docker compose --env-file .env up -d --build
- name: Wait for services to start
run: sleep 30
- name: Run tests
run: |
docker compose exec app pytest
- name: Shut down Docker Compose
if: always()
run: |
docker compose down
deploy:
needs: build-and-test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Docker Hub
run: echo "${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}" | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin
- name: Build Docker image
run: |
docker build -t my-fastapi-app .
- name: Tag and push Docker image
run: |
docker tag my-fastapi-app ${{ secrets.DOCKER_HUB_USERNAME }}/my-fastapi-app:latest
docker push ${{ secrets.DOCKER_HUB_USERNAME }}/my-fastapi-app:latest
Let’s quickly break down the new changes:
We start off by creating a new job i.e
deploy
.To ensure the job only runs after the successful completion of the "build-and-test" job, we added this:
needs: build-and-test
.The
if: github.ref == 'refs/heads/main'
condition ensures deployments occur only from themain
branch, preventing accidental deployments from other branches.We securely log in using
DOCKER_HUB_ACCESS_TOKEN
andDOCKER_HUB_USERNAME
, both stored as secrets in GitHub. This avoids exposing sensitive credentials.The job builds the image locally from the project’s
Dockerfile
and tags it with a descriptive name i.emy-fastapi-app
.The image is then tagged with your Docker Hub username and the
latest
tag before being pushed to Docker Hub, making it available for future deployments.
Push code to your repository
Now that our CI/CD workflow is set up, it's time to see it in action! Push your changes to your GitHub repository's main branch to trigger the workflow.
git add .
git commit -m "ci: set up CI/CD workflow"
git push origin main
Once the push is complete, go to your GitHub repository and navigate to the Actions tab. Here, you’ll see your workflow in progress. If everything is set up correctly, the pipeline will build, test and push your Docker image to Docker Hub. A successful workflow run will look like this:
Validate your deployment
Before we move on, let’s make sure that our image was actually pushed to Docker hub and that our application is working as expected. You can do this by running the image locally:
docker run -d -p 8000:8000 $DOCKER_HUB_USERNAME/my-fastapi-app:latest
Navigate to http://localhost:8000 in your browser and you should see your Fastapi application running!
Pro tip: You can as well navigate to your Docker hub account to see your application docker image there, magic!
Wrapping Up
In this article, we’ve built a solid foundation for automating our CI/CD pipeline. From automating builds and tests to pushing Docker images to Docker Hub, we’ve taken huge strides towards a fully automated deployment workflow.
But we’re not stopping here! Deploying the application to a container registry like Docker Hub is just the beginning. In Part 2, we’ll dive deeper into:
Deploying to the Cloud: Taking your application live with a reliable cloud platform.
Deploying to Kubernetes: Leveraging Kubernetes for scaling and managing your application in production.
These advanced steps will elevate your CI/CD pipeline into a seamless workflow for getting your application from code to production.
Stay tuned for Part 2, where we’ll take your deployment skills to the next level!
One last thing, can you think of a way to make sure that the workflow runs whenever a pull request is made against the main branch? (Hint: It’s a tweak to the on
section of the workflow file) Let’s discuss in Part 2 🚀!