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.

Django Helm releasevalues-prod.yamlimage tag, hostshelm upgraderender templatesPre-upgrade hookmigrate JobDeploymentrolling updateThe migration hook must finish successfully before Helm updates the Deployment.
  1. Scaffold with helm create, then replace the templates with a Django Deployment, Service, Ingress, and ConfigMap.
  2. Keep environment differences in values-staging.yaml and values-prod.yaml; set the image tag per release.
  3. Create Secrets outside the chart (or with an external secrets operator) and reference them by name.
  4. Add a migration Job with helm.sh/hook: pre-install,pre-upgrade and a delete policy.
  5. Add a checksum annotation so config changes roll the pods.
  6. Deploy with helm upgrade --install ... --atomic --wait and roll back with helm 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
values that change per release and per environmentimage:tag:""# always set per releasereplicaCount:2django:secretName:myapp-secrets# created outside the chartresources:limits: { memory:768Mi}12341Empty on purpose: --set image.tag=<sha> on every upgrade.2values-prod.yaml overrides it per environment.3The chart references the Secret; it never contains it.4Above workers × memory per worker, or pods get OOMKilled.Defaults in values.yaml, differences in one file per environmentdjango-deployment.com
Nothing secret is ever passed with --set, where it would land in shell history.

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 }}
  • required fails the render if no tag is passed, which prevents accidentally deploying an empty or latest tag.
  • The checksum annotation changes whenever the ConfigMap changes, which triggers a rolling restart; without it, pods keep the old environment.
  • Probes send a Host header that Django accepts; otherwise ALLOWED_HOSTS rejects them with 400.

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 upgrade, in orderhelm upgrade--set image.tagpre-upgrade hookmigration Job runsJob succeedshook deletedDeployment updatedrolling updateRelease recordednew revisionA failing hook stops the upgrade before any pod changesdjango-deployment.com
Add --wait and --timeout so Helm reports failure if the new pods never become ready.

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
helm rollback is not a time machineRestored✓image tag and replica count✓ConfigMap and manifests✓as a new release revisionNot restored✕database schema✕Secrets created outside the chart✕data written since the upgradeDesign migrations so the previous revision still runs on the new schemadjango-deployment.com
Hooks do not run on rollback unless you add pre-rollback hooks deliberately.

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

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.

Learning path · step 13 of 16

Django on Kubernetes

Keep going