Deploying a FastAPI app with Kamal, AWS ECR, and Github Actions

fastapi
kamal
aws
Author
Affiliation
Published

September 21, 2024

Modified

November 20, 2024

These days I use Kamal to deploy my FastAPI (or Django) projects. Kamal is a simpler alternative to Kubernetes that you can use to deploy containerized apps to a VPS.

Once you get the hang of it, it’ll only take you a few minutes to set up a CI/CD pipeline that automatically deploys your app to production with each push to the main branch.

In this tutorial, I’ll walk you through the process of deploying a FastAPI app with Kamal, AWS ECR, and Github Actions.

Prerequisites

To make the most of this tutorial, you should:

  • Have a FastAPI app ready to deploy.
  • Have an AWS account and its CLI installed.
  • Be comfortable with Docker.
  • Have a basic understanding of Kamal. You’ll need to install version 1.9.0 for this tutorial.
  • Have a basic understanding of Github Actions.
  • Have a VPS with Ubuntu ready to host your app.

Prepare your VPS

You’ll need to install docker, curl, git, and snapd on your VPS, and create a non-root user called kamal that can sudo. You should also set the UID and GID of the user to 1000.

If you’re using Hetzner, you can use my terraform script to prepare the VPS.

Otherwise, you can run these commands on your VPS’s terminal:

# Install docker, curl, and git, and snapd
apt-get update
apt-get install -y docker.io curl git snapd

# Start and enable the docker service
systemctl start docker
systemctl enable docker

# Create a non-root user called kamal
useradd -m -s /bin/bash -u 1000 kamal
usermod -aG sudo kamal
echo "kamal ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/kamal

# Add SSH key to login as kamal user
mkdir -p /home/kamal/.ssh
echo "<YOUR_PUBLIC_SSH_KEY>" >> /home/kamal/.ssh/authorized_keys # you need a public key to login as the kamal user
chmod 700 /home/kamal/.ssh
chmod 600 /home/kamal/.ssh/authorized_keys
chown -R kamal:kamal /home/kamal/.ssh

# Disable root login
sed -i '/PermitRootLogin/d' /etc/ssh/sshd_config
echo "PermitRootLogin no" >> /etc/ssh/sshd_config
systemctl restart sshd

# Add the kamal user to the docker group
usermod -aG docker kamal
docker network create --driver bridge kamal_network

# Create a folder for the Let's Encrypt ACME JSON
mkdir -p /letsencrypt && touch /letsencrypt/acme.json && chmod 600 /letsencrypt/acme.json
chown -R kamal:kamal /letsencrypt

reboot

To run these commands, you need to login as root. This assumes that there isn’t already a non-root user with UID 1000. Otherwise, you’ll have to adjust the commands accordingly.

Also, if you don’t have a public SSH key for the “Add SSH key” step, you can generate one with the following command:

ssh-keygen -t ed25519 -C "[email protected]"

These commands will:

  1. Install docker, curl, and git
  2. Start and enable the docker service
  3. Create a non-root user called kamal
  4. Disable root login
  5. Add the kamal user to the docker group (this allows the user to run docker without needing to use sudo)
  6. Create a Docker bridge network for Traefik
  7. Create a folder for the Let’s Encrypt ACME JSON file
  8. Make the Let’s Encrypt ACME JSON folder writable by the kamal user
  9. Restart the server

Finally, configure the SSH key in your local .ssh/config file so you can login as the kamal user without using the root account.

Host kamal
  HostName <YOUR_VPS_IP>
  User kamal
  IdentityFile ~/.ssh/<YOUR_PRIVATE_SSH_KEY>

Create a Dockerfile for your FastAPI app

Kamal works with containerized apps, so you’ll need to have a Dockerfile. I also recommend using an entrypoint.sh script to run the application, because that also allows you to run commands in the container.

Dockerfile

Here’s the Dockerfile I’m using for my projects. You can use this as a template and adjust it to your needs.

Dockerfile
FROM python:3.10-slim AS base

ENV POETRY_HOME=/opt/poetry
ENV POETRY_VERSION=1.8.3
ENV PATH=${POETRY_HOME}/bin:${PATH}

RUN apt-get update \
    && apt-get install --no-install-recommends -y \
    curl \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

RUN curl -sSL https://install.python-poetry.org | python3 - && poetry --version

FROM base AS builder

WORKDIR /app

COPY poetry.lock pyproject.toml ./

RUN poetry config virtualenvs.in-project true && \
    poetry install --only main --no-interaction

FROM base AS runner

WORKDIR /app
COPY --from=builder /app/.venv/ /app/.venv/

COPY . /app

RUN chmod +x /app/entrypoint.sh

FROM runner AS production

EXPOSE 8000

ARG user=kamal
ARG group=kamal
ARG uid=1000
ARG gid=1000
RUN groupadd -g ${gid} ${group} && \
    useradd -u ${uid} -g ${group} -s /bin/sh -m ${user} && \
    chown -R ${uid}:${gid} /app

USER ${uid}:${gid}

