Run Django Scheduled Jobs as Kubernetes CronJobs

Schedule Django management commands on Kubernetes with CronJobs: concurrencyPolicy Forbid, time zones, startingDeadlineSeconds, history limits, resource requests, secrets, and monitoring missed or failed runs, compared with Celery Beat.

Problem statement

Django apps have periodic work: clearing expired sessions, sending digest emails, syncing data, generating reports. On a VM you would use cron or systemd timers. On Kubernetes, running cron inside the web container is fragile (every replica runs it, logs vanish, and it dies with the pod). Kubernetes CronJobs create a fresh pod from your image on schedule, run one management command, and record success or failure. They need a few settings to behave well: no overlapping runs, a defined time zone, sensible deadlines, and monitoring for runs that silently never happen.

Django on a CronJob scheduleCronJob schedulee.g. 0 3 * * *Job createdconcurrency ForbidPod runs commandmanage.py taskStatus recordedhistory + alertsEach run is a fresh pod with the app image; Kubernetes records whether it succeeded.
  1. Create a CronJob per command, using the same image tag and secrets as the web Deployment.
  2. Set concurrencyPolicy: Forbid so a slow run is never overlapped by the next one.
  3. Set timeZone (Kubernetes 1.27+) explicitly, for example Europe/Berlin or Etc/UTC.
  4. Set startingDeadlineSeconds, backoffLimit, activeDeadlineSeconds, and history limits.
  5. Make commands idempotent; a run can be retried or, rarely, start twice.
  6. Monitor with a heartbeat service or alerts on failed Jobs and missed schedules.

Step-by-step solution

1. A CronJob for a management command

apiVersion: batch/v1
kind: CronJob
metadata: { name: clearsessions, namespace: app }
spec:
  schedule: "15 3 * * *"
  timeZone: "Etc/UTC"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 600
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 1800
      ttlSecondsAfterFinished: 86400
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: task
              image: ghcr.io/you/myapp:SHA
              command: ["python", "manage.py", "clearsessions"]
              envFrom: [{ secretRef: { name: django-env } }]
              resources:
                requests: { cpu: 100m, memory: 256Mi }
                limits: { memory: 512Mi }

Keep the image tag in step with the Deployment in your release tooling (Helm values or Kustomize), so scheduled jobs run the same code as the web pods.

2. Understand the scheduling fields

  • concurrencyPolicy: Forbid skips a run if the previous one is still active. Replace kills the old run; Allow (the default) runs both, which is rarely safe for Django tasks.
  • startingDeadlineSeconds lets a run start late (for example after a controller restart) within the window; outside it, the run is counted as missed.
  • activeDeadlineSeconds kills runs that hang.
  • backoffLimit retries failed pods; set it low for non-idempotent commands.
fields that prevent surprisesSet toPreventsconcurrencyPolicy✓Forbidoverlapping runstimeZoneexplicit zoneruns at unexpected hoursstartingDeadlineSeconds300-900silent skips after outagesactiveDeadlineSecondsabove normal runtimehung pods foreverbackoffLimit0-2repeated side effectsThe defaults allow overlap and unlimited retries; override themdjango-deployment.com
Without timeZone, schedules use the controller manager's time zone, usually UTC.

3. Idempotent commands

Even with Forbid, Kubernetes documents that a CronJob can occasionally create two Jobs or none for one schedule. Write commands so a second run does no harm: process items with a "done" flag, use database locks (select_for_update(skip_locked=True)), or take an advisory lock at the start:

from django.db import connection

def handle(self, *args, **opts):
    with connection.cursor() as cur:
        cur.execute("SELECT pg_try_advisory_lock(%s)", [hash("send_digest") & 0x7FFFFFFF])
        if not cur.fetchone()[0]:
            self.stdout.write("another run in progress, exiting")
            return
        send_digests()

4. CronJobs or Celery Beat?

CronJobs suit a handful of independent, mostly daily or hourly commands, with logs and status per run in Kubernetes. Celery Beat suits many schedules, frequent intervals (every minute), schedules edited by staff in the admin, and tasks that fan out to workers. Mixing both is fine; just do not schedule the same work in both.

5. Monitor missed and failed runs

A CronJob that never runs produces no error. Add a heartbeat ping at the end of each successful command (Healthchecks.io, Cronitor, or your monitoring), and alert on kube_job_status_failed and on kube_cronjob_status_last_schedule_time falling behind with kube-state-metrics.

where should the schedule live?What do the scheduled jobs look like?few, daily or hourlyKubernetes CronJobpod per run, logs keptmany, every minuteCelery Beatworkers already runningedited in the admindjango-celery-beatdatabase schedulerUse one scheduler per job; never schedule the same work twicedjango-deployment.com
Pod startup takes seconds, so minute-level schedules fit Beat better.

Verification check: kubectl create job --from=cronjob/clearsessions test-clearsessions -n app runs the command on demand and completes; kubectl get cronjob clearsessions shows LAST SCHEDULE after the next scheduled time; the heartbeat dashboard shows the ping.

Explanation

The CronJob controller creates a Job object at each scheduled time, and the Job controller creates pods until one succeeds or the retry limit is reached. Each run is an isolated pod from your image with its own logs and exit status, which is cleaner than cron inside a long-running container. The controller's guarantees are deliberately loose (at most once per schedule is not strictly guaranteed), which is why idempotent commands and explicit concurrency settings matter.

Edge cases / notes

  • Clock and DST. Schedules in time zones with daylight saving can skip or repeat an hour; use UTC for critical jobs.
  • Database migrations should not run as CronJobs; use release Jobs.
  • Long jobs that exceed the schedule interval are skipped under Forbid; size them or split the work.
  • Suspend a CronJob during maintenance with kubectl patch cronjob x -p '{"spec":{"suspend":true}}'.

Next steps

FAQ

How do I run a Django management command on a schedule in Kubernetes?

Create a CronJob whose pod template runs python manage.py <command> with the app image and secrets.

How do I stop CronJob runs from overlapping?

Set concurrencyPolicy: Forbid.

Should I use CronJobs or Celery Beat?

CronJobs for a few daily or hourly commands; Beat for many or frequent schedules.

How do I test a CronJob immediately?

kubectl create job --from=cronjob/<name> <test-name>.

Learning path · step 7 of 16

Django on Kubernetes

Keep going