Deploy Django on Kubernetes

Deploy Django on Kubernetes with a Deployment, Service, Ingress, Secrets, readiness and liveness probes, and a migration Job that runs before each rollout.

Problem statement

Kubernetes gives Django horizontal scaling, self-healing pods, and rolling updates, but it does not know anything about Django. A naive manifest that runs migrate && gunicorn in every pod, mounts no secrets, and has no probes will:

  • run migrations concurrently from several pods at once
  • send traffic to pods before Gunicorn is ready
  • restart healthy pods whose liveness check depends on the database
  • reject requests from the ingress controller or kubelet with DisallowedHost

This guide builds a small, correct set of manifests for a stateless Django web tier on an existing cluster: a Secret and ConfigMap, a migration Job, a Deployment with probes, a Service, and an Ingress with TLS. It assumes PostgreSQL runs outside the cluster (a managed database) and that you already have a production image.

Django on KubernetesIngressTLS, routingServiceClusterIPDeploymentGunicorn podsMigration Jobruns once per releaseThe Job applies migrations before the Deployment rolls out new pods.

Quick answer

  1. Push an immutable image tag (for example the Git SHA) to your registry.
  2. Store DJANGO_SECRET_KEY and database credentials in a Kubernetes Secret; non-secret settings in a ConfigMap.
  3. Run python manage.py migrate --noinput as a Kubernetes Job using the new image, and wait for it to complete.
  4. Update the Deployment image; it runs Gunicorn only, with readiness and liveness probes on a lightweight endpoint.
  5. Expose it through a ClusterIP Service and an Ingress with TLS.
  6. Set SECURE_PROXY_SSL_HEADER and ALLOWED_HOSTS for the ingress hostname, and handle probe requests before host validation.
  7. Verify with kubectl rollout status, kubectl logs, and real HTTPS requests.

Step-by-step solution

1. Namespace and configuration

apiVersion: v1
kind: Namespace
metadata:
  name: django-prod
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: django-config
  namespace: django-prod
data:
  DJANGO_SETTINGS_MODULE: config.settings
  DJANGO_ALLOWED_HOSTS: www.example.com
  DB_HOST: mydb.example-provider.com
  DB_NAME: django
  DB_USER: django

Create the Secret from the command line so values never land in Git:

kubectl -n django-prod create secret generic django-secrets \
  --from-literal=DJANGO_SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(50))')" \
  --from-literal=DB_PASSWORD='generated-db-password'

Kubernetes Secrets are base64-encoded, not encrypted, unless encryption at rest is enabled for the cluster. Restrict access with RBAC, and consider an external secrets operator backed by your cloud's secret manager for rotation.

2. Settings for running behind an ingress

import os

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = False
ALLOWED_HOSTS = [h for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if h]
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "HOST": os.environ["DB_HOST"],
        "NAME": os.environ["DB_NAME"],
        "USER": os.environ["DB_USER"],
        "PASSWORD": os.environ["DB_PASSWORD"],
        "CONN_MAX_AGE": 60,
        "CONN_HEALTH_CHECKS": True,
    }
}

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "root": {"handlers": ["console"], "level": "INFO"},
}

Log to stdout so kubectl logs and your cluster log collector see everything. Only trust X-Forwarded-Proto if your ingress controller sets it and clients cannot reach pods directly.

3. Handle probes before host validation

Kubelet probes connect to the pod IP. The Host header is the pod IP and port, which is not in ALLOWED_HOSTS, so a probe hitting a normal Django view gets 400 and the pod never becomes ready. Answer probe paths in the first middleware:

from django.http import HttpResponse


class ProbeMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if request.path in ("/livez/", "/readyz/"):
            return HttpResponse("ok", content_type="text/plain")
        return self.get_response(request)
two probes, two different jobsReadiness: /readyz/may check the database✓failing: removed from the Service✓pod keeps running, no restartLiveness: /livez/checks only that Python answersfailing: container is restarted✕a DB check here restarts every podBoth must answer before ALLOWED_HOSTS rejects the pod IPdjango-deployment.com
Probes arrive with the pod IP as Host, which is why they need middleware placed early.

Keep liveness checks independent of the database. If a database outage makes liveness fail, Kubernetes restarts every pod at once, which turns a database incident into a full application restart loop. A readiness check that also tests the database is a reasonable choice when you prefer removing pods from rotation during outages, but understand that if every pod becomes unready, the Service has no endpoints at all.

4. Migration Job

apiVersion: batch/v1
kind: Job
metadata:
  name: django-migrate-abc1234
  namespace: django-prod
