Deploying FastAPI Python App With Kamal: Self-Hosted CI/CD & DevOps for €5/mo

A simple Hello World-like FastAPI application deployed to a Hetzner host. We’ll use Kamal, Docker, Github Actions and Cloudflare.

This article is in pre-release stage (97% complete), and is available to the readers. It may have some minor details missing, so please leave feedback via X DM, this Tweet or a comment below this post.
What is Kamal?
Kamal offers zero-downtime deploys, rolling restarts, asset bridging, remote builds, accessory service management, and everything else needed to deploy and manage the web app in production with Docker. Originally built for Rails apps, Kamal will work with any type of web app that can be containerized.


Let’s imagine we have a product called Crabcoin. It’s written in Python using FastAPI Framework. We will need to deliver it to our customers with minimal DevOps skills.

In order to follow this scenario it’s not required to be an expert. Basic Linux, Docker and Git skills would be enough.

  1. App is available on domain.
  2. App hosted on inexpensive Hetzner VM (~€3-5)
  3. DNS and TLS configured via Cloudflare.
  4. FastAPI App is Dockerized, deployed via Kamal and stored on GitHub.
  5. Application automatically deployed on a push to main branch.
Features Planned

Crabcorp has more features on the roadmap.

Please follow to stay updated!

This document is loosely broken down into two sections: Dev & Ops.
We will spend some time on best-practices on how to Dockerize Python application, but the most focus would be on the Ops part where we would discuss how to deploy it.


Dev part will be bare-minimum, just enough to get to the Ops part.

This is going to be a new git repo. Initialize it like this:

❯ mkdir -p crabcoin-web
cd crabcoin-web
❯ mkdir app
❯ git init .

In one of the next steps we’ll be pushing this repo to GitHub, but for now let’s move on to adding code.

We’ll be using pyenv to manage our Python versions. It’s a convenient tool to choose Python version that a specific project needs. In this scenario let’s target version 3.11.

Let’s follow Pyenv Installation Instructions, and when it’s installed let’s install Python 3.11:

(It will take a while):

❯ pyenv install 3.11

python-build: use openssl@1.1 from homebrew
python-build: use readline from homebrew
Downloading Python-3.11.6.tar.xz...
Installing Python-3.11.6...
... omitted for brevity ...

We’ll set the local version (system version won’t be affected)

❯ pyenv local 3.11

It will create a .python-version file, make sure to keep it in the git repo.

Let’s confirm the current Python version, it should start with 3.11.

