Deploying a FastAPI app with Kamal, AWS ECR, and Github Actions
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:
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:
- Install docker, curl, and git
- Start and enable the docker service
- Create a non-root user called kamal
- Disable root login
- Add the kamal user to the docker group (this allows the user to run docker without needing to use
sudo
) - Create a Docker bridge network for Traefik
- Create a folder for the Let’s Encrypt ACME JSON file
- Make the Let’s Encrypt ACME JSON folder writable by the kamal user
- 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:
- Installs poetry and sets up the virtual environment
- Creates the user
kamal
with theUID
andGID
1000 and runs the application with that user. - 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
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:
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:
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:
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:
- Run
kamal app logs
to see the logs of the app. - 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:
- Checkout the code
- Set up the Ruby environment and install Kamal
- Configure the AWS credentials
- Login to the AWS ECR registry
- Set up Docker Buildx for cache
- Expose GitHub Runtime for cache
- Create the
.env
file - 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 VPSAWS_ACCESS_KEY_ID_ECR
: The access key ID for the AWS ECR registryAWS_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
@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}
}