WORKDIR /app
CMD [ "/app/entrypoint.sh" , "app"]

This multi-stage Dockerfile does the following:

  1. Installs poetry and sets up the virtual environment
  2. Creates the user kamal with the UID and GID 1000 and runs the application with that user.
  3. Exposes port 8000 and runs the application by executing the entrypoint.sh script. Kamal automatically detects that is the port the app runs on and will use that to set up the reverse proxy.

Feel free to adjust this Dockerfile to your needs.

entrypoint.sh script

I use an entrypoint.sh script to run the application because that makes it easier to collect static files, run migrations when the container starts, and also running commands in the container.

Here’s an example of a simple entrypoint.sh script:

entrypoint.sh
#!/bin/sh

set -e

if [ "$1" = "app" ]; then
    echo "Collecting static files"
    exec poetry run gunicorn -c gunicorn.conf.py
else
    exec "$@"
fi

This script starts the gunicorn server with uvicorn workers and some sensible defaults. It also allows you to pass other arguments to the script, which is useful if you want to run other commands in the container. You can add or remove commands to the script as needed.

Configure an ECR registry in AWS

Next, you’ll need a place to push and pull your Docker images. I use AWS ECR, so that’s what I’ll show you how to do here. Kamal also supports other registries.

Log in to the AWS Management Console and go to Amazon ECR. Click on Create repository and set a name for your repository.

Then, create a new IAM user in your AWS account by going to Services > IAM > Users > Add user.

During the process you’ll have to assign a permissions to the user. You can create a new policy with the following content and attach it to the user:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListImagesInRepository",
      "Effect": "Allow",
      "Action": ["ecr:ListImages"],
      "Resource": [
        "arn:aws:ecr:<REGION>:<ACCOUNT_ID>:repository/<REPOSITORY_NAME>"
      ]
    },
    {
      "Sid": "GetAuthorizationToken",
      "Effect": "Allow",
      "Action": ["ecr:GetAuthorizationToken"],
      "Resource": "*"
    },
    {
      "Sid": "ManageRepositoryContents",
      "Effect": "Allow",
      "Action": [
        "ecr:BatchCheckLayerAvailability",
        "ecr:GetDownloadUrlForLayer",
        "ecr:GetRepositoryPolicy",
        "ecr:DescribeRepositories",
        "ecr:ListImages",
        "ecr:DescribeImages",
        "ecr:BatchGetImage",
        "ecr:InitiateLayerUpload",
        "ecr:UploadLayerPart",
        "ecr:CompleteLayerUpload",
        "ecr:PutImage"
      ],
      "Resource": [
        "arn:aws:ecr:<REGION>:<ACCOUNT_ID>:repository/<REPOSITORY_NAME>"
      ]
    }
  ]
}

This policy enables users to list, access, and manage the ECR repository they have previously created, as well as obtain an authorization token necessary for pushing and pulling images. You must replace <REGION>, <ACCOUNT_ID>, and <REPOSITORY_NAME> with the specific details of your own repository.

Then, select the user you created and navigate to Security credentials > Access keys > Create access key. Download the generated CSV file and store it in a secure location.

The GitHub Actions workflow will use these credentials for pushing and pulling images from the ECR registry.

Set up Kamal in your project

Open your FastAPI project in your favorite code editor. Create a folder called deploy in the root directory. Then go into the folder and initialize Kamal:

kamal init

This will create two folders (.kamal/ and config/) and an .env file. Inside config/, you’ll find a deploy.yml file. This is where you’ll provide the instructions for Kamal to build and deploy your app.

You can use the following deploy.yml file as a template for your FastAPI app:

deploy.yml
service: example

image: example

ssh:
  user: kamal

env:
  secret:
    - FASTAPI_ENV

traefik:
  options:
    publish:
      - "443:443"
    volume:
      - "/letsencrypt/:/letsencrypt/"
    memory: 500m
    network: private_network
  args:
    entryPoints.web.address: ":80"
    entryPoints.websecure.address: ":443"
    entryPoints.web.http.redirections.entryPoint.to: websecure
    entryPoints.web.http.redirections.entryPoint.scheme: https
    entryPoints.web.http.redirections.entrypoint.permanent: true
    certificatesResolvers.letsencrypt.acme.email: <YOUR_EMAIL>"
    certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json"
    certificatesResolvers.letsencrypt.acme.httpchallenge: true
    certificatesResolvers.letsencrypt.acme.httpchallenge.entrypoint: web

servers:
  web:
    hosts:
      - 128.140.0.209
    healthcheck:
      port: 8000
      interval: 5s
    options:
      network: private_network
    labels:
      traefik.http.routers.app.tls: true
      traefik.http.routers.app.entrypoints: websecure
      traefik.http.routers.app.rule: Host(`<YOUR_DOMAIN>`)
      traefik.http.routers.app.tls.certresolver: letsencrypt

registry:
  server: <account_id>.dkr.ecr.<region>.amazonaws.com
  username: AWS
  password:
    - KAMAL_REGISTRY_PASSWORD

