Deploy Django with Docker Swarm

Deploy Django with Docker Swarm: a stack file with replicas, Docker secrets, rolling start-first updates with automatic rollback, one-off migrations, and Nginx in front.

Problem statement

Docker Compose on one server is a solid Django deployment until you need zero-downtime updates, several replicas, or a second machine. Kubernetes solves those problems but brings a lot of operational weight. Docker Swarm sits in between: it uses almost the same file format as Compose and adds replicas, rolling updates with rollback, secrets, and multi-node scheduling, all built into Docker.

This guide converts a Django Compose setup into a Swarm stack with the pieces that matter in production: secrets instead of plain environment values, start-first rolling updates gated on health checks, migrations run exactly once, and persistent data pinned to the right node.

Django on Docker SwarmNginxpublished 80, 443Web service3 replicasOverlay networkservice DNSPostgrespinned to one nodeStart-first updates plus a health check replace replicas without dropping requests.
  1. docker swarm init on the manager; join workers if you have more than one node.
  2. Create secrets with docker secret create and read them from /run/secrets/ in settings.
  3. Write a stack file with deploy.replicas, update_config.order: start-first, failure_action: rollback, and a health check.
  4. Pin PostgreSQL to one node with a placement constraint and a local volume.
  5. Run migrations as a one-off container on an attachable overlay network before updating the web service.
  6. Deploy with docker stack deploy -c stack.yml myapp and update images by tag.

Step-by-step solution

1. Initialise the swarm

docker swarm init --advertise-addr <manager-private-ip>
docker node ls

For more than one server, run the docker swarm join command printed by init on each worker. Swarm needs TCP 2377, TCP/UDP 7946, and UDP 4789 open between nodes on the private network only. Label the node that will hold the database:

docker node update --label-add db=true <manager-node-name>

2. Create secrets

python3 -c "import secrets; print(secrets.token_urlsafe(50))" | docker secret create django_secret_key -
openssl rand -base64 32 | tr -d '\n' | docker secret create postgres_password -
docker secret ls
where secrets live in a swarmenvironment:✕visible in docker inspect✕often committed in the stack file✓fine for non-secret settingsDocker secrets✓encrypted in the Raft log✓mounted at /run/secrets/<name>✓only for services that list themDjango reads the file path, not the value, from the environmentdjango-deployment.com
A small helper reads NAME_FILE when it is set and falls back to NAME otherwise.

Secrets are stored encrypted in the swarm's Raft log and mounted as files at /run/secrets/<name> only inside services that request them. Read them in Django:

from pathlib import Path


def read_secret(name, default=None):
    path = Path("/run/secrets") / name
    return path.read_text().strip() if path.exists() else default


SECRET_KEY = read_secret("django_secret_key")
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "myapp",
        "USER": "myapp",
        "PASSWORD": read_secret("postgres_password"),
        "HOST": "db",
        "PORT": 5432,
        "CONN_MAX_AGE": 60,
    }
}

3. Write the stack file

# stack.yml
services:
  web:
    image: ghcr.io/example/myapp:${TAG}
    command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3 --access-logfile -
    environment:
      DJANGO_SETTINGS_MODULE: config.settings.production
      DJANGO_ALLOWED_HOSTS: example.com
    secrets: [django_secret_key, postgres_password]
    networks: [backend]
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request as u; u.urlopen(u.Request('http://127.0.0.1:8000/healthz/', headers={'Host': 'example.com'}), timeout=3)"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 20s
    deploy:
      replicas: 3
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
        failure_action: rollback
        monitor: 30s
      rollback_config:
        parallelism: 1
        order: start-first
      restart_policy:
        condition: on-failure

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
    secrets: [postgres_password]
    volumes: [pgdata:/var/lib/postgresql/data]
    networks: [backend]
    deploy:
      placement:
        constraints: [node.labels.db == true]

  nginx:
    image: nginx:1.27
    ports:
      - target: 80
        published: 80
        mode: host
      - target: 443
        published: 443
        mode: host
    configs:
      - source: nginx_conf
        target: /etc/nginx/conf.d/default.conf
    networks: [backend]
    deploy:
      placement:
        constraints: [node.role == manager]

networks:
  backend:
    driver: overlay
    attachable: true

volumes:
  pgdata:

secrets:
  django_secret_key:
    external: true
  postgres_password:
    external: true

configs:
  nginx_conf:
    file: ./nginx.conf

