
A note from the founder. Chasing a Kubernetes CronJob that quietly stopped firing? I'm looking for a small group of early users to try Runhooks and share honest feedback. Early adopters get upgraded plans for free.
You check on a CronJob that's supposed to run every hour and find it hasn't fired since yesterday. No failed pod, no alert, no error in your dashboards — it just stopped, and the only clue is buried in the controller's events:
Cannot determine if job needs to be started: too many missed start times
Kubernetes CronJobs are convenient, but they fail quietly in several distinct ways. Once you know the failure modes, you can decide which jobs are safe to leave on the built-in controller and which need a more reliable trigger.
Why Kubernetes CronJobs Miss Runs
The CronJob controller is best-effort, and several mechanisms can silently drop a run:
1. The controller fell behind, or was down
The controller has to be running at the scheduled minute to create the Job. If it's unavailable — a control-plane hiccup, a node drain, an upgrade — the scheduled time can pass unhandled. Whether the run is recovered depends on startingDeadlineSeconds.
2. startingDeadlineSeconds skipped it
If the controller is late by more than startingDeadlineSeconds, it skips that run entirely rather than starting it late. Leave the field unset and a missed window is simply lost. Set it too low and normal scheduling jitter causes skips.
3. concurrencyPolicy: Forbid suppressed it
With Forbid, if the previous run is still executing when the next is due, the new run is skipped, not queued. A job that occasionally runs long can quietly eat its own next execution — and the one after that.
4. "Too many missed start times"
If the controller counts more than 100 missed schedules since the last successful start — common after a CronJob is suspended, or after the controller is down for a while — it gives up with too many missed start times and stops trying. The job stays dead until someone notices.
5. Timezone confusion
Unless you set spec.timeZone, many clusters interpret the schedule in UTC. A 0 9 * * * you meant for 9am local runs at the wrong hour, which looks a lot like "not running" to whoever's expecting it.
The common thread: the CronJob controller optimizes for in-cluster batch scheduling, not for guaranteed timing or visibility. When a run is skipped, nothing pages you — you find out when the downstream data is missing.
The Fix: Trigger and Watch From Outside the Cluster
For jobs where timing and guaranteed execution actually matter, the reliable pattern is to drive the schedule from outside the cluster and treat the in-cluster workload as the thing that runs, not the thing that keeps time.
Expose the work behind an authenticated endpoint — an existing service route, or a small handler that creates the Kubernetes Job via the API:
# The Job template stays in the cluster; an external call decides WHEN it runs.
apiVersion: batch/v1
kind: Job
metadata:
generateName: nightly-report-
spec:
template:
spec:
containers:
- name: report
image: myorg/report:latest
restartPolicy: Never
An external scheduler calls your endpoint (or the Kubernetes API) at the exact time. Because the trigger lives outside the control plane, a controller hiccup, a suspended CronJob, or a too many missed start times condition can't silently swallow a run — and every attempt is logged and alertable.
Why a DIY External Cron Isn't Enough
Moving the trigger to a cron job on a bastion host just relocates the problem:
- Requires an always-on machine that you now have to patch and monitor.
- Fails silently — a failed
kubectl create jobor API call gets no retry and no alert. - No execution history across runs, so a missed trigger looks identical to a run that never was.
- No retries — a transient API error means a lost run.
You'd rebuild retries, logging, and alerting around a script — which is a scheduler with none of the guarantees.
How Runhooks Triggers Kubernetes Jobs Reliably
Runhooks is a scheduled HTTP execution service, and triggering an in-cluster job is just an HTTP call:
- Create a job — name it "Trigger nightly report."
- Set the URL — your authenticated in-cluster endpoint (behind an Ingress) or the Kubernetes API endpoint that creates the Job.
- Set the method and auth —
POSTwith the appropriate token or shared secret header. - Set the schedule — any cron expression, in a timezone you specify explicitly, with retries enabled.
What you get that the native controller doesn't:
- Accurate timing — the job is triggered when you asked, independent of control-plane state.
- Automatic retries — a transient API error retries instead of silently losing the run.
- Execution logs — every trigger recorded with status and response, so a skipped run is visible.
- Missed-run alerts — you're notified when a run fails or doesn't happen, instead of discovering it from missing data.
- Unambiguous timezone — the schedule is defined once, in the timezone you choose.
This is the same principle behind triggering GitHub Actions reliably: separate the timing from the execution, and put a scheduler you can observe in front of it.
When the Built-in CronJob Is Fine
To be fair, native CronJobs are a good fit for in-cluster batch work that tolerates drift — log rotation, cache warming, cleanup jobs where "sometime in the next few minutes" and an occasional skipped run are acceptable. Set startingDeadlineSeconds and spec.timeZone sensibly and they'll serve you well.
Reach for an external trigger when exact timing, guaranteed execution, or missed-run alerting matter — billing runs, data exports, SLA-bound syncs — the jobs where a silent skip has real consequences.
Get Started
Kubernetes is excellent at running jobs and only best-effort at scheduling them. Separate the two:
- Expose the job as an authenticated endpoint (or use the Kubernetes API to create the Job).
- Try Runhooks and trigger it on an exact schedule with retries, logs, and missed-run alerts.
- Build and preview your cron expression with the cron visualizer.
Frequently Asked Questions
Why is my Kubernetes CronJob not running?
The most common causes are: the CronJob controller was unavailable past startingDeadlineSeconds so the run was skipped; concurrencyPolicy: Forbid suppressed the run because the previous job was still active; the schedule is being interpreted in UTC rather than your local timezone; or the job hit the "too many missed start times" condition after being paused or after the controller fell behind. None of these produce an obvious error unless you're watching events.
What does "too many missed start times" mean in Kubernetes?
The CronJob controller logs "Cannot determine if job needs to be started: too many missed start times" when it counts more than 100 missed schedules since the last successful run. This usually happens after the CronJob was suspended, or the controller was down long enough to miss many runs. Once it trips, the controller stops trying to catch up and your job stays stopped until you fix the underlying gap.
How do I make a Kubernetes CronJob run reliably on time?
For jobs where exact timing and guaranteed execution matter, trigger the work from outside the cluster instead of relying solely on the CronJob controller. An external scheduler like Runhooks calls an in-cluster endpoint (or an API that creates the Job) at the precise time, retries if the call fails, logs every attempt, and alerts you when a run is missed — the visibility native CronJobs don't provide.
Do Kubernetes CronJobs support timezones?
Modern Kubernetes supports a spec.timeZone field on CronJobs, but many clusters and manifests still rely on the controller's default, which interprets schedules in UTC. If you set 0 9 * * * expecting 9am local time and the controller uses UTC, the job runs at the wrong hour. Always set timeZone explicitly, or drive the schedule from an external scheduler where the timezone is unambiguous.
Read next: node-cron Running Twice? Fix Duplicate Jobs · Why Cron Jobs Fail in Production · GitHub Actions Scheduled Workflows Are Unreliable · How to Monitor Cron Jobs