Kubernetes CronJob: A Practical Guide
Kubernetes borrowed cron's five-field syntax and added a scheduler with its own set of surprises.
Updated 5 August 2026 · 6 min read
-
Written by
Andrian Valeanu
Founder of Pulsetic
-
Reviewed by
Ionut Caval
Technical reviewer
A CronJob creates a Job on a repeating schedule, using the same five-field cron syntax. It is apiVersion: batch/v1, and the pod's restartPolicy must be OnFailure or Never. Schedules run in UTC unless you set spec.timeZone, and by default a slow job can overlap the next run.
Key takeaways
- Use
batch/v1. The olderbatch/v1beta1was removed in Kubernetes 1.25. - Schedules are UTC unless
spec.timeZoneis set (stable since 1.27). concurrencyPolicydefaults toAllow, so a slow job will overlap itself.- Only the last 3 successful and 1 failed Job are kept, which is often why the logs you want are gone.
- If more than 100 schedules are missed and
startingDeadlineSecondsis unset, the controller stops scheduling entirely.
The manifest
A CronJob is a controller that creates a Job on a schedule, and each Job creates one or more pods to do the work. The schedule field takes the same five-field expression as Unix cron.
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-report
spec:
schedule: "0 3 * * *"
timeZone: "Europe/Berlin" # stable since Kubernetes 1.27
concurrencyPolicy: Forbid
startingDeadlineSeconds: 300
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 3600
template:
spec:
restartPolicy: OnFailure # must be OnFailure or Never
containers:
- name: report
image: registry.example.com/report:1.4.0
args: ["/usr/bin/python3", "/app/report.py"]
Use batch/v1. The older batch/v1beta1 was deprecated in 1.21 and removed in 1.25, so manifests carrying it fail outright on any current cluster.
Timezones
Without spec.timeZone, schedules are interpreted in the timezone of the kube-controller-manager, which in practice means UTC. A job written as 0 3 * * * and expected at 3 a.m. local time will run at 3 a.m. UTC, an hour or more adrift depending on the season.
Setting timeZone to an IANA name such as Europe/Berlin or America/New_York fixes that, and handles daylight saving transitions for you. The field reached stable in Kubernetes 1.27.
Schedules that fall inside a daylight saving transition can be skipped or repeated. For anything sensitive, schedule outside the 01:00 to 03:00 local window.
Overlapping runs
Like Unix cron, a CronJob does not wait for the previous run to finish, and the default policy makes that explicit:
| concurrencyPolicy | Behaviour |
|---|---|
Allow (default) | Starts the new Job regardless of whether the previous one is still running |
Forbid | Skips the new run entirely if the previous Job is still active |
Replace | Cancels the running Job and starts the new one in its place |
For anything that writes to shared state, Forbid is almost always what you want. Note that a skipped run is a missed run, and Kubernetes counts it as such, which matters for the trap in the next section.
The missed-schedule trap
This is the CronJob behaviour that catches people out most sharply. The controller counts how many scheduled runs it has missed. If that count passes 100 and startingDeadlineSeconds is not set, it stops scheduling the CronJob altogether and logs an error.
A CronJob running every minute reaches that limit after roughly an hour and forty minutes of controller downtime, a cluster upgrade, or a suspended CronJob left suspended. It then stays stopped, quietly, until somebody notices.
Setting startingDeadlineSeconds prevents it. The field means "if a run is more than this many seconds late, skip it rather than starting it", and it also resets the missed-run accounting to that window instead of counting forever.
spec:
schedule: "*/5 * * * *"
startingDeadlineSeconds: 200 # skip a run that is more than ~3 minutes late
Set startingDeadlineSeconds on every CronJob. Without it, a long enough outage stops the schedule permanently rather than resuming it.
Get told when a scheduled job stops
Cron job monitoring alerts on a missing check-in, which covers a suspended CronJob and a tripped schedule limit alike.
Running one manually
You do not need to wait for the schedule, or edit it to a time a minute from now. Create a Job from the CronJob template directly:
kubectl create job --from=cronjob/nightly-report manual-run-1
# Watch it
kubectl get jobs -w
kubectl logs job/manual-run-1
# Clean up
kubectl delete job manual-run-1
To pause a schedule without deleting it, suspend it. Remember the missed-schedule trap above before leaving it suspended for long:
kubectl patch cronjob nightly-report -p '{"spec":{"suspend":true}}'
kubectl patch cronjob nightly-report -p '{"spec":{"suspend":false}}'
Debugging
The CronJob object itself tells you when it last fired. The Jobs it created tell you what happened, and the pods tell you why.
kubectl get cronjob nightly-report
# NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE
# nightly-report 0 3 * * * Europe/Berlin False 0 8h
kubectl get jobs --selector=job-name
kubectl describe cronjob nightly-report # events, including missed schedules
kubectl logs job/nightly-report-28901234
kubectl describe pod <pod-name> # image pull errors, OOMKills
If the logs you want are missing, the history limits are usually why. Kubernetes keeps only the last 3 successful and 1 failed Job by default, and everything older is deleted along with its pods and their logs. Raise failedJobsHistoryLimit on any CronJob you expect to debug, or ship logs off the cluster.
Retries and deadlines
Two fields on the Job template control what happens when the work itself fails or hangs. backoffLimit sets how many times the Job retries a failing pod, defaulting to 6, with an exponential delay between attempts. activeDeadlineSeconds caps the total runtime, after which the Job is terminated and marked failed.
The deadline is the more valuable of the two. Without it, a pod blocked on a network call it will never get an answer to runs indefinitely, holding cluster resources and, under concurrencyPolicy: Forbid, blocking every subsequent run.
A CronJob that stops is still silent
Kubernetes gives you far more visibility than a crontab does. It does not give you notice. A CronJob that was suspended and forgotten, that tripped the 100-missed-schedule limit, or whose Jobs have been failing and rolling out of the history window, produces no alert on its own. You find out by looking, and looking is exactly what nobody does while things appear fine.
The same fix applies as anywhere else: have the job report in when it succeeds, and alert on the report not arriving. A ping at the end of the container command is enough, and cron job monitoring raises the alert when a scheduled check-in does not turn up, whether the cause was the job, the controller, or the cluster.
See how Pulsetic's cron job monitoring catches this from the outside, across 15+ locations.
Frequently asked questions
-
What timezone do Kubernetes CronJobs use?
UTC by default, since the schedule is interpreted in the timezone of the kube-controller-manager. Set spec.timeZone to an IANA name such as Europe/Berlin to use a specific zone. The field has been stable since Kubernetes 1.27.
-
How do I run a Kubernetes CronJob manually?
Create a Job from the CronJob template: "kubectl create job --from=cronjob/my-cronjob manual-run-1". It runs immediately with the same pod spec, without changing the schedule.
-
Why did my CronJob stop running altogether?
If more than 100 scheduled runs are missed and startingDeadlineSeconds is not set, the controller stops scheduling the CronJob and logs an error. This happens after controller downtime or a long suspension. Set startingDeadlineSeconds to avoid it, and check "kubectl describe cronjob" for the event.
-
Why are my old CronJob logs missing?
Kubernetes keeps only the last 3 successful and 1 failed Job by default, deleting older ones along with their pods and logs. Raise successfulJobsHistoryLimit and failedJobsHistoryLimit, or ship logs to a system outside the cluster.
-
Catch the next outage before your visitors do.
2-minute setup · Cancel any time
-
No credit card needed