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.
Quick answer
- Push an immutable image tag (for example the Git SHA) to your registry.
- Store
DJANGO_SECRET_KEYand database credentials in a Kubernetes Secret; non-secret settings in a ConfigMap. - Run
python manage.py migrate --noinputas a Kubernetes Job using the new image, and wait for it to complete. - Update the Deployment image; it runs Gunicorn only, with readiness and liveness probes on a lightweight endpoint.
- Expose it through a ClusterIP Service and an Ingress with TLS.
- Set
SECURE_PROXY_SSL_HEADERandALLOWED_HOSTSfor the ingress hostname, and handle probe requests before host validation. - 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)
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: 0fails fast instead of retrying a broken migration repeatedly.ttlSecondsAfterFinishedcleans 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: 0keeps 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
preStopsleep gives the ingress controller time to remove the pod from its endpoints before Gunicorn receivesSIGTERM, reducing dropped requests during rollouts. It requires asleepbinary 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
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
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
RunningandREADY 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
| State or event | Meaning | Typical fix |
|---|---|---|
ImagePullBackOff | image tag missing or registry credentials absent | check the tag, add an imagePullSecrets entry |
CrashLoopBackOff | Gunicorn exits on boot | read logs --previous; usually a missing environment variable or import error |
Running but 0/1 ready | readiness probe failing | probe path returns 400 (host validation) or 404 (no route); probe port mismatch |
OOMKilled in last state | memory limit exceeded | fewer workers per pod or higher limit |
Job BackoffLimitExceeded | migration failed | kubectl 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
latestas the image tag, which makes rollbacks androllout historymeaningless. - Committing Secret manifests with real values to Git.
- Mounting a PersistentVolume for media with
ReadWriteOnceand scaling to several replicas; only one node can mount it. - Running Celery workers in the web Deployment instead of their own Deployment.
Next steps
- Prepare the image with How to Dockerize a Django App for Production.
- Design useful health endpoints with How to Build a Django Health Check Endpoint for Deployments.
- Plan multi-instance behavior with Scale Django Horizontally Behind a Load Balancer.
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.