Deploy Django to Kubernetes with a Helm Chart
Package Django for Kubernetes with a Helm chart: values per environment, Deployment and Ingress templates, a pre-upgrade migration hook, config checksums, and helm rollback.
Problem statement
A Django app on Kubernetes needs several manifests: a Deployment for Gunicorn, a Service, an Ingress, a ConfigMap, Secrets, a migration Job, and often Celery workers. Copying those YAML files between staging and production quickly leads to drift, and hand-editing image tags is error-prone.
Helm packages the manifests as a chart with templated values, so each environment is one values file and each release is a versioned, reversible revision. This guide builds a small, production-ready chart for Django, including the part most charts get wrong: running migrations exactly once per release, before the new pods take traffic.
- Scaffold with
helm create, then replace the templates with a Django Deployment, Service, Ingress, and ConfigMap. - Keep environment differences in
values-staging.yamlandvalues-prod.yaml; set the image tag per release. - Create Secrets outside the chart (or with an external secrets operator) and reference them by name.
- Add a migration Job with
helm.sh/hook: pre-install,pre-upgradeand a delete policy. - Add a checksum annotation so config changes roll the pods.
- Deploy with
helm upgrade --install ... --atomic --waitand roll back withhelm rollback.
Step-by-step solution
1. Chart layout
helm create chart
rm -rf chart/templates/tests chart/templates/hpa.yaml
A minimal layout:
chart/
Chart.yaml
values.yaml
templates/
_helpers.tpl
configmap.yaml
deployment.yaml
service.yaml
ingress.yaml
migrate-job.yaml
2. Values
# chart/values.yaml
image:
repository: ghcr.io/example/myapp
tag: "" # always set per release
pullPolicy: IfNotPresent
replicaCount: 2
django:
settingsModule: config.settings.production
allowedHosts: example.com
secretName: myapp-secrets # created outside the chart
gunicorn:
workers: 3
timeout: 30
resources:
requests: { cpu: 250m, memory: 384Mi }
limits: { memory: 768Mi }
ingress:
className: nginx
host: example.com
tlsSecret: example-com-tls
# values-prod.yaml
replicaCount: 3
django:
allowedHosts: example.com,www.example.com
ingress:
host: example.com
3. ConfigMap and Deployment
# templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "chart.fullname" . }}
data:
DJANGO_SETTINGS_MODULE: {{ .Values.django.settingsModule | quote }}
DJANGO_ALLOWED_HOSTS: {{ .Values.django.allowedHosts | quote }}
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "chart.fullname" . }}
spec:
replicas: {{ .Values.replicaCount }}
strategy:
rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }
selector:
matchLabels: {{- include "chart.selectorLabels" . | nindent 6 }}
template:
metadata:
labels: {{- include "chart.selectorLabels" . | nindent 8 }}
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
spec:
containers:
- name: web
image: "{{ .Values.image.repository }}:{{ required "image.tag is required" .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["gunicorn", "config.wsgi:application",
"--bind", "0.0.0.0:8000",
"--workers", "{{ .Values.gunicorn.workers }}",
"--timeout", "{{ .Values.gunicorn.timeout }}",
"--access-logfile", "-"]
ports:
- containerPort: 8000
envFrom:
- configMapRef: { name: {{ include "chart.fullname" . }} }
- secretRef: { name: {{ .Values.django.secretName }} }
readinessProbe:
httpGet:
path: /healthz/
port: 8000
httpHeaders: [{ name: Host, value: {{ .Values.ingress.host | quote }} }]
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz/
port: 8000
httpHeaders: [{ name: Host, value: {{ .Values.ingress.host | quote }} }]
periodSeconds: 20
failureThreshold: 3
resources: {{- toYaml .Values.resources | nindent 12 }}
requiredfails the render if no tag is passed, which prevents accidentally deploying an empty orlatesttag.- The checksum annotation changes whenever the ConfigMap changes, which triggers a rolling restart; without it, pods keep the old environment.
- Probes send a
Hostheader that Django accepts; otherwiseALLOWED_HOSTSrejects them with400.
4. Migration hook
# templates/migrate-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "chart.fullname" . }}-migrate
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "0"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
backoffLimit: 0
activeDeadlineSeconds: 600
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
command: ["python", "manage.py", "migrate", "--noinput"]
env:
- name: DJANGO_SETTINGS_MODULE
value: {{ .Values.django.settingsModule | quote }}
- name: DJANGO_ALLOWED_HOSTS
value: {{ .Values.django.allowedHosts | quote }}
envFrom:
- secretRef: { name: {{ .Values.django.secretName }} }
Helm runs pre-upgrade hooks before applying the new manifests and waits for the Job to complete. If it fails, the upgrade fails and the Deployment is not touched. The hook cannot use the chart's ConfigMap on a first install, because hooks run before normal resources are created; that is why it sets its environment directly and uses a Secret that exists before the chart.
before-hook-creation removes the previous Job before creating a new one, and hook-succeeded cleans up after success. Failed Jobs stay around for inspection.
5. Create Secrets and deploy
kubectl create namespace myapp
kubectl -n myapp create secret generic myapp-secrets \
--from-literal=DJANGO_SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(50))')" \
--from-literal=DATABASE_URL='postgres://myapp:pw@db.internal:5432/myapp?sslmode=require'
helm lint chart -f values-prod.yaml --set image.tag=1.5.0
helm template myapp chart -f values-prod.yaml --set image.tag=1.5.0 | less
helm upgrade --install myapp chart -n myapp \
-f values-prod.yaml --set image.tag=1.5.0 \
--atomic --wait --timeout 10m
--atomic rolls the release back automatically if the upgrade fails or pods do not become ready within the timeout. Helm 4 names this flag --rollback-on-failure; check helm upgrade --help for your version.
6. Roll back
helm history myapp -n myapp
helm rollback myapp <revision> -n myapp --wait
Rollback restores the previous manifests (image tag, config) but does not reverse database migrations. Keep migrations backward compatible so the previous revision's code runs against the newer schema.
Verification check:
helm status myapp -n myapp
kubectl -n myapp rollout status deployment/myapp-chart
kubectl -n myapp get pods
curl -sI https://example.com/healthz/
The Deployment name depends on your chart.fullname helper; kubectl -n myapp get deploy shows it.
Explanation
A Helm release is a named, versioned installation of a chart. Each helm upgrade renders templates with the merged values and records a new revision, which is what makes helm history and helm rollback possible. Hooks let you insert steps at defined points in the release lifecycle; a pre-upgrade migration Job gives Django the order it needs: schema first, then new code, with the rolling update keeping old pods serving until new ones are ready.
Edge cases / notes
Long migrations
The hook blocks the release until it completes. Operations that rewrite large tables should run as separate, planned migrations with their own timeout rather than inside a routine release.
Celery and Beat
Add separate Deployments for workers and a single-replica Beat Deployment using the same image and ConfigMap. Beat must never run with more than one replica.
Secrets in Git
Do not put secret values in values files. Use Kubernetes Secrets created by a controlled process, an external secrets operator, or sealed secrets, and reference them by name.
When to automate this
Run helm lint, helm template, and helm upgrade --install --atomic from CI with the image tag set to the commit SHA that was just built. For multiple clusters or environments, a GitOps controller that applies the chart from Git is the natural next step.
Next steps
- Understand the underlying objects in Deploy Django on Kubernetes.
- Compare hook-based and job-based approaches in Run Django Migrations as a Kubernetes Job.
- Move to Git-driven releases with Deploy Django with Argo CD (GitOps).
- Review everything with the Django Kubernetes Production Checklist.
FAQ
Should Django migrations run as a Helm hook?
It is a reasonable default: a pre-upgrade hook runs once per release before pods update, and a failure stops the release. Very long migrations are better run as separate, planned Jobs.
Why don't my pods pick up ConfigMap changes after helm upgrade?
Environment variables are read at container start. Add a checksum annotation of the ConfigMap to the pod template so a change triggers a rolling restart.
Does helm rollback undo Django migrations?
No. It only restores Kubernetes manifests. Write backward-compatible migrations, and reverse schema changes deliberately with a new migration if needed.
Should I use the latest tag in a Helm chart?
No. Use an immutable tag such as a version or commit SHA, so each Helm revision maps to exactly one image and rollbacks are meaningful.