~ 6 min read

Deploying a FastAPI app to Hetzner with Kamal

Kamal is a tool written by the team at 37signals to deploy web applications. It uses just an ssh connection and docker - downloading all dependencies you need for your application onto your server. It will handle provisioning and renewing certificates using letsencrypt and offers features like rolling restarts.

If you know anything about 37signals you may be aware that they build basecamp.com - a Ruby on Rails web application and as such Kamal is written in Ruby. Fear not though, Kamal is commandline based and will deploy applications written in any language, so long as you’re have a docker image for it.

I was pretty taken with the use of ssh and docker alone, since that’s what I’m currently doing for my own projects. For most of the things I do, I do them in Python and don’t want anywhere near the complexity of running a kubernetes cluster. I just want to build features. It’s also nice that it understands that I’m going to be deploying a web application, removing the need for a lot of boilerplate if I were doing things from scratch myself.

Server Config

It’s worth configuring infrastructure and dns records along for any domains you want to be before anything else and give them time to propogate. I pointed my domains nameservers to Hetzner and made sure I had an A record for the app subdomain I’d be using with Kamal pointing to a shiny new instance.

Sadly the dns entries for Hetzner can’t easily be created with terraform and fail, so it requires some manual work in their web app.

I used a pre configured ssh key stored within Hetzner on my instance, but Kamal will ask for a root password if it can’t connect during setup.

Kamal Install

Kamal offers two installation methods, either via Ruby or Docker. Since I don’t have a lot of experience with Ruby, I opted for Docker - which was a mistake on my part. You’ll be using an alias for a docker command, that doesn’t pick up environment variables so you’ll hit problems as soon as you need to pass secrets to Kamal. Unfortunately at the moment, anything other than using Ruby isn’t well documented - here’s an issue on their repo where someone comments regarding that.

After realising this, I installed the latest version of Ruby via rbenv using brew. Things went a lot smoother from there on.

My FastAPI App

In my case I want to deploy a very basic FastAPI app written in a single main.py file, that defines it’s dependencies in a requirements.txt - but configuration is the same for most apps. Here’s what the Dockerfile for my app looks like:

FROM python:3.12-bullseye

ENV PYTHONUNBUFFERED True
ENV APP_HOME /app
WORKDIR $APP_HOME
COPY . ./

RUN pip install --no-cache-dir -r requirements.txt

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

NB: It’s likely your app won’t need to use 3.12-bullseye and you could go straight for 3.12-slim-bullseye. I had a couple of deps that need to be built and therefore require gcc to be installed. I ended up moving to a multi-stage build later

It goes without saying that you should ensure this image is working locally before going any further.

Kamal Config

In order to configure your deployment, move to your application directory and type:

kamal init

This creates a config/deploy.yml file with a whole bunch of template filler you should update to suit you. Here’s what mine ended up looking like:

service: mywebapp

# Name of the container image.
image: iwootten/mywebapp

# Deploy to these servers.
servers:
  web:
    - MY_SERVER_IP
  # job:
  #   hosts:
  #     - 192.168.0.1
  #   cmd: bin/jobs

# Enable SSL auto certification via Let's Encrypt (and allow for multiple apps on one server).
# If using something like Cloudflare, it is recommended to set encryption mode 
# in Cloudflare's SSL/TLS setting to "Full" to enable end-to-end encryption. 
proxy: 
  ssl: true
  host: app.mywebapp.com
  # kamal-proxy connects to your container over port 80, use `app_port` to specify a different port.
  app_port: 8000
  healthcheck:
    path: /healthcheck/
    interval: 2
    timeout: 2

# Credentials for your image host.
registry:
  # Specify the registry server, if you're not using Docker Hub
  # server: registry.digitalocean.com / ghcr.io / ...
  username: iwootten

  # Always use an access token rather than real password (pulled from .kamal/secrets).
  password:
    - KAMAL_REGISTRY_PASSWORD

# Configure builder setup.
builder:
  arch: amd64

It also creates a .kamal/secrets file that defines where secrets come from. You shouldn’t be storing actual secrets here and they should instead come from env vars or a secrets manager. As detailed there, they need to be ‘git safe’.

Kamal will build your image and push it to an image registry locally so your server can use it. Within the registry settings KAMAL_REGISTRY_PASSWORD by default refers to the password you’re using to log in to hub.docker.com with. You can use any image registry you like so long as it is configured appropriately. To check if it works ok before setup you can run kamal registry login.

Notice the healthcheck endpoint - this caught me out a bit, by default kamal looks for a endpoint within your images at /up (although this isn’t mentioned in the default template anywhere). Whilst setting things up it will try and hit it every second and if it can’t find it, rollback. When deploying my app for the first time, it would build and deploy happily but always resulted in failure and rollback to…nothing. After realising, I modified it as you can see above to suit my app routes and managed to deploy.

Kamal understands git and will ignore anything that hasn’t been committed. If the app you’re working in is a repo, you’ll need to ensure that any changes you want to see deployed are committed.

If you’re happy with the configuration changes you’ve made, to deploy you can run:

kamal setup

This will run through a full build, push to your image registry, deploy, provision certs and start your images. After hitting a few issues, tweaking the config outlined above this worked great.

Kamal has a huge collection of options other than the few covered here. For instance kamal redeploy will skip server setup and deploy only. kamal app logs let you inspect the most recent logs to docker. There’s a multitude of them to explore.

I love the fact that this is another option for me to deploy web apps to any provider I choose. I’ve chosen a single Hetzner server here, which is incredibly reasonable - but I could equally be using multiple servers or a mix of providers. It’s likely I’ll be migrating some of my other projects over to Kamal now I’ve gone through this.

Subscribe for Exclusives

My monthly newsletter shares exclusive articles you won't find elsewhere, tools and code. No spam, unsubscribe any time.