spec:
  backoffLimit: 0
  activeDeadlineSeconds: 600
  ttlSecondsAfterFinished: 86400
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: registry.example.com/django-app:abc1234
          command: ["python", "manage.py", "migrate", "--noinput"]
          envFrom:
            - configMapRef:
                name: django-config
            - secretRef:
                name: django-secrets
  • The Job name includes the release tag, because Job pod templates are immutable and each release needs a new Job.
  • backoffLimit: 0 fails fast instead of retrying a broken migration repeatedly.
  • ttlSecondsAfterFinished cleans up finished Jobs automatically.

Run it and gate the release on success:

kubectl apply -f migrate-job.yaml
kubectl -n django-prod wait --for=condition=complete job/django-migrate-abc1234 --timeout=600s
kubectl -n django-prod logs job/django-migrate-abc1234

If kubectl wait times out or the Job fails, stop the release and read the logs.

5. Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: django-web
  namespace: django-prod
spec:
  replicas: 3
  revisionHistoryLimit: 5
  selector:
    matchLabels:
      app: django-web
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  template:
    metadata:
      labels:
        app: django-web
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
      containers:
        - name: web
          image: registry.example.com/django-app:abc1234
          command: ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--access-logfile", "-"]
          ports:
            - containerPort: 8000
          envFrom:
            - configMapRef:
                name: django-config
            - secretRef:
                name: django-secrets
          resources:
            requests:
              cpu: 250m
              memory: 384Mi
            limits:
              memory: 768Mi
          readinessProbe:
            httpGet:
              path: /readyz/
              port: 8000
            periodSeconds: 5
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /livez/
              port: 8000
            initialDelaySeconds: 20
            periodSeconds: 10
            failureThreshold: 3
          lifecycle:
            preStop:
              exec:
                command: ["sleep", "5"]
      terminationGracePeriodSeconds: 30

Key choices:

  • maxUnavailable: 0 keeps full capacity during rollouts.
  • A memory limit protects the node; size it from observed usage. Gunicorn workers each hold a copy of the app, so memory scales with --workers.
  • The preStop sleep gives the ingress controller time to remove the pod from its endpoints before Gunicorn receives SIGTERM, reducing dropped requests during rollouts. It requires a sleep binary in the image, which slim Python images include.

6. Service and Ingress

apiVersion: v1
kind: Service
metadata:
  name: django-web
  namespace: django-prod
spec:
  selector:
    app: django-web
  ports:
    - port: 80
      targetPort: 8000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: django-web
  namespace: django-prod
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - www.example.com
      secretName: django-web-tls
  rules:
    - host: www.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: django-web
                port:
                  number: 80
how a request reaches DjangoIngressTLS, host rules, X-Forwarded-ProtoServicestable name, ready pods onlyPodGunicorn on port 8000Djangotrusts the ingress proxy headersThe Service only lists pods whose readiness probe passesdjango-deployment.com
SECURE_PROXY_SSL_HEADER is what makes Django see HTTPS requests as secure here.

This assumes the ingress-nginx controller and cert-manager with a letsencrypt-prod ClusterIssuer are installed. Adjust ingressClassName and annotations for your controller.

7. Static and media files

Pods are disposable and replicated, so do not write uploads to the container filesystem. Serve static files from the image with WhiteNoise, or upload them to object storage behind a CDN during CI. Store media in S3-compatible storage with django-storages.

8. Release flow

set -euo pipefail
TAG=$(git rev-parse --short HEAD)

docker build -t registry.example.com/django-app:$TAG .
docker push registry.example.com/django-app:$TAG

sed "s/__TAG__/$TAG/g" k8s/migrate-job.template.yaml | kubectl apply -f -
kubectl -n django-prod wait --for=condition=complete job/django-migrate-$TAG --timeout=600s

kubectl -n django-prod set image deployment/django-web web=registry.example.com/django-app:$TAG
kubectl -n django-prod rollout status deployment/django-web --timeout=300s
release flow on Kubernetes1Build and push image:shaimmutable tag2Run the migration Jobsame image, wait for complete3Update the Deployment imagekubectl set image4Rolling updatenew pods ready before old stop5rollout status succeedsor rollout undoNever let every pod run migrate in its start commanddjango-deployment.com
Old and new pods serve together during the rollout, so migrations must suit both.

rollout status exits non-zero if the rollout does not complete in time, which lets CI fail the release.

9. Verify

kubectl -n django-prod get pods -l app=django-web
kubectl -n django-prod logs deploy/django-web --tail=50
kubectl -n django-prod get ingress django-web
curl -I https://www.example.com/

Verification check:

  • all pods Running and READY 1/1
  • no restarts climbing in kubectl get pods
  • the Job for this release shows Complete
  • HTTPS works and the admin loads with styling

Rollback

kubectl -n django-prod rollout history deployment/django-web
kubectl -n django-prod rollout undo deployment/django-web
kubectl -n django-prod rollout status deployment/django-web

