CI/CD Made Simple Part 1: Deploying a Dockerized FastAPI App with GitHub Actions

CI/CD Made Simple Part 1: Deploying a Dockerized FastAPI App with GitHub Actions

Photo by NASA on Unsplash

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:

  1. Discuss the core principles and concepts of CI/CD.

  2. Understand the structure and components of a Github Actions workflow file.

  3. Write a CI/CD pipeline in GitHub Actions following a structured mental model.

  4. Build and test a FastAPI application using a CI/CD workflow.

  5. Automate the process of building Docker images and pushing them to Docker Hub.

  6. Deploy a FastAPI application to a cloud service provider i.e. AWS.

  7. Configure and execute deployments to a Kubernetes cluster.

  8. 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 tells

      steps:
        - 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:

  1. Define Goals

    • What are you automating? Integration, testing, or deployment?

    • What environments will you deploy to (e.g., staging, production)?

  2. 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.

  3. Understand Workflow Components

    • i.e triggers, jobs, steps, actions etc as previously elaborated in the Anatomy of a Github Actions Workflow File section.
  4. Start Small
    Begin with automating the Build stage, then add Tests and finally Deployment.

  5. 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:

  1. Begin by creating a new directory in the project’s root and name it .github.

  2. Create another directory within .github and name it workflows. This directory will hold all of our workflow files.

  3. 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
    
  4. Let’s break this down to understand what this workflow is doing:

  5. We start off by defining the name of the workflow. In this case, the workflow is named fastapi-cicd-starter for easy identification.

  6. We then specify the trigger to run the workflow when a push event to the main branch occurs.

  7. We define a single job named build-and-test to be executed on an ubuntu-latest virtual machine.

  8. Thereafter, we checkout the code i.e pull the latest code from the repository to the workflow environment.

  9. Python 3.10 is installed.

  10. pip is upgraded and all dependencies from requirements.txt are installed.

  11. 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.

  12. All services, including the application and database, are built and started using Docker Compose.

  13. 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.

  14. Tests are ran inside the running docker containers using pytest.

  15. 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:

  1. 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, and delete permissions. Make sure you set those permissions to prevent this error when running the workflow: unauthorized: access token has insufficient scopes

    • Click Generate.

  2. Next, add the token as a Github secret i.e DOCKER_ACCESS_TOKEN with the corresponding value.

  3. 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:

  1. We start off by creating a new job i.e deploy .

  2. To ensure the job only runs after the successful completion of the "build-and-test" job, we added this: needs: build-and-test .

  3. The if: github.ref == 'refs/heads/main' condition ensures deployments occur only from the main branch, preventing accidental deployments from other branches.

  4. We securely log in using DOCKER_HUB_ACCESS_TOKEN and DOCKER_HUB_USERNAME, both stored as secrets in GitHub. This avoids exposing sensitive credentials.

  5. The job builds the image locally from the project’s Dockerfile and tags it with a descriptive name i.e my-fastapi-app .

  6. 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 🚀!