Kubernetes CronJob — A Complete Guide
Kubernetes CronJob takes the standard Unix 5-field cron syntax and wraps it in production knobs: timezone control, concurrency policies, history limits, and a controller that can either survive downtime gracefully or flood you with catch-up runs depending on how you configure it. This guide walks through the whole resource end-to-end.
Updated
The CronJob resource
Kubernetes CronJob is a workload controller (batch/v1, GA since Kubernetes 1.21) that creates a Job on a recurring schedule. Each scheduled fire creates one Job; each Job creates one or more Pods to run your container.
A minimal CronJob looks like this:
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-backup
spec:
schedule: "0 0 * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: backup
image: my-org/backup:latest
restartPolicy: OnFailure
That’s enough to run my-org/backup:latest once a day at midnight (in the controller-manager’s timezone — usually UTC; see Timezone below). Everything else in this guide is about production hardening.
Schedule syntax
spec.schedule accepts standard 5-field Unix cron — see the Unix cron dialect reference for the full syntax. Quick recap:
minute hour day-of-month month day-of-week
0–59 0–23 1–31 1–12 0–6 (Sun=0)
Common schedules:
| Schedule | Meaning |
|---|---|
*/5 * * * * | Every 5 minutes |
0 * * * * | Every hour, on the hour |
0 0 * * * | Daily at midnight |
0 9 * * 1-5 | Weekdays at 9 AM |
0 0 1 * * | First day of every month |
Kubernetes also accepts these named shortcuts:
| Shortcut | Expands to |
|---|---|
@hourly | 0 * * * * |
@daily or @midnight | 0 0 * * * |
@weekly | 0 0 * * 0 |
@monthly | 0 0 1 * * |
@yearly or @annually | 0 0 1 1 * |
@reboot is not supported — Kubernetes has no concept of “system boot.” If you need a job to run once at startup, use an init container or a one-shot Job.
The minimum granularity is one minute. For sub-minute scheduling you’ll need a long-running Deployment with an in-process scheduler, an Argo Workflow loop, or a queue like Celery Beat.
Timezone (spec.timeZone)
Kubernetes 1.27+ supports per-CronJob timezones via spec.timeZone:
spec:
schedule: "0 9 * * 1-5"
timeZone: "America/Los_Angeles"
The value must be a valid IANA timezone name (e.g., Europe/London, Asia/Tokyo, UTC). The cluster will refuse to apply a CronJob with an unknown timezone.
On clusters older than 1.27, spec.timeZone is silently ignored. Your schedule will run in the controller-manager’s timezone (usually UTC) regardless of what you set. If you can’t upgrade, the standard workaround is to offset the cron expression manually — 0 17 * * 1-5 runs at 9 AM Pacific Standard Time (UTC-8), 0 16 * * 1-5 runs at 9 AM Pacific Daylight Time (UTC-7). You’ll need to live with the DST drift twice a year or maintain two CronJobs with date-range gating.
For brand-new clusters: pin the timezone explicitly even when you mean UTC. spec.timeZone: "UTC" is unambiguous; “no timezone set” is a configuration choice you have to remember.
Concurrency policy
When the schedule fires and a previous Job is still running, what happens depends on spec.concurrencyPolicy:
| Value | Behavior |
|---|---|
Allow (default) | Spawn a new Job alongside the running one. Both run in parallel. |
Forbid | Skip the new fire. The previous Job continues; the missed schedule is logged but no new Job is created. |
Replace | Cancel the running Job (delete its Pods), then create a new one. |
Pick Forbid unless you’ve proven your job is idempotent and parallelism-safe. The default Allow is the source of most “why is my database getting hammered” CronJob incidents. A schedule like */5 * * * * combined with a Job that takes 7 minutes will, under Allow, run two Jobs concurrently every 5 minutes — and the count grows if the Jobs take longer.
Replace is useful when freshness matters more than completion (e.g., a periodic cache refresh where the latest run wins).
Catch-up behavior
If the CronJob controller is unavailable when a schedule fires (controller pod restart, scheduler backlog, cluster upgrade), Kubernetes records the miss and tries to catch up when the controller comes back. This catch-up behavior is governed by spec.startingDeadlineSeconds.
spec:
startingDeadlineSeconds: 60
With this set to 60, the controller will only create a Job for a scheduled fire if it can do so within 60 seconds of the original schedule. Older missed fires are dropped.
If you don’t set startingDeadlineSeconds, the controller counts every fire missed since the CronJob last ran — and if that count exceeds 100, it stops scheduling entirely. After a 3-hour outage, a */5 * * * * CronJob has missed ~288 fires (>100), so instead of catching up the controller gives up and logs Cannot determine if job needs to be started. Too many missed start times (> 100) — the job silently stops running until you fix it. (Under 100 missed, it starts a single catch-up run, not a burst.) Setting startingDeadlineSeconds scopes the count to that many seconds back, which both avoids this dead-stop and caps how stale a fire it will honor.
Recommended defaults:
- For frequent jobs (
*/5 * * * *,*/15 * * * *):startingDeadlineSeconds: 60or less. - For daily jobs (
0 0 * * *):startingDeadlineSeconds: 3600(one hour grace) is reasonable. - For monthly or yearly jobs: set deliberately. A missed monthly billing run probably needs human intervention, not an automatic 24-hour-late retry.
History limits
spec.successfulJobsHistoryLimit (default 3) and spec.failedJobsHistoryLimit (default 1) control how many completed Job objects the controller keeps. Once a Job is older than the limit, it (and its Pods) get deleted.
spec:
successfulJobsHistoryLimit: 10
failedJobsHistoryLimit: 10
Bump these if you need a longer audit trail. Logs are kept for as long as the Pod exists, so a low history limit means you lose access to the logs of older runs.
If you need durable run history beyond what Kubernetes retains, ship logs to a central system (Loki, Datadog, CloudWatch, etc.) before the Pod is garbage-collected — don’t rely on kubectl logs for anything long-term.
Suspending a CronJob
To temporarily stop a CronJob without deleting it:
kubectl patch cronjob nightly-backup -p '{"spec":{"suspend":true}}'
spec.suspend: true stops the controller from creating new Jobs. Existing Jobs continue to completion. Unsuspend with suspend:false when you’re ready.
This is the cleanest way to pause a CronJob during an incident or maintenance window — better than scaling it down (which doesn’t apply to CronJobs anyway) or deleting and reapplying.
Debugging
When a CronJob isn’t behaving:
# See the CronJob and its recent status
kubectl describe cronjob nightly-backup
# List Jobs created by the CronJob (they're named <cronjob>-<timestamp>, so they
# sort together; there's no label linking a Job to its CronJob — the link is ownerReferences)
kubectl get jobs | grep '^nightly-backup-'
# Get logs from the most recent Job's Pod
kubectl logs job/nightly-backup-1735082400
Common diagnostic patterns:
- Job ran but failed —
kubectl describe job <name>shows the failure reason.kubectl logsshows container output. - Job didn’t run at the expected time — check
kubectl describe cronjobfor theLast Schedule TimeandActivefields. Compare with cluster time (kubectl get nodes -o json | jq '.items[0].status.conditions'). - Cron expression isn’t doing what you think — paste it into the parser with the Kubernetes dialect selected, and verify the next-run times match your intent.
- The controller can’t create Jobs — usually an RBAC issue. The CronJob controller uses the cluster’s default service account; if you’ve restricted it, you may need to grant it
createonjobs.batch.
Production checklist
Before shipping a CronJob to a real cluster:
-
spec.timeZoneset explicitly (even if it’s “UTC”) -
spec.concurrencyPolicy: Forbidunless parallelism is proven safe -
spec.startingDeadlineSecondsset to bound catch-up behavior -
spec.successfulJobsHistoryLimitandspec.failedJobsHistoryLimittuned for your audit/log retention needs -
jobTemplate.spec.activeDeadlineSecondsset as a hard cap on Job runtime -
jobTemplate.spec.backoffLimitset (default 6 retries on failure can be a lot) - Container
restartPolicy: OnFailure(notAlways, which isn’t supported on Jobs) - Resource requests/limits on the container
- Logs being shipped somewhere durable
- A way to manually trigger the Job (often:
kubectl create job --from=cronjob/<name> <one-shot-name>) - Monitoring/alerting on consecutive failures
The defaults are sane for development. They are not sane for production at scale — every production CronJob incident I’ve seen traces back to one of the boxes above being unchecked.
Frequently asked questions
What cron syntax does Kubernetes CronJob use?
What timezone does a Kubernetes CronJob use?
How do I prevent overlapping runs?
Why did my CronJob fire late (or stop firing) after downtime?
How do I see past Job runs?
Related
Every 5 Minutes
`*/5 * * * *` — the most common high-frequency CronJob schedule.
PatternDaily at Midnight
`0 0 * * *` — the canonical nightly job.
ToolCron Parser
Paste any expression and see it explained with next-run times.
GuideCron Dialect Comparison
How Kubernetes CronJob differs from Quartz, EventBridge, and other cron dialects.
GuideHow Cron Expressions Work
The 5-field cron syntax Kubernetes uses.