It may be a different patch version (different from 6.

❯ python --version
Python 3.11.6

It’s recommended to use direnv with Pyenv to automatically set the Python version when entering the directory. There is an excellent DigitalOcean guide on How To Manage Python with Pyenv and Direnv.

We’ll be using poetry for our package and virtual environment management. It’s a standard way for Python and highly recommended.

We will install packages later, so please skip the interactive dependencies. Basically the whole process is to hit <RETURN> till the end.

❯ poetry init

This command will guide you through creating your pyproject.toml config.

Package name [crabcoin-web]: <RETURN>
Version [0.1.0]: <RETURN>
Description []: <RETURN>
Author [AutomationD <>, n to skip]: <RETURN>
License []: <RETURN>
Compatible Python versions [^3.9]:  <RETURN>
Would you like to define your main dependencies interactively? (yes/no) [no] <RETURN>
Would you like to define your development dependencies interactively? (yes/no) [no] <RETURN>
Do you confirm generation? (yes/no) [yes] <RETURN>

This way we’ll tell poetry to use a specific Python version in the the virtualenv.

❯ poetry env use 3.11

Using virtualenv: /Users/AutomationD/crabcoin/crabcoin-web/.venv

Packages will be installed into a virtualenv and available for our use.

❯ poetry add fastapi "uvicorn[standard]"

... Omitted for brevity ...
Package operations: 18 installs, 0 updates, 0 removals
... Omitted for brevity ...

It’s time to create our first application. We’ll be using a very simple example from FastAPI First Steps. Create app/ like this:

from fastapi import FastAPI

app = FastAPI()

async def root():
    return {"message": "Hello World"}

async def up():
    return {"status": "OK"}

To see if it works run:

❯ poetry run uvicorn app.main:app --host --port 3000

Now browse to There should be a message in the browser {"message":"Hello World"}

Create .gitignore with the following contents:


Let’s commit our changes to git.

❯ git commit -am "Initial"
[main (root-commit) 9f3f30d] Initial
 1 file changed, 8 insertions(+)
 create mode 100644 app/

This code will remain local for now (But we’ll push it later to GitHub). Our Dev part is done here.

Let’s move on to Ops!


Kamal is written in Ruby, so there are two ways to run Kamal, I prefer to run it natively so there is no Docker overhead. Feel free to skip RVM, Ruby and Kamal installation if Docker approach is more acceptable, as described here.

RVM is a Ruby Version Manager: the best way to install and manage Ruby. Follow Docs on how to install it.

Latest stable Ruby version was 3.2.0, so we are going to install it

❯ rvm install 3.2.0
❯ rvm 3.2.0
❯ gem install kamal
❯ kamal version


More info on Kamal installation

We’ll be using Github Packages to store our Docker images. It’s an easy way to get started. Free account allows us to store up to 500MB. It’s not a lot, but it’s a good start. Pricing for other plans is available here.

Setting up a container registry on Github is rather simple, the steps are the following:

  1. Get PAT Token Classic (Personal Access Token) (ideally scoped just to packages)
  2. Export token to your environment via export CR_PAT=<YOUR_TOKEN>, but better use direnv
  3. Login with a Docker client to the Github Package registry via echo $CR_PAT | docker login -u USERNAME --password-stdin

Now we are ready to do docker push to github container Github Container Registry has great documentation on that topic.

We’ll be using Hetzner Cloud to host our servers. Go ahead and create a new project for our app.


Then click on Add Server

Select Location (We’ll choose Helsinki this time for no reason)


Select Ubuntu 22.04 (or the current LTS). Most likely it will be pre-selected.


Select Server Type. We’ll choose CPX11 which would give us 40GB of SSD, 2vCPU and 2GB of RAM. Should be plenty for our small application.


Keep Networking as-is


Configure SSH Key. Get a public key for the next step. On MacOS one can use pbcopy as follows: cat ~/.ssh/ | pbcopy


Paste a public key and click Add SSH Key


There are other useful options, but we’ll scroll all the way to the Server Name. Enter a name for this specific, we’ll use, (as in web 100). This is going to be an “internal” DNS name, meaning it won’t be visible to a customer (we’ll create that one later).


Check final pricing and click “Create & Buy now”


Eventually the page will be redirected to the server list, find

Please note the IP address, we will need it later, in our case it’s


It would be useful to confirm there is ssh access to our new host.

❯ ssh root@

The authenticity of host ' (' can't be established.
ED25519 key fingerprint is SHA256:abcdefghklmnopqrstf2ca1bb6c7e907d06dafe4687e579f2.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])?

-> yes


If there is a prompt it means it’s possible to connect. Moving on to the next step!

We assume we have already configured Cloudflare to host our dns zone for If not, please read on How to Add a Domain From Namecheap to Cloudflare.

In order to configure DNS records for please select the domain in a Cloudflare account and then go to DNS -> Records. th


This configuration is for our internal needs to connect to a host via a domain and not IP address. This domain won’t be seen by a customer The goal is to

  1. Create an A record to point --A--> (IP from ssh test)
  2. Don’t enable Cloudflare Proxy Click on Add record