rollout undo restores the previous pod template (image and configuration). It does not reverse migrations or Secret changes, so keep schema changes backward compatible across one release.

Explanation

The Deployment should describe a stateless, replaceable web process. Everything that must happen once per release (migrations) belongs in a Job; everything that must persist (database, uploads) belongs outside the pod. Probes let Kubernetes decide when to send traffic (readiness) and when to restart a stuck process (liveness), and they must be designed around Django's host validation and around failure modes you actually want to trigger restarts.

Edge cases / notes

Pods crash-loop with OOMKilled

Lower Gunicorn workers per pod and scale replicas instead, or raise the memory limit based on measured usage.

Horizontal Pod Autoscaler

Set CPU requests accurately before adding an HPA; autoscaling decisions use utilization relative to requests.

Database connection limits

Replicas × Gunicorn workers × connections per worker can exceed the database limit quickly. Use PgBouncer or keep CONN_MAX_AGE modest.

Troubleshooting pods

Most Kubernetes problems with Django show up as one of a handful of pod states. Start with describe, which includes recent events, then read logs from the current and previous container:

kubectl -n django-prod get pods -l app=django-web
kubectl -n django-prod describe pod <pod-name>
kubectl -n django-prod logs <pod-name> --previous
reading pod statusStatusUsual causeImagePullBackOffwrong tag, or no pullsecret for the registryCrashLoopBackOffDjango fails at start;kubectl logs --previousOOMKilledmemory limit below workers × app sizeRunning, 0/1 readyreadiness probe failing: host or DBPendingrequests do not fit on any nodekubectl describe pod shows the events that explain each statedjango-deployment.com
The previous flag matters: the current container may have restarted too recently to log anything.
State or eventMeaningTypical fix
ImagePullBackOffimage tag missing or registry credentials absentcheck the tag, add an imagePullSecrets entry
CrashLoopBackOffGunicorn exits on bootread logs --previous; usually a missing environment variable or import error
Running but 0/1 readyreadiness probe failingprobe path returns 400 (host validation) or 404 (no route); probe port mismatch
OOMKilled in last statememory limit exceededfewer workers per pod or higher limit
Job BackoffLimitExceededmigration failedkubectl logs job/<name>; fix and create a new Job

Test a probe path from inside the cluster exactly as kubelet would, using the pod IP:

kubectl -n django-prod run probe-test --rm -it --image=curlimages/curl --restart=Never -- \
  curl -si http://<pod-ip>:8000/readyz/

If this returns 400, the probe middleware is not first in MIDDLEWARE or the path does not match exactly, including the trailing slash.

Graceful shutdown

When a pod is terminated, Kubernetes removes it from Service endpoints and sends SIGTERM at roughly the same time. The ingress controller may keep sending requests for a short moment after SIGTERM. The preStop sleep covers that gap; Gunicorn then handles SIGTERM by finishing in-flight requests within its --graceful-timeout (30 seconds by default). Keep terminationGracePeriodSeconds longer than the preStop sleep plus Gunicorn's graceful timeout, otherwise Kubernetes sends SIGKILL and drops in-flight requests.

Disruption and scheduling

Add a PodDisruptionBudget so node drains during cluster upgrades never take all replicas at once:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: django-web
  namespace: django-prod
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: django-web

Spread replicas across nodes (and zones, if your cluster spans them) with topologySpreadConstraints or pod anti-affinity, so one node failure does not remove most of your capacity.

Common mistakes

  • Using latest as the image tag, which makes rollbacks and rollout history meaningless.
  • Committing Secret manifests with real values to Git.
  • Mounting a PersistentVolume for media with ReadWriteOnce and scaling to several replicas; only one node can mount it.
  • Running Celery workers in the web Deployment instead of their own Deployment.

Next steps

FAQ

Should Django migrations run in an init container?

Not for replicated Deployments. Init containers run once per pod, so several pods starting together run migrations concurrently. Use a Kubernetes Job per release and wait for it to complete before updating the Deployment.

Why do Kubernetes probes get 400 Bad Request from Django?

Probes send the pod IP as the Host header, which is not in ALLOWED_HOSTS. Return probe responses from a middleware before host validation, rather than adding pod IP ranges to ALLOWED_HOSTS.

Should the liveness probe check the database?

Usually no. If the database goes down, every pod fails liveness and restarts, which does not fix the database and adds load during recovery. Keep liveness about the process itself.

How many Gunicorn workers should each Django pod run?

Start with two to four workers per pod with a CPU request around one core or less, then scale with replicas. Smaller pods make scheduling and rolling updates smoother than a few very large pods.

Learning path · step 1 of 16

Django on Kubernetes

Keep going