Deploy Django with Traefik and Docker

Put Traefik in front of a Dockerized Django app: automatic Let's Encrypt certificates, HTTP to HTTPS redirects, Docker labels for routing, static files, and upload limits.

Problem statement

In a Docker Compose deployment, the reverse proxy is usually Nginx with Certbot managed on the side. Traefik is an alternative built for containers: it watches Docker for containers with routing labels, obtains and renews Let's Encrypt certificates automatically, and redirects HTTP to HTTPS without a separate cron job.

The Django side still needs attention: the app must trust Traefik's forwarded headers, static files need a server, the container port must be declared, and large uploads need explicit limits. This guide builds a Compose deployment with Traefik v3 in front of Gunicorn and covers those details.

Django behind TraefikTraefik:80 and :443ACME resolverLet's Encrypt certsRouter labelsHost(example.com)Django containerGunicorn :8000Traefik routes by container labels; only containers with traefik.enable=true are exposed.
  1. Run Traefik as a container with the Docker provider, exposedbydefault=false, web and websecure entrypoints, and an ACME resolver.
  2. Redirect the web entrypoint to websecure.
  3. Label the Django service with a Host() router, the websecure entrypoint, the cert resolver, and the service port 8000.
  4. Serve static files with WhiteNoise inside the Django container.
  5. Set SECURE_PROXY_SSL_HEADER, ALLOWED_HOSTS, and CSRF_TRUSTED_ORIGINS in Django.
  6. Persist acme.json in a volume so certificates survive restarts.

Step-by-step solution

1. Compose file

# compose.yaml
services:
  traefik:
    image: traefik:v3.1
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --entrypoints.web.http.redirections.entrypoint.scheme=https
      - --certificatesresolvers.le.acme.email=ops@example.com
      - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
      - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt
    restart: unless-stopped

  web:
    image: ghcr.io/example/myapp:1.4.0
    command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3 --access-logfile -
    env_file: .env.production
    labels:
      - traefik.enable=true
      - traefik.http.routers.myapp.rule=Host(`example.com`) || Host(`www.example.com`)
      - traefik.http.routers.myapp.entrypoints=websecure
      - traefik.http.routers.myapp.tls.certresolver=le
      - traefik.http.services.myapp.loadbalancer.server.port=8000
    depends_on:
      - db
    restart: unless-stopped

  db:
    image: postgres:16
    env_file: .env.db
    volumes:
      - pgdata:/var/lib/postgresql/data
    restart: unless-stopped

volumes:
  letsencrypt:
  pgdata:
  • exposedbydefault=false means only containers labelled traefik.enable=true get routes; the database is never exposed.
  • loadbalancer.server.port tells Traefik which container port to use. Without it, Traefik guesses from exposed ports, which fails when there are none or several.
  • Pin a specific Traefik version; label and option names changed between v2 and v3.
  • Point DNS for both hostnames at the server before starting, so the HTTP challenge can succeed.
routing by container labelstraefik.enable=truetraefik.http.routers.myapp.rule=Host(`example.com`)traefik.http.routers.myapp.entrypoints=websecuretraefik.http.routers.myapp.tls.certresolver=letraefik.http.services.myapp.loadbalancer.server.port=800012341Opt-in: containers without it stay private.2Requests for this host go to this container.3Let's Encrypt certificate issued and renewed by Traefik.4Gunicorn port inside the container; nothing is published.Traefik reads these at runtime; no proxy config file to editdjango-deployment.com
Set exposedByDefault=false on Traefik so the enable label is required.

2. Configure Django

Traefik terminates TLS and sets X-Forwarded-Proto, X-Forwarded-For, and X-Forwarded-Host:

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
ALLOWED_HOSTS = ["example.com", "www.example.com"]
CSRF_TRUSTED_ORIGINS = ["https://example.com", "https://www.example.com"]

By default, Traefik does not trust X-Forwarded-* headers sent by clients on its entrypoints; it replaces them with its own values. That is what makes trusting the header in Django safe. If Traefik itself sits behind a load balancer or CDN, configure forwardedHeaders.trustedIPs on the entrypoint for that proxy's addresses.

3. Static files

Traefik is a router, not a file server. The simplest option is WhiteNoise in the Django container:

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    # ...
]
STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
    "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}