Enter record data:

  • Name: w100
  • IP:
  • Proxy Status: Disabled
    Disabled Proxy Warning

    Disabling Cloudflare Proxy is not entirely secure for production as it exposes host’s IP address, but it’s sufficient for the purpose of this Scenario.

    Safe to ignore for now, but for more information on SSH + CloudFlare Proxy please refer to Connect with SSH through Cloudflare Tunnel

Test this by running:

❯ nslookup

❯ nslookup

Non-authoritative answer:

If the nslookup command returns correct IP it means that a DNS record is working. If it doesn’t return correct result, ensure that:

  1. NS Servers are configured correctly (How to Add a Domain From Namecheap to Cloudflare._)
  2. A bit of time has passed, since DNS propagation is much slower than changes on other layers.

At this stage it’s possible to ssh into the host via it’s domain name:

❯ ssh

This configuration is for a public app. This domain will be seen by a customer. The goal is to:

  1. Create an A record to point --A--> (IP from ssh test)
  2. Enable Cloudflare Proxy Click on Add record

Enter record data:

  • Name: app
  • IP:
  • Proxy Status: Enabled


kamal init
FROM python:3.11.6

ARG ENV='dev'
ARG USER_FOLDER="nonexistent"
ARG PORT="3000"


# Order is designed to cache packages if the lock file hasn't changed.
COPY ./poetry.lock ./pyproject.toml /app/
COPY ./ /app/

RUN pip install "poetry==$POETRY_VERSION"

  poetry config virtualenvs.create false && \
  poetry install --no-interaction --no-ansi $(test "$ENV" = 'prod' && echo "--no-dev")


CMD ["uvicorn", "app.main:app", "--host", "", "--port", "3000"]

Edit config/deploy.yml

service: crabcoin-web
image: automationd/crabcoin-web

  username: USERNAME
    - CR_PAT

      - 443:443
    entryPoints.web.address: ":80" websecure
    entryPoints.web.http.redirections.entryPoint.scheme: https
    entryPoints.web.http.redirections.entrypoint.permanent: true

    entryPoints.websecure.address: ":443"
    entryPoints.websecure.forwardedHeaders.trustedIPs: ",,,,,,,,,,,,,,,," # CloudFlare IPs per
    entrypoints.websecure.http.tls: true[0].main: ""
    accesslog: true
    accesslog.format: json
❯ kamal setup

Head to Create New Repo and create a new crabcoin-web repo. We will use it later to push our local code.

Click on Create repository

Copy git url, we’ll use it later.

In this scenario it would be:

Create .github/workflows/prod.deploy.yml

name: "[prod] Crabсoin Deploy"
    shell: bash

      - main

    runs-on: ubuntu-latest
      - name: Checkout Code
        uses: actions/checkout@v2
          submodules: true

      # Needed for Kamal
      - uses: ruby/setup-ruby@v1
          bundler-cache: true

      - name: Install Kamal
        run: gem install kamal

      - name: Install SSH key
        uses: shimataro/ssh-key-action@v2
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          name: id_rsa
          known_hosts: unnecessary
          if_key_exists: replace

      - name: Deploy
        run: kamal deploy
          GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

Got to Settings of the repo, then to Actions / General


Then scroll down to Workflow Permissions and allow the following setting:


Also, make sure to Associate Package with Repo

git remote add origin
git commit -am "Initial Commit"
git push --set-upstream origin main

Navigate to Actions section of the repo


Watch the CI/CD pipeline. If deployment is successful it would be green


For auto-completion on Kamal syntax in the yaml file it’s possible to configure Schema.

Use schema.yaml For VSCode Add this to a config/deploy.yml file

# yaml-language-server: $schema=

Please refer to the official doc and use the schema.yaml and configure Schema Mapping in JSON Schema Mappings for config/deploy.yml file.

Upcoming Improvements

Current application will be improved, and the following upgrades are planned:

  • Load Balancing
  • Database
  • Celery with Workers

Subscribe in order not to miss them!

Inspired by:

Post photo used by @wolfgang_hasselmann

Related Content