builder:
  dockerfile: "../Dockerfile"
  context: "../"
  multiarch: false
  cache:
    type: gha

This will set up your app and a reverse proxy using Traefik (with automatic SSL certificates using Let’s Encrypt). Remember to replace the placeholders with your own values. It will also do a healthcheck on /up on port 8000.

Test the configuration locally

To test it locally, first, you must define the required environment variables in .env, such as keys for AI services, email providers, etc.

You’ll also need to get a temporary password to authenticate into the ECR registry. You can get this password by running the following command from your terminal:

aws ecr get-login-password --region <YOUR_REGION>

You should copy the output of this command and paste it in the KAMAL_REGISTRY_PASSWORD field in the .env file.

Then, run the following command to deploy your application to your VPS:

kamal env push
kamal deploy

The first command will push the environment variables to the VPS. The second command will build the Docker image, push it to the ECR registry, and deploy it to your VPS.

After a few minutes, your app should be live at https://<YOUR_DOMAIN>.

If you see any errors, you can:

  1. Run kamal app logs to see the logs of the app.
  2. Open a terminal in the container by running kamal app exec -it bash.

This is how I usually debug the app.

Automate the deployment with Github Actions

Now that you have a working deployment process in your local environment, you can set up your CI/CD pipeline using GitHub Actions.

Create a new file in the .github/workflows folder called deploy.yml and add the following code:

name: Deploy FastAPI app to VPS
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

on:
  push:
    branches: ["main"]
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - uses: webfactory/[email protected]
        with:
          ssh-private-key: ${{ secrets.VPS_SSH_PRIVATE_KEY }}

      - name: Set up Ruby and install kamal
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.2.2
      - run: gem install kamal -v 1.9.0

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID_ECR }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY_ECR }}
          aws-region: us-east-1
          mask-aws-account-id: false # otherwise the mask will hide your account ID and cause errors in the deployment

      - name: Login to AWS ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Set up Docker Buildx for cache
        uses: docker/setup-buildx-action@v3

      - name: Expose GitHub Runtime for cache
        uses: crazy-max/ghaction-github-runtime@v3

      - name: Create .env file
        run: |
          cd <YOUR_PROJECT_ROOT>/deploy
          touch .env
          echo KAMAL_REGISTRY_PASSWORD="${{ steps.login-ecr.outputs.docker_password_<YOUR_ACCOUNT_ID>_dkr_ecr_<YOUR_REGION>_amazonaws_com }}" >> .env
          # if you have other secrets, add them here
          cat .env

      - name: Kamal Deploy
        id: kamal-deploy
        run: |
          cd <YOUR_PROJECT_ROOT>/deploy
          kamal lock release
          kamal env push
          kamal deploy

This workflow will:

  1. Checkout the code
  2. Set up the Ruby environment and install Kamal
  3. Configure the AWS credentials
  4. Login to the AWS ECR registry
  5. Set up Docker Buildx for cache
  6. Expose GitHub Runtime for cache
  7. Create the .env file
  8. Run Kamal deploy

It will run everytime you make a push to the main branch or by manually triggering the workflow. It’ll cancel any in-progress runs to avoid conflicts.

Also, before you push your code to the repository, you’ll need to add the following secrets to the repository:

  • VPS_SSH_PRIVATE_KEY: The private key to connect to your VPS
  • AWS_ACCESS_KEY_ID_ECR: The access key ID for the AWS ECR registry
  • AWS_SECRET_ACCESS_KEY_ECR: The secret access key for the AWS ECR registry

Finally, to speed up the deployment, add these options to the builder section of the deploy.yml file:

builder:
  dockerfile: "../Dockerfile"
  context: "../"
  multiarch: false # new
  cache: # new
    type: gha # new

This will enable the Docker Buildx cache for the build process in Github Actions. You can set multiarch to false if your CI pipeline shares the same architecture as your VPS, which was the case for me.

Conclusion

You now have a fully automated deployment pipeline for your FastAPI app. A push to the main branch will trigger the workflow, that will build the Docker image, push it to the ECR registry, and deploy it to your VPS.

Break free from the tyranny of manual deployments and expensive cloud services. Sleep like a baby and let Kamal handle your deployments.

If you have any questions or feedback, please feel free to leave a comment below.

Citation

BibTeX citation:
@online{castillo2024,
  author = {Castillo, Dylan},
  title = {Deploying a {FastAPI} App with {Kamal,} {AWS} {ECR,} and
    {Github} {Actions}},
  date = {2024-09-21},
  url = {https://dylancastillo.co/posts/deploy-a-fastapi-app-with-kamal-aws-ecr-and-github-actions.html},
  langid = {en}
}
For attribution, please cite this work as:
Castillo, Dylan. 2024. “Deploying a FastAPI App with Kamal, AWS ECR, and Github Actions.” September 21, 2024. https://dylancastillo.co/posts/deploy-a-fastapi-app-with-kamal-aws-ecr-and-github-actions.html.