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 Host that is not in ALLOWED_HOSTS
  • the app port defaults to 80, while Gunicorn usually binds 8000
  • migrations need an explicit place in the release flow
  • SECURE_PROXY_SSL_HEADER has 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.

Kamal deploy flowkamal deploybuilds and pushesNew containerentrypoint migrateskamal-proxyhealth check /upTraffic switchold container stopsTraffic moves only after the new container answers its health check.

Quick answer

  1. Install Kamal locally and run kamal init in the project to create config/deploy.yml and .kamal/secrets.
  2. Build a production Dockerfile that runs Gunicorn on port 8000 through an entrypoint that migrates first.
  3. In deploy.yml, set proxy.app_port: 8000, proxy.host, proxy.ssl: true, and proxy.healthcheck.path: /up.
  4. Answer /up in a middleware before host validation.
  5. Define env.clear and env.secret, and load secrets from .kamal/secrets.
  6. Add Postgres as an accessory, then run kamal setup for the first deploy and kamal deploy afterwards.
  7. 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
deploy.yml, the Django-specific partsproxy:app_port:8000ssl: truehealthcheck: {path:/up}env:secret: [DJANGO_SECRET_KEY, DATABASE_URL]boot: {limit:1}123451Where Gunicorn listens inside the container.2kamal-proxy gets a Let's Encrypt certificate.3Must return 200 before the host passes ALLOWED_HOSTS.4Values come from .kamal/secrets, never this file.5One container at a time, so only one runs migrations.Everything else in the file is registry and server addressesdjango-deployment.com
The entrypoint runs migrate when RUN_MIGRATIONS is set, before Gunicorn starts.

Notes on the important keys:

  • proxy.app_port: 8000 tells kamal-proxy where Gunicorn listens; the default is 80.
  • proxy.ssl: true makes kamal-proxy obtain a Let's Encrypt certificate for host. That requires a single server, DNS pointing at it, and port 443 open.
  • boot.limit: 1 boots 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.1 on 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
clear values versus secretsValue lives inCommitted?env.cleardeploy.ymlyesenv.secret.kamal/secrets (from a manager)✓noKAMAL_REGISTRY_PASSWORD.kamal/secrets✓no.kamal/secrets can call a password manager instead of holding valuesdjango-deployment.com
Changing a secret needs kamal env push and a redeploy to reach running containers.

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 deploy, step by stepBuild and pushtagged with the commitServers pullover SSHNew container bootsentrypoint migrates/up returns 200kamal-proxy checksTraffic switchesold container stopsUntil /up passes, the old container keeps servingdjango-deployment.com
A deploy stuck on waiting for healthy almost always means /up is not returning 200.

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 details shows exactly one running app container per host
  • the deploy output shows the migration running before the health check passed
  • check --deploy shows 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>
rolling back with Kamal1kamal app containersfind the previous version2Check the schemais the old code compatible?3kamal rollback <version>image already on the server4Proxy switches backafter the health check5Verify and fix forwardthe next deploy moves aheadOld images stay on the server until pruned, which is what makes this fastdjango-deployment.com
Rollback does not reverse migrations; that is a separate, deliberate step.

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

operating Kamal day to daykamal app detailswhat is running wherekamal app logs -ffollow Django outputkamal lock statusbefore forcing a releasekamal env push after secret changesthen redeployPrune old imageswatch disk usageA stale lock is the usual reason a deploy refuses to startdjango-deployment.com
Release a lock only after confirming no deploy is running.

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

SymptomLikely causeCheck
target failed to become healthy/up returns non-200, wrong app_port, slow bootkamal app logs, curl the container from the host
TLS certificate not issuedDNS not pointing at host, port 80/443 blockeddig, firewall rules, kamal proxy logs
connection refused to databaseaccessory not booted or wrong host namekamal accessory details db, DATABASE_URL host is myapp-db
App works but CSRF fails on formsHTTPS not detected, origin mismatchrequest.is_secure(), CSRF_TRUSTED_ORIGINS
Deploy pushes to wrong architecturebuilder on Apple Silicon without arch: amd64builder.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/secrets instead 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.limit with multiple hosts while migrations run in the entrypoint.

Next steps

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.

Keep going