Kubernetes Zero-Touch Cron Monitoring
The UptimeHunt Kubernetes operator turns your CronJobs into cron monitors automatically — no script changes, no ping URL to wire into your workloads.
Kubernetes Zero-Touch Cron Monitoring
The same Kubernetes auto-discovery operator that turns your Ingress resources into HTTP checks also watches CronJob and Job resources and turns them into UptimeHunt cron monitors — with zero changes to the workload itself. No ping URL to copy into a script, no image to modify. Deploy the operator once with an API token and every CronJob it's allowed to see is monitored, kept in sync as the schedule changes, and reported on as its Jobs run.
How it differs from the manual ping flow
For a hand-rolled cron job or systemd timer, your script calls a ping URL. For a Kubernetes CronJob, the operator does the reporting on the workload's behalf, over the authenticated API — it never touches the monitor's ping token. Both paths land on the exact same CronMonitor resource and the exact same missed-ping sweeper.
How it works
For each in-scope CronJob, the operator:
- Upserts one UptimeHunt cron monitor (
schedule_kind: cron) fromspec.scheduleandspec.timeZone. - Watches the
Jobs thatCronJobcreates and reports their lifecycle back to the monitor:startwhile a Job is running,successon aCompletecondition,fail(with the Kubernetes failure reason attached, e.g.BackoffLimitExceeded) on aFailedcondition. - Maps
spec.suspendto the monitor'ssuspendedflag on every reconcile, in both directions.
Because the operator resolves each Job's owning CronJob itself, it doesn't wait for the CronJob to be re-reconciled — a Job starting or finishing is reported as soon as the operator sees it.
A concurrencyPolicy: Forbid/Replace CronJob can skip a scheduled slot entirely
If Kubernetes itself skips a scheduled occurrence — no Job created at all, which can happen with concurrencyPolicy: Forbid/Replace or a startingDeadlineSeconds miss — the operator has no event to report, because none occurred. UptimeHunt's own missed-ping sweeper is what turns "no run showed up within schedule + grace" into an incident, exactly as it would for a hand-rolled job that silently stopped running.
Autodiscovered monitors are editable
When the operator creates a monitor from a CronJob, you can still edit it directly in UptimeHunt — change the grace period, enable drift detection, tune its baseline/threshold, add a max runtime, pause it, or edit its settings. On every reconcile the operator re-applies schedule/timezone/metadata plus any field you've set via annotation — a field you leave annotation-free keeps whatever you last set in the dashboard. See Annotations below for exactly which fields that covers and how removing an annotation behaves.
Schedule and timezone
spec.schedule is sent to UptimeHunt verbatim as the monitor's cron_expression, after being validated client-side against the same grammar the backend accepts: a standard 5-field expression, or one of @yearly/@annually/@monthly/@weekly/@daily/@midnight/@hourly. Kubernetes itself also accepts @reboot and the @every <duration> extension in spec.schedule — neither is accepted by UptimeHunt, since neither has a fixed wall-clock time to expect a ping against. A CronJob using one of those is skipped (never upserted) and gets a Warning Kubernetes Event named InvalidCronSchedule — fix spec.schedule and the next edit re-triggers reconciliation.
spec.timeZone unset ≠ UTC scheduling — set it explicitly
When spec.timeZone is set, it's sent verbatim as the monitor's timezone. When it's unset (nil), the operator defaults the monitor's timezone to UTC — but that is not what Kubernetes itself does. A CronJob with no spec.timeZone is actually scheduled by the kube-controller-manager process's local timezone, which the operator has no way to read from the CronJob object. If your cluster's controller-manager doesn't run in UTC, a monitor defaulted to UTC will disagree with when the job actually fires, and you'll see spurious "late"/"down" states around the offset.
Set spec.timeZone explicitly (GA since Kubernetes v1.27) on any CronJob running on a non-UTC cluster:
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-db-backup
spec:
schedule: "0 4 * * *"
timeZone: "Europe/Warsaw"
jobTemplate:
spec:
template:
spec:
containers:
- name: backup
image: my-backup-image:latest
restartPolicy: OnFailureDon't put the timezone inside spec.schedule
Unlike some non-Kubernetes cron implementations, you cannot prefix spec.schedule with CRON_TZ=Europe/Warsaw or TZ=Europe/Warsaw — the Kubernetes API server itself rejects a spec.schedule that isn't a bare 5-field expression or @macro, before the operator or UptimeHunt ever see it. spec.timeZone is the only place a CronJob's timezone belongs.
Annotations
The same monitor.uptimehunt.io/ prefix used for Ingress discovery applies to CronJobs, with a slightly different set of keys:
| Annotation | Effect |
|---|---|
enabled | Opt in/out (semantics depend on discovery mode) |
name | Override the monitor's display name — used verbatim. Without it, the default is <namespace>/<cronjob-name> |
grace | Grace period in seconds before a missed run alerts (default 300) |
drift-enabled | "true" enables duration-drift detection (default false); see Drift detection |
drift-mode | "absolute" or "percent" (default percent) — which grammar drift-threshold is parsed with |
drift-threshold | Sensitivity, interpreted per drift-mode: in percent mode a bare number 0-100 (default 50); in absolute mode a bare number of seconds or an s/m/h-suffixed duration (e.g. "90s", "5m", "1h") |
drift-baseline-runs | Number of the monitor's most recent successful runs averaged into the baseline (3-50, default 5) |
max-runtime | Cap on how long a run may stay running after starting — a bare number of seconds or an s/m/h-suffixed duration. Leave unset to never alert on duration alone |
logs | "true" opts this CronJob into log capture (also requires the install-wide flag) |
allow-removal | "true" permits the operator to delete this monitor if the CronJob is later opted out or removed (default: never delete — see Monitor Removal) |
These can also be set on the Namespace as inherited defaults, exactly as for Ingress annotations — a CronJob's own annotation always wins over its namespace's.
An annotated field is re-applied on every reconcile — and removing it later doesn't undo it
The grace annotation and the drift/runtime annotations above are entirely optional — set one and the operator pushes that value on every reconcile, overriding any dashboard edit to that same field in the meantime; leave a field annotation-free and the operator omits it from the upsert, so a dashboard edit to it is preserved untouched. This is the mechanism that makes autodiscovered monitors editable at all. Removing an annotation you'd previously set does not revert the field back to a default — the field simply stops being touched by the operator going forward and keeps whatever value it currently holds. The one true "turn it off" lever is setting drift-enabled: "false" explicitly.
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-db-backup
namespace: data-platform
annotations:
monitor.uptimehunt.io/enabled: "true"
monitor.uptimehunt.io/name: "Nightly database backup"
monitor.uptimehunt.io/grace: "600"
monitor.uptimehunt.io/drift-enabled: "true"
monitor.uptimehunt.io/drift-mode: "percent"
monitor.uptimehunt.io/drift-threshold: "50"
monitor.uptimehunt.io/drift-baseline-runs: "5"
monitor.uptimehunt.io/max-runtime: "1h"
spec:
schedule: "0 4 * * *"
timeZone: "Europe/Warsaw"
# ...Suspending a CronJob
Setting spec.suspend: true on a CronJob pauses new Job creation — and the operator mirrors that onto the monitor's suspended flag on the very next reconcile, in both directions. A suspended monitor is never alerted as "missed", and its detail page shows a distinct Suspended (Kubernetes) badge instead of the regular Paused control.
Suspended is independent of a manual Pause
Only the operator ever sets or clears suspended — it never touches the monitor's user-controlled paused flag, and the sweeper suppresses alerts for either one. So if you manually Pause a monitor in the UI and someone later un-suspends the CronJob (spec.suspend: false), the monitor stays paused — un-suspending in Kubernetes can't silently override a pause you set by hand. To fully resume, both suspended and paused need to be cleared (spec.suspend: false on the CronJob, and Resume in the UI).
Log capture (opt-in)
When enabled, the operator attaches the tail (up to 100 KB) of a terminal Job's pod log to its success/fail report — useful for seeing why a job failed without kubectl logs-ing into the cluster. Because reading pod contents, not just metadata, is a real expansion of the operator's trust footprint, it's opt-in at two levels:
- Install-wide —
logCapture.enabled=truein the Helm chart, which additionally grants the operator's ServiceAccountpods get;listandpods/log get. - Per-
CronJob— themonitor.uptimehunt.io/logs: "true"annotation. The install-wide flag alone captures nothing.
helm upgrade --install uptimehunt-kubediscovery \
oci://ohcr.io/uptimehunt/autodiscovery/kubernetes/charts/uptimehunt-kubediscovery \
--namespace uptimehunt-kubediscovery \
--reuse-values \
--set logCapture.enabled=truemetadata:
annotations:
monitor.uptimehunt.io/logs: "true"Monitor removal
Like the Ingress side of the operator, cron monitoring is add/update-only by default: a CronJob disappearing doesn't delete its monitor, because a vanished CronJob may itself be the misconfiguration worth alerting on. Set monitor.uptimehunt.io/allow-removal: "true" on the CronJob (or its namespace) to let the operator prune the monitor when the CronJob is deleted or opted out.
Pruning is best-effort
Unlike Ingress checks, the cron monitors API has no lookup-by-source route yet, so the operator can only prune a monitor whose id is still in its own in-memory cache from an earlier upsert in the same process. A cold cache — right after the operator restarts, before that CronJob has been reconciled again — means the prune is skipped (logged, not silently dropped). If you need a guaranteed removal, delete the monitor directly in UptimeHunt.
Prerequisites
Everything from Kubernetes Auto-Discovery applies: install the operator with Helm and an organization service token (Automation preset). No extra RBAC configuration is needed to enable CronJob/Job watching — it's included in the same discovery-watcher Role/ClusterRole the Ingress watcher uses.
Related Documentation
- Cron Job Monitoring — the manual ping flow, curl idioms, schedule grammar
- Kubernetes Auto-Discovery (Ingress → HTTP checks)
- Cron Monitors API Reference
- Service Account / Organization Tokens
Cron Job Monitoring
Monitor scheduled jobs — cron, systemd timers, CI pipelines, Kubernetes CronJobs — by having them ping UptimeHunt on start/success/fail. A ping that never arrives raises an incident.
Monitor GitHub Actions Scheduled Workflows
Ping UptimeHunt from a GitHub Actions workflow to monitor a scheduled run the same way you'd monitor a crontab entry.