Deploy Django with Kamal
Deploy a Dockerized Django app with Kamal 2: deploy.yml, secrets, kamal-proxy HTTPS, a Postgres accessory, safe migrations, health checks, and rollbacks.
Problem statement
Kamal deploys Docker images to plain servers over SSH. It builds and pushes your image, starts the new container next to the old one, waits for a health check through kamal-proxy, and then switches traffic. It was built for Rails, but nothing in it is Rails-specific, which makes it an attractive way to get gapless Docker deploys for Django without Kubernetes.
Rails conventions leak into the defaults, though, and a Django project hits them quickly:
- the proxy health check hits
/up, which Django does not have - health checks reach the container with a
Hostthat is not inALLOWED_HOSTS - the app port defaults to 80, while Gunicorn usually binds 8000
- migrations need an explicit place in the release flow
SECURE_PROXY_SSL_HEADERhas to match what kamal-proxy actually sends
This guide deploys Django with Kamal 2 to one or more servers, with PostgreSQL as a Kamal accessory and automatic HTTPS.
Quick answer
- Install Kamal locally and run
kamal initin the project to createconfig/deploy.ymland.kamal/secrets. - Build a production Dockerfile that runs Gunicorn on port 8000 through an entrypoint that migrates first.
- In
deploy.yml, setproxy.app_port: 8000,proxy.host,proxy.ssl: true, andproxy.healthcheck.path: /up. - Answer
/upin a middleware before host validation. - Define
env.clearandenv.secret, and load secrets from.kamal/secrets. - Add Postgres as an accessory, then run
kamal setupfor the first deploy andkamal deployafterwards. - Roll back with
kamal rollback <version>.
Step-by-step solution
1. Install Kamal
Kamal is a Ruby gem. Install it with gem install kamal, or run the official Docker image as documented in the Kamal installation guide. Then, in the Django repository:
kamal init
This creates config/deploy.yml, .kamal/secrets, and sample hooks in .kamal/hooks/.
2. Dockerfile and entrypoint
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN DJANGO_SECRET_KEY=build-only python manage.py collectstatic --noinput
RUN useradd --create-home --uid 10001 app && chmod +x bin/docker-entrypoint
USER app
EXPOSE 8000
ENTRYPOINT ["bin/docker-entrypoint"]
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--access-logfile", "-"]
bin/docker-entrypoint:
#!/bin/sh
set -e
if [ "$1" = "gunicorn" ] && [ "${RUN_MIGRATIONS:-0}" = "1" ]; then
python manage.py migrate --noinput
fi
exec "$@"
Migrations only run when the container starts the web server and RUN_MIGRATIONS=1 is set. One-off commands such as kamal app exec "python manage.py shell" skip them. Because kamal-proxy only switches traffic after the new container passes its health check, and Gunicorn only starts after migrate succeeds, a failed migration leaves the old container serving.
3. Health check endpoint
kamal-proxy polls /up on the new container during deploys. The request goes straight to the container, and its Host header will not be your public domain. Answer it before Django's host validation:
# config/middleware.py
from django.http import HttpResponse
class UpMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.path == "/up":
return HttpResponse("ok", content_type="text/plain")
return self.get_response(request)
Add "config.middleware.UpMiddleware" as the first entry in MIDDLEWARE. Keep it free of database calls so a slow database does not block every deploy.
4. Settings
import os
import dj_database_url
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = False
ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",")
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]
DATABASES = {"default": dj_database_url.config(conn_max_age=60)}
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
After the first deploy, confirm that HTTPS detection works (step 9). Do not enable SECURE_SSL_REDIRECT in Django when kamal-proxy already redirects HTTP to HTTPS; it adds nothing and risks redirect loops if the header is missing.
5. deploy.yml
service: myapp
image: myorg/myapp
servers:
web:
hosts:
- 203.0.113.10
proxy:
host: www.example.com
app_port: 8000
ssl: true
healthcheck:
path: /up
interval: 3
timeout: 3
registry:
server: ghcr.io
username: myorg
password:
- KAMAL_REGISTRY_PASSWORD
builder:
arch: amd64
env:
clear:
DJANGO_SETTINGS_MODULE: config.settings
DJANGO_ALLOWED_HOSTS: www.example.com
RUN_MIGRATIONS: "1"
secret:
- DJANGO_SECRET_KEY
- DATABASE_URL
boot:
limit: 1
accessories:
db:
image: postgres:16
host: 203.0.113.10
port: "127.0.0.1:5432:5432"
env:
clear:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
secret:
- POSTGRES_PASSWORD
directories:
- data:/var/lib/postgresql/data
Notes on the important keys:
proxy.app_port: 8000tells kamal-proxy where Gunicorn listens; the default is 80.proxy.ssl: truemakes kamal-proxy obtain a Let's Encrypt certificate forhost. That requires a single server, DNS pointing at it, and port 443 open.boot.limit: 1boots hosts one at a time. With several web hosts, the first container runs the migrations, and later hosts find nothing left to apply instead of racing.- The Postgres port is bound to
127.0.0.1on the host so it is not exposed publicly.
6. Secrets
.kamal/secrets is evaluated by Kamal on your machine and may reference environment variables or a password manager. It must not contain literal secrets if it is committed:
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
DJANGO_SECRET_KEY=$DJANGO_SECRET_KEY
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
DATABASE_URL=postgres://myapp:$POSTGRES_PASSWORD@myapp-db:5432/myapp
Accessories run on the same Docker network as the app (named kamal), so the app can reach Postgres by its container name, which defaults to <service>-<accessory>, here myapp-db.
7. First deploy
export KAMAL_REGISTRY_PASSWORD=... DJANGO_SECRET_KEY=... POSTGRES_PASSWORD=...
kamal setup
kamal setup installs Docker on the servers if needed, boots accessories and kamal-proxy, then deploys the app. Subsequent releases use:
kamal deploy
8. Admin tasks
kamal app exec --interactive --reuse "python manage.py createsuperuser"
kamal app logs -f
kamal app details
--reuse runs the command in the currently running container instead of starting a new one.
9. Verify
curl -I https://www.example.com/
curl -I http://www.example.com/
kamal app exec --reuse "python manage.py check --deploy"
Also verify HTTPS detection from inside Django. Temporarily add a view or log line that records request.is_secure() and request.META.get("HTTP_X_FORWARDED_PROTO"), request the site over HTTPS, and confirm the result is True and https. If the header is missing, check your kamal-proxy version's forwarded header behavior and the proxy.forward_headers option before relying on secure redirects or CSRF origin checks.
Verification check:
- HTTPS returns
200; HTTP redirects to HTTPS kamal app detailsshows exactly one running app container per host- the deploy output shows the migration running before the health check passed
check --deployshows no unexpected warnings
Rollback
Kamal keeps previous images on the servers (pruned to a small number). List versions and roll back:
kamal app containers
kamal rollback <previous-version>
Rollback starts the old image and switches the proxy after its health check passes. It does not reverse migrations, so the old release must be compatible with the current schema. Split destructive schema changes across releases for that reason.
Explanation
Kamal's deploy is a sequence of simple Docker operations: pull the new image, start a container, ask kamal-proxy to deploy it (which polls the health check), then stop the old container. Everything Django-specific therefore has to be expressed in the image itself: how to start, when to migrate, and how to answer a health check that does not look like normal traffic. Putting migrations in the entrypoint ties them to the moment the new version starts, and boot.limit keeps multi-host deploys from running them concurrently.
Edge cases / notes
Deploy hangs on "Waiting for healthy"
Run kamal app logs to look for tracebacks. Typical causes: /up returning 400 (middleware not first), Gunicorn bound to a different port than app_port, or a long migration exceeding the deploy timeout (deploy_timeout in deploy.yml).
Long migrations
Migrations that take minutes keep the new container unhealthy for that long. Run them ahead of time with a one-off command using the new image, and keep the entrypoint migration as a no-op safety net.
Multiple servers with HTTPS
Automatic Let's Encrypt in kamal-proxy works for a single server. With several web hosts, terminate TLS at a load balancer or provide a custom certificate through the proxy.ssl certificate options.
Accessory backups
Accessories are not updated or backed up by deploys. Schedule pg_dump from the host or a sidecar and store dumps off the server.
Operating a Kamal deployment
Inspecting what is running
kamal app details # running app containers per host
kamal proxy details # kamal-proxy container status
kamal accessory details db # Postgres accessory status
kamal audit # who deployed what, and when
kamal config # resolved configuration, including secrets
kamal config prints resolved secrets; do not paste its output anywhere shared.
Locks
Kamal takes a deploy lock on the servers so two people cannot deploy at the same time. If a deploy was interrupted (for example your laptop lost connectivity), the lock may remain:
kamal lock status
kamal lock release
Only release a lock after confirming no other deploy is in progress.
Environment changes
Changing env.clear or env.secret values requires a new deploy (or kamal app boot) so containers start with the new environment. Running containers never pick up changed environment variables on their own.
Disk usage
Each deploy pulls a new image. Kamal prunes old images and stopped containers after deploys, keeping a few for rollbacks, but build caches and accessory data still grow. Watch docker system df on hosts and keep enough free space for at least two full images, or deploys fail midway while pulling.
Troubleshooting table
| Symptom | Likely cause | Check |
|---|---|---|
target failed to become healthy | /up returns non-200, wrong app_port, slow boot | kamal app logs, curl the container from the host |
| TLS certificate not issued | DNS not pointing at host, port 80/443 blocked | dig, firewall rules, kamal proxy logs |
connection refused to database | accessory not booted or wrong host name | kamal accessory details db, DATABASE_URL host is myapp-db |
| App works but CSRF fails on forms | HTTPS not detected, origin mismatch | request.is_secure(), CSRF_TRUSTED_ORIGINS |
| Deploy pushes to wrong architecture | builder on Apple Silicon without arch: amd64 | builder.arch in deploy.yml |
To call the health check from the host exactly as the proxy does, find the container and request /up over the Docker network:
docker ps --filter label=service=myapp
docker run --rm --network kamal curlimages/curl -si http://<container-name>:8000/up
Common mistakes
- Committing literal secrets into
.kamal/secretsinstead of referencing environment variables or a password manager. - Publishing the Postgres accessory port on all interfaces (
5432:5432), exposing the database to the internet. - Running several web hosts with automatic Let's Encrypt, which the proxy does not support across hosts.
- Forgetting
boot.limitwith multiple hosts while migrations run in the entrypoint.
Next steps
- Build the image correctly with How to Dockerize a Django App for Production.
- Understand release compatibility in How to Set Up Zero-Downtime Deployments for Django.
- Compare with a git-push platform in Deploy Django with Dokku on Your Own Server.
FAQ
Does Kamal work with Django or only Rails?
Kamal deploys any Docker image that serves HTTP. Django works well once you set the app port, provide a health check path, and decide where migrations run.
Why does Kamal get stuck waiting for my Django container to become healthy?
kamal-proxy polls the health check path (default /up) on the container. Django returns 404 if the route does not exist or 400 if the Host header is not allowed. Serve the path from a first-position middleware.
Where should Django migrations run in a Kamal deploy?
A common approach is an entrypoint that runs migrate before Gunicorn, combined with boot.limit: 1 for multi-host deploys. For long migrations, run them separately before kamal deploy.
How do I open a Django shell with Kamal?
Use kamal app exec --interactive --reuse "python manage.py shell", which attaches to the running app container over SSH.