Notes:

  • order: start-first starts a new replica, waits for it to become healthy, then stops an old one. With failure_action: rollback, a replica that fails its health check during monitor rolls the service back.
  • The health check sends a Host header that is in ALLOWED_HOSTS; a bare 127.0.0.1 request gets 400. If SECURE_SSL_REDIRECT is on, add SECURE_REDIRECT_EXEMPT = [r"^healthz/$"] so the check is not redirected.
  • The PostgreSQL volume is local to a node, which is why the service is pinned with a placement constraint. A managed database removes this constraint entirely.
  • mode: host publishing preserves the client IP for Nginx logs and Django; the default ingress mode hides it behind the routing mesh.
  • Swarm configs are immutable. To change nginx.conf, rename the config (for example nginx_conf_v2) in the stack file and redeploy.
rolling updates with automatic rollbackdeploy:replicas: 3update_config:parallelism:1order:start-firstfailure_action:rollbackmonitor:30s12341Replace one task at a time.2The new task starts before the old one stops.3A failing new task reverts the service automatically.4How long a new task must stay healthy to count.Automatic rollback needs a HEALTHCHECK in the image to detect failuredjango-deployment.com
Without a health check, Swarm only notices tasks that exit, not tasks that return errors.

In nginx.conf, proxy to http://web:8000. Swarm's DNS resolves the service name to a virtual IP that balances across healthy replicas.

4. Deploy the stack

export TAG=1.4.0
docker stack deploy -c stack.yml --with-registry-auth myapp
docker stack services myapp
docker service ps myapp_web

--with-registry-auth forwards your registry login to the nodes so they can pull private images. Swarm prefixes names with the stack name: the web service becomes myapp_web and the network myapp_backend.

5. Run migrations once per release

Run migrations with the new image before rolling the web service. Because the network is attachable, a plain container can join it:

docker run --rm --network myapp_backend \
  --env DJANGO_SETTINGS_MODULE=config.settings.production \
  --env DJANGO_SECRET_KEY="$(cat ~/secrets/django_secret_key)" \
  --env POSTGRES_PASSWORD="$(cat ~/secrets/postgres_password)" \
  ghcr.io/example/myapp:1.5.0 python manage.py migrate --noinput

Plain docker run containers cannot mount Swarm secrets, so this step reads credentials from a protected file on the manager and passes them as environment variables. Make the settings accept either source (read_secret("postgres_password") or os.environ.get("POSTGRES_PASSWORD")). Keep migrations backward compatible, since old replicas keep serving traffic while new ones start.

6. Roll out and roll back

docker service update --image ghcr.io/example/myapp:1.5.0 --with-registry-auth myapp_web
docker service ps myapp_web
docker service rollback myapp_web
one task at a timeservice updatenew image tagTask 1 replacedstart-first, healthyTask 2 replacedafter 10 s delayTask 3 fails checkmonitor windowAutomatic rollbackall back to old imagedocker service rollback does the same by handdjango-deployment.com
Migrations applied before the update are not rolled back, so keep them compatible.

Or set TAG=1.5.0 and rerun docker stack deploy, which keeps the stack file as the source of truth.

Verification check:

docker service ls
docker service logs --tail 50 myapp_web
curl -sI https://example.com/healthz/

All web replicas should show 3/3, and the update state in docker service inspect --pretty myapp_web should report completed.

Explanation

Swarm turns each stack service into a desired state: an image, a replica count, and update rules. The manager continuously reconciles running containers to that state. Rolling updates replace replicas one at a time and use the container health check to decide whether the new version works. Secrets and configs are distributed by the manager, so no credentials sit in the stack file or image. The overlay network gives every service a stable DNS name across nodes.

Edge cases / notes

Static files and media

Serve static files from the image with WhiteNoise or from Nginx with a shared build step. Media must go to object storage once replicas run on more than one node, because local volumes are not shared.

Manager count

Run one or three manager nodes (an odd number) for Raft consensus. With a single manager, losing it means you cannot change the swarm until it returns, although running containers keep serving.

When to automate this

The release sequence (build and push image, run the migration container, docker stack deploy with the new tag, check service state) fits one CI job with SSH access to the manager or a Docker context pointing at it. Secret creation stays a controlled manual or provisioning step.

Next steps

FAQ

Is Docker Swarm still a reasonable choice for Django?

Yes, for small teams that want rolling updates and a few nodes without running Kubernetes. It is included in Docker Engine and uses a Compose-style file.

How do Docker secrets reach Django?

As files under /run/secrets/ inside containers of services that list them. Read the file in settings; the official PostgreSQL image supports _FILE variables for the same purpose.

How do I run Django migrations in Docker Swarm?

Run a one-off container with the new image on an attachable overlay network before updating the web service, so migrations run exactly once.

Why does Django see the wrong client IP in Swarm?

Ports published through the ingress routing mesh are source-NATed. Publish the proxy's ports in host mode and forward X-Forwarded-For to Django.

Keep going