Run collectstatic in the Dockerfile. For heavy media traffic, add a small Nginx container that serves the media volume, routed with a higher-priority rule such as Host(`example.com`) && PathPrefix(`/media/`).

4. Redirect www and set headers with middlewares

    labels:
      # ...existing labels...
      - traefik.http.middlewares.www-redirect.redirectregex.regex=^https://www\.example\.com/(.*)
      - traefik.http.middlewares.www-redirect.redirectregex.replacement=https://example.com/$${1}
      - traefik.http.middlewares.www-redirect.redirectregex.permanent=true
      - traefik.http.routers.myapp.middlewares=www-redirect
what runs before Django sees a requestEntrypoint :80redirect to HTTPSEntrypoint :443, TLScertificate from the resolverwww redirect middlewareone canonical hostheaders middlewareHSTS, nosniff, frame optionsDjango (Gunicorn :8000)X-Forwarded-Proto: httpsMiddlewares are attached to the router by label, in orderdjango-deployment.com
Set security headers in one place, Traefik or Django, not both.

In Compose files, $ must be escaped as $$. Security headers such as HSTS can be set by Django (SECURE_HSTS_SECONDS) or with Traefik's headers middleware; set them in one place only.

5. Upload size limits

Traefik does not limit request body size by default. Add a buffering middleware to cap uploads before they reach Gunicorn:

      - traefik.http.middlewares.upload-limit.buffering.maxRequestBodyBytes=20000000
      - traefik.http.routers.myapp.middlewares=www-redirect,upload-limit

Keep Django's DATA_UPLOAD_MAX_MEMORY_SIZE and your view-level checks consistent with this limit.

6. Start and verify

docker compose up -d
docker compose logs -f traefik

Watch for the certificate being obtained. Then:

curl -sI http://example.com/ | grep -i location
curl -sI https://example.com/ | head -1
docker compose exec web python manage.py check --deploy

Verification check:

  • HTTP redirects to HTTPS with a single 308 or 301.
  • The certificate is issued by Let's Encrypt (openssl s_client -connect example.com:443 -servername example.com </dev/null | grep issuer).
  • The admin login works, confirming CSRF and secure cookies behind the proxy.

Explanation

Traefik's Docker provider subscribes to Docker events and builds routers and services from container labels. A router matches requests (by host and path) on an entrypoint and forwards them to a service, which load-balances across the containers that carry the labels. The ACME resolver requests certificates for the hosts in router rules and stores them in acme.json. When you scale the web service to several containers, Traefik balances across them automatically.

Edge cases / notes

Docker socket exposure

the Docker socket is root accessSocket mounted directly✓simplest setup✕a Traefik exploit controls Docker✕:ro does not limit the APISocket proxy✓only read endpoints allowed✓Traefik never touches the socketone more small containerRead-only mounts protect the file, not what the API can dodjango-deployment.com
The socket proxy only needs the containers and networks endpoints for Traefik.

Mounting the Docker socket gives Traefik control over Docker, even read-only in practice for API calls. Keep the Traefik dashboard disabled or protected, pin the image, and consider a socket proxy that only permits the read endpoints Traefik needs.

Rate limits during testing

Let's Encrypt enforces rate limits. While experimenting, set --certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory, then remove it and delete acme.json for production certificates.

Zero-downtime deploys

With a single web container, docker compose up -d recreates it and requests fail briefly. Run two replicas and update them one at a time, or use a platform with rolling updates.

When to automate this

Once Traefik runs, adding another Django project on the same server is a matter of labels. The repeatable part to script is the release: pull the new image, run migrations in a one-off container, recreate the web service, and check the health endpoint.

Next steps

FAQ

Does Traefik serve Django static files?

No. Use WhiteNoise in the Django container, object storage, or a separate web server container routed by path.

Why does Traefik return 404 for my Django container?

The container lacks traefik.enable=true, the Host() rule does not match the request, or the router is on a different entrypoint. Check the Traefik logs and the labels with docker inspect.

Why does Traefik return Bad Gateway?

It reached the container on the wrong port or before Gunicorn started. Set traefik.http.services.<name>.loadbalancer.server.port to Gunicorn's port and make sure Gunicorn binds 0.0.0.0.

Do I need Certbot with Traefik?

No. Traefik's ACME resolver obtains and renews certificates itself. Persist the storage file so it does not request new certificates on every restart.

Learning path · step 11 of 12

Docker, from image to production

Keep going