I just migrated two of my production Django apps (Place Card Me
and Translation Creator) to a
single, shared Hetzner VPS using Kamal.
If you haven’t heard of it, Kamal is a tool built by the Rails community to
deploy applications to any server.
They recently released Kamal 2, which makes it easy to deploy multiple apps to the same server,
which really helps cut down on costs.
Overall the process was quite smooth!
And there is still plenty of headroom on the $10/month machine for several more projects.
I did the migration in two steps:
- Deploy the apps to a new server.
- Migrate the data and flip DNS.
If you’re deploying a new application, you can just do step 1 and you should be done.
If you’re migrating from an existing installation you can also do step 2,
or do the migration in place (scarier).
Here’s the detailed steps I followed:
Provision the server
As I mentioned already, I use Hetzner for my VPS hosting these days.
They have the most affordable hosting out there. But you could do this on Digital Ocean, Linode, or wherever.
I provisioned a 4GB, shared CPU machine on Ubuntu 24 which, at the time of this writing, costs €7.05 ($7.71) / month.
I also threw in automatic backups—since I’m hosting my own database on the machine—bringing
the monthly total to a whopping $9.80 a month.
Remarkably, if you use Kamal 2’s defaults, this is the only setup step required!
There are no other steps needed to deploy the app.
Create a production Dockerfile
Kamal uses Docker to deploy your apps, so you need a Dockerfile to tell it how to build and run your app.
I build all my applications with my SaaS Pegasus Django boilerplate,
which ships with a production Dockerfile.
But the gist of it is basically setting up the project and running gunicorn
.
Pegasus uses a multi-stage Dockerfile to improve performance and caching.
Here’s a slightly simplified version of the file:
# Dockerfile.web
# Multi-stage Docker buildfile
# See https://docs.docker.com/build/guide/multi-stage/
# Stage 1: Build the python dependencies
FROM python:3.11-slim-bookworm as build-python
RUN --mount=target=/var/lib/apt/lists,type=cache,sharing=locked \
--mount=target=/var/cache/apt,type=cache,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean && \
apt-get update \
# dependencies for building Python packages
&& apt-get install -y build-essential libpq-dev
COPY ./requirements /requirements
RUN --mount=type=cache,target=/root/.cache \
pip wheel --no-deps --wheel-dir /wheels \
-r /requirements/requirements.txt
# Stage 2: Build the front end files
FROM node:20-bookworm-slim AS build-node
RUN nodejs -v && npm -v
WORKDIR /code
COPY *.json *.js .babelrc /code/
COPY assets /code/assets/
RUN npm install
COPY . /code
RUN npm run build
# Stage 3: Build the final image
# This copies the python dependencies from the first stage
# and the front end files from the second stage.
FROM python:3.11-slim-bookworm
ENV PYTHONUNBUFFERED 1
ENV DEBUG 0
RUN --mount=target=/var/lib/apt/lists,type=cache,sharing=locked \
--mount=target=/var/cache/apt,type=cache,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean && \
apt-get update \
&& apt-get install -y \
curl \
# psycopg2 dependencies
libpq-dev \
# Translations dependencies
gettext \
# cleaning up unused files
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false
RUN addgroup --system django \
&& adduser --system --ingroup django django
COPY --from=build-python /wheels /wheels
COPY ./requirements /requirements
RUN --mount=type=cache,target=/root/.cache \
pip install --no-index --find-links=/wheels \
-r /requirements/requirements.txt \
&& rm -rf /wheels
WORKDIR /code
COPY --chown=django:django . /code
COPY --from=build-node /code/static /code/static
# collect static files for whitenoise
RUN DEBUG=False python /code/manage.py collectstatic --noinput --settings=translationcreator.settings_production
RUN chown django:django -R static_root
USER django
COPY --chown=django:django deploy/docker_startup.sh /start
RUN chmod +x /start
CMD /start
Dockerfile for building and running a Django application.
Staticfiles are collected during the Docker build process and served by whitenoise.
The docker_startup.sh
file that gets called at the end does our migrations and then starts gunicorn
:
# docker_startup.sh
#!/bin/bash
PORT=${PORT:-8000}
echo "Django migrate"
python manage.py migrate --noinput
echo "Run Gunicorn"
gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 8 --timeout 0 translationcreator.asgi:application -k uvicorn.workers.UvicornWorker
Startup script to run database migrations and start gunicorn.
Create a Kamal deploy configuration
Next we need to create a Kamal deploy configuration file, which looks like this:
# deploy.yml
service: translationcreator
image: translationcreator
env:
# This is the minimal set of environment variables needed to run the app
# in production. You can add more here and in the .env file as needed.
clear:
DJANGO_SETTINGS_MODULE: translationcreator.settings_production
USE_S3_MEDIA: 'True'
AWS_STORAGE_BUCKET_NAME: translationcreator-media
ALLOWED_HOSTS: www.translationcreator.com
# ... other clear variables here
secret:
- POSTGRES_PASSWORD
- SECRET_KEY
- DATABASE_URL
- REDIS_URL
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY
# ... other secrets here
# https://kamal-deploy.org/docs/configuration#using-different-roles-for-servers
servers:
web:
hosts:
- 5.161.204.158
options:
expose: "8000"
# See https://kamal-deploy.org/docs/configuration/builders/ for config options
builder:
arch: amd64
dockerfile: './Dockerfile.web'
# See https://kamal-deploy.org/docs/configuration/docker-registry/ for config options
# This example uses Amazon ECR
registry:
server: redacted.dkr.ecr.us-east-1.amazonaws.com
username: AWS
password: <%= %x(aws ecr get-login-password) %>
accessories:
postgres:
image: postgres
host: 5.161.204.158
directories:
- data:/var/lib/postgresql/data
env:
clear:
POSTGRES_USER: translationcreator
POSTGRES_DB: translationcreator
secret:
- POSTGRES_PASSWORD
redis:
image: redis
host: 5.161.204.158
# See https://kamal-deploy.org/docs/configuration/proxy/ for config options
proxy:
ssl: true
host: www.translationcreator.com
app_port: 8000
healthcheck:
path: /kamal/up/
interval: 2
timeout: 2
Kamal’s deploy.yml
configuration.
A few notable changes from the default Kamal configuration:
- We’ve added postgres and redis accessories for our DB and cache.
- We’ve made some modifications to make the app work on port 8000.
- We’ve set up a custom healthcheck endpoint (more on that later).
Finally, you will need to set any secrets you need in .kamal/secrets
.
# .kamal/secrets
POSTGRES_PASSWORD=your-secret-postgres-password
# Django settings
SECRET_KEY=some-super-long-key
DATABASE_URL=postgres://translationcreator:your-secret-postgres-password@translationcreator-postgres:5432/translationcreator
REDIS_URL=redis://translationcreator-redis:6379
# ...more secrets here
Kamal’s secrets
file for storing…well, secrets.
One thing to note is the use of translationcreator-postgres
and translationcreator-redis
as the hostnames
for your database connection strings. These are determined by your service name (translationcreator) and accessory type
(postgres/redis) as described here.
This should be everything you need to deploy your application!
Finally, run:
Which will magically do everything it needs to deploy your app!
And then… fail.
The failure has to do with the interplay between Kamal’s health checks and Django’s security features.
Here’s how to deal with that.
Workaround health checks and Django’s security settings
Kamal runs “health checks” against your application during deploy.
These will hit a predefined URL in your app and check for a 200 response code.
If it doesn’t get a 200 back, it fails the deploy (and rolls it back if it’s not the first time).
Health checks are configured in the proxy
section of your deploy.yml
file:
proxy:
ssl: true
host: www.translationcreator.com
app_port: 8000
healthcheck:
path: /kamal/up/
interval: 2
timeout: 2
Here we are saying “hit the /kamal/up/ URL on port 8000 every 2 seconds until it returns ‘OK’”.
However, there are two problems with this in a Django environment which are both related to security settings.
The first is related to the ALLOWED_HOSTS
setting.
Kamal makes the request from inside the Docker network using the container’s ID as the hostname.
So the request will look like it was made to something like http://7c7168a662c1:8000, which results in Django throwing
an “Invalid HTTP_HOST header” error if you are using ALLOWED_HOSTS
The second is a similar problem with Django’s SECURE_SSL_REDIRECT
setting.
The request comes in over http, and Django immediately tries to redirect it to https, but https is not set up at this low a level
(it is set from outside the proxy / Docker network), so the redirect hangs.
The workaround in both cases is to bypass the security middleware for just the health check URL.
This is documented in this Github issue.
Basically you can create a middleware that handles the URL Kamal will check and return a 200 response.
Here’s the code:
# <project>/middleware.py
from django.http import HttpResponse
class HealthCheckMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.path == "/kamal/up/":
# Or perform any "real" health checking, if needed
response = HttpResponse("OK")
else:
response = self.get_response(request)
return response
Then, add it to the very beginning of your settings.MIDDLEWARE
and your health checks will pass!
After that, kamal setup
and kamal deploy
worked like a charm.
Migrating data
If you are migrating from Kamal 1 to Kamal 2 then you aren’t quite done.
In my case, I found it safer to re-deploy my application on a new server, then backup/restore the Postgres
database and flip DNS.
This was fine for me as I could tolerate ~10 minutes of the app being in readonly mode.
That process entailed backing up and restoring the database as outlined in the
Pegasus docs here.
And finally rerouting my DNS from the old server to the new server.
Kamal also provides a kamal upgrade
command which purports to upgrade your environment in-place,
but I was too worried about extended downtime if something went wrong to try it that way.
Deploying a second app
After going through this process with one app, I did it again with a second app and it was… exactly the same.
Kamal is smart enough to see if there’s already an instance of the proxy server running and just ues it if
it already exists.
And it routes traffic to the right place depending on the passed in hostname from the URL.
This makes it super easy to run many micro-apps on a single server, which is really nice if, like me,
you have a lot of Django projects.
Closing thoughts
Overall this process was quite smooth!
Excepting the blip with the health checks, everything worked exactly as advertised.
Also Kamal 2 deployments feel substantially faster than Kamal 1 (especially the health checks, ironically,
given the issues with them).
I just updated yesterday, so I don’t know if I’ll run into any other issues, but so far
I’m finding this a great way to deploy my Django projects to a shared server.