Jenkins Cron: Scheduled Builds, the H Token, and Missed Runs
Contents
To run a Jenkins job on a schedule, give it a cron trigger: tick Build periodically under Triggers in a freestyle job, or add triggers { cron('H 2 * * *') } to a declarative Jenkinsfile. The spec has Unix cron's five fields plus H, a value hashed from the job's name, so a dozen nightly jobs don't all start in the same minute. Schedules follow the controller's time zone unless the spec starts with a TZ= line. A scheduled build can also fail to happen without any error: Jenkins doesn't replay minutes it was down for, skips disabled jobs, and registers a Jenkinsfile's trigger only after a build has run it.
Behavior below was checked on October 25, 2026 against the source and documentation of Jenkins 2.580.1 LTS and the current plugin releases: Pipeline: Declarative 2.2293.v6e7193cec599, Pipeline: Job 1609.v8926e7962024, Pipeline: Multibranch 842.v3a_b_59b_57b_e6e, Pipeline: Basic Steps 1098.v808b_fd7f8cf4, Durable Task 686.v80ff80875b_82, Branch API 2.1303.v9f3b_95dc329d, and Credentials Binding 728.v902a_273b_8947.
Three Ways to Put a Job on a Schedule#
Freestyle jobs. In the job's configuration, under Triggers, tick Build periodically and write the spec in Schedule.
Declarative Pipeline. The triggers directive is allowed once, inside the pipeline block, and takes cron, pollSCM, and upstream (Pipeline syntax):
pipeline {
agent any
triggers {
cron('H 2 * * *')
}
stages {
stage('Report') {
steps {
sh './scripts/nightly-report.sh'
}
}
}
}Scripted Pipeline. Set the trigger as a job property (PipelineTriggersJobProperty.java):
properties([
pipelineTriggers([cron('H 2 * * *')])
])
node {
checkout scm
sh './scripts/nightly-report.sh'
}Each properties call replaces the properties the previous call set (JobPropertyStep.java), so keep every property in one call; one that leaves out pipelineTriggers deletes the schedule.
Build periodically or Poll SCM? cron builds at every matching minute; pollSCM checks the repository on its schedule and builds only when it finds changes (Pipeline syntax). Backups and reports want cron. A multibranch project's Periodically if not otherwise run setting schedules branch indexing, a "recomputation" of the project, not builds (PeriodicFolderTrigger.java).
Jenkins Cron Syntax and the H Token#
Each line has five fields, minute, hour, day of month, month, and day of week (0-7, "where 0 and 7 are Sunday"), with *, ranges, M-N/X or */X steps, and lists; lines starting with # are comments (cron syntax). Two differences from Linux cron:
- Numbers only. The lexer's only value token is a run of digits (CrontabLexer.g4), so
MON-FRIis a syntax error; write1-5. - Both day fields must match.
CronTab.checkrequires all five fields to match (CronTab.java), soH H 1 * 1runs only on a 1st that is a Monday. Linux cron runs it on every 1st and every Monday.
What H Does#
H "can be thought of as a random value over a range, but it actually is a hash of the job name, not a random function, so that the value remains stable for any given project" (cron syntax). In the source, Jenkins seeds a random number generator with an MD5 digest of the job's full name (Trigger.java, Hash.java) and draws each H field from it (BaseParser.java). So:
- It's stable per job.
H 2 * * *runs at one fixed minute past 2 every night, say 02:37. - The full name counts. It includes folders and, in a multibranch project, the branch, so
reports/mainandreports/releaseget different minutes, and a renamed or moved job gets a new one. - Ranges and steps work.
H(0-29)stays in the first half hour;H/15runs every 15 minutes from a hashed offset, "perhaps at :07, :22, :37, :52". A hashed day of month stays in 1-28. - Aliases are hashed too.
@hourlyisH * * * *,@dailyisH H * * *,@midnightisH H(0-2) * * *,@weeklyisH H * * H,@monthlyisH H H * *, and@yearlyisH H H H *(CrontabParser.g4).
A spec that starts with a fixed minute gets a warning to "spread load evenly" by using H 2 * * * rather than 0 2 * * * (CronTab.java), but it saves and runs. Cron jitter compares H with other ways of spreading jobs out.
Read the Time H Picked#
Under the Schedule field, Jenkins prints "Would last have run at …; would next run at …", computed with the job's own hash (TimerTrigger.java) and formatted in the controller's default time zone, which it names. A Pipeline job shows its Jenkinsfile's trigger there, under Triggers in its configuration, once a build has run it.
Time Zones: The Controller's or TZ=#
Without a zone in the spec, "periodic tasks are normally executed at the scheduled time in the time zone of the Jenkins master JVM", and the Schedule field's help shows which zone that is (help-spec.jelly). /systemInfo lists it as user.timezone (change time zone).
To pin a schedule to a zone, start the spec with TZ= and a Java time zone ID, supported since Jenkins 1.615 (the @since tag on the zone-aware parser in CronTab.java):
triggers {
cron('TZ=UTC\nH 2 * * *')
}- First line only. Jenkins treats
TZ=as a zone only on line 1 (CronTabList.java). A triple-quoted Groovy string whose text starts on the next line makes line 1 empty, andTZ=on line 2 is rejected as an invalid schedule. Use\n, or putTZ=right after the opening'''. - One zone per spec, for every line below it. An unknown ID fails with "Invalid or unsupported timezone".
- Daylight saving. Jenkins checks each real minute against the wall clock of the spec's zone (Trigger.java, CronTab.java). With daylight saving, a time inside the hour skipped in spring doesn't run that day, and a time inside the repeated autumn hour runs twice. Cron timezone compares other schedulers.
TZ=UTC avoids both and makes the spec mean what a CronRadar schedule means, since CronRadar reads UTC.
Jenkinsfile Triggers Exist Only After a Build#
Committing a cron line schedules nothing. Declarative Pipeline applies triggers to the job at the start of each build, before it allocates an agent (ModelInterpreter.groovy, Utils.groovy), and the scripted properties step "updates the properties of the job which runs this step" (properties). Until a build runs the Jenkinsfile, the job has no schedule. In practice:
- New branches. Branch indexing builds a newly discovered branch by default, tags excepted (MultiBranchProject.java), and that build registers the cron. With the Suppress automatic SCM triggering branch property (help), a matching branch has no schedule until someone builds it by hand.
- Changed or removed schedules take effect at the next build. If only the timer builds the job, the old schedule fires once more and that build installs the new one.
- Every branch inherits the schedule, each at its own hashed minute. To schedule only
main:
triggers {
cron(env.BRANCH_NAME == 'main' ? 'TZ=UTC\nH 2 * * *' : '')
}Declarative evaluates an expression argument when the build runs (ModelParser.groovy), and an empty spec is "No schedules so will never run". BRANCH_NAME comes from the multibranch job (Branches and Pull Requests); variables from the Jenkinsfile's environment block don't work here, because triggers are applied before it.
Why a Scheduled Build Didn't Run#
First look for a build that says "Started by timer". If there is one, the trigger fired and the problem is in the build. If there isn't, check the build queue, then these:
- The controller was down or restarting at that minute. Jenkins checks triggers once a minute, starting from the minute the controller started (Trigger.java). A late check works through each minute it missed, so a busy controller builds late rather than skipping, but minutes before startup are never checked. A trigger that takes over 30 seconds shows up in the Cron Triggers Monitor, because "other timers could be delayed" (Trigger.java). Missed runs after downtime compares other schedulers.
- A safe restart was in progress. While Jenkins quiets down, queued builds wait with "Jenkins is about to shut down" (Queue.java). Jenkins saves the queue at shutdown and restores it at startup (Jenkins.java, Queue.java), so the build runs late, after the restart.
- The job is disabled. The trigger fires, but a job that isn't buildable gets no queue item and no message (ParameterizedJobMixIn.java).
- The trigger was never registered, or was removed. The job's configuration has no Build periodically entry under Triggers; see the previous section.
- The zone is wrong. The build ran, but hours early or late.
When the build was late rather than missing:
- It's starved of executors. A Pipeline build needs no executor to start, since Pipeline jobs are flyweight tasks (WorkflowJob.java), so it starts on time and waits at
agent. After 15 seconds its log prints "Still waiting to schedule task" and the reason, such as "Waiting for next available executor" or "There are no nodes with the label ‘linux’" (ExecutorStepExecution.java, Messages.properties). A freestyle job waits in the queue instead, where the entry's tooltip gives the reason (executor starvation). - The previous build was still running. Pipeline builds run concurrently unless
options { disableConcurrentBuilds() }is set (WorkflowJob.java); with it, the next build waits as "Build #41 is already in progress". Triggers that fire while a build of the job is waiting fold into it rather than queueing another (Queue.java), and the build later shows "Started by timer (3 times)": on anH/15job, a 50-minute stall turns three scheduled runs into one build.
The quiet period isn't a cause: the timer schedules builds with a quiet period of zero (TimerTrigger.java), so neither the job's quiet period nor options { quietPeriod(...) } delays a timer build.
Timeouts and Post Conditions#
options { timeout(time: 2, unit: 'HOURS') } makes Jenkins "abort the Pipeline" after two hours (options). Set it a little above the longest normal run. Three details matter:
- A timeout is
aborted, notfailure. The timeout interrupts the build with an aborted result (TimeoutStepExecution.java), sopost { failure { } }doesn't run.unsuccessfulruns for anything but success: failed, unstable, or aborted (post). - The clock starts after the agent. Declarative runs
optionswrappers inside the top-level agent (ModelInterpreter.groovy), so waiting for an executor doesn't count. A stage-level timeout applies "before entering the agent" (stage options) and does count it. postruns inside the timeout. After "Cancelling nested steps due to timeout", the rest of the body,postincluded, gets one minute before Jenkins terminates it (TimeoutStepExecution.java), so keep post steps short.
A timeout on the call that hangs fails sooner and with a clearer error; cron job timeouts covers those.
Get Alerted When a Scheduled Build Doesn't Start#
Jenkins notifications, whether post blocks, mail, or chat steps, run inside a build, so a build that never exists sends nothing, and the Cron Triggers Monitor reports slow triggers, not missing builds. An outside monitor that expects a check-in on the job's schedule covers that gap.
There is no CronRadar SDK or plugin for Jenkins and no auto-discovery of Jenkins jobs, so each job reports with its own HTTP check-ins from sh steps:
- Store the CronRadar application's API key as a Secret text credential with the ID
cronradar-api-key. - Create the monitor in the CronRadar dashboard before the first run, with the key
nightly-backup, the trigger's time,37 2 * * *, and a 900-second grace period. It stayspending, and if the first expected run plus the grace period passes with no check-in, CronRadar sends a never-executed alert. A monitor created by its first check-in can't flag a trigger that never registered. - Report from the Jenkinsfile:
pipeline {
agent any
triggers {
cron('TZ=UTC\n37 2 * * *') // a fixed minute that CronRadar's schedule can match
}
options {
timeout(time: 2, unit: 'HOURS')
}
environment {
PING_URL = 'https://cron.life/api/v1/ping/nightly-backup'
MONITOR_SCHEDULE = '37 2 * * *' // the trigger's time, in UTC
GRACE_SECONDS = '900'
}
stages {
stage('Report start') {
steps {
withCredentials([string(credentialsId: 'cronradar-api-key', variable: 'CRONRADAR_API_KEY')]) {
sh '''
set +x
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=$MONITOR_SCHEDULE" \
--data-urlencode "gracePeriod=$GRACE_SECONDS" \
--data-urlencode "run_id=build-$BUILD_NUMBER" \
"$PING_URL/start" || true
'''
}
}
}
stage('Backup') {
steps {
sh './scripts/backup.sh'
}
}
}
post {
success {
withCredentials([string(credentialsId: 'cronradar-api-key', variable: 'CRONRADAR_API_KEY')]) {
sh '''
set +x
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=$MONITOR_SCHEDULE" \
--data-urlencode "gracePeriod=$GRACE_SECONDS" \
--data-urlencode "run_id=build-$BUILD_NUMBER" \
"$PING_URL/complete" || true
'''
}
}
unsuccessful {
withCredentials([string(credentialsId: 'cronradar-api-key', variable: 'CRONRADAR_API_KEY')]) {
sh '''
set +x
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=$MONITOR_SCHEDULE" \
--data-urlencode "gracePeriod=$GRACE_SECONDS" \
--data-urlencode "run_id=build-$BUILD_NUMBER" \
--data-urlencode "message=$JOB_NAME #$BUILD_NUMBER did not succeed $BUILD_URL" \
"$PING_URL/fail" || true
'''
}
}
}
}startwaits for the executor. It runs once the build has an agent and its checkout, so a build starved past the grace period turns the monitorcriticalas missed.- Every ending reports.
successsendscomplete;unsuccessfulsendsfail, which alerts immediately, for a failure, an unstable result, an abort, or the timeout. If unstable results shouldn't alert, usefailureandabortedblocks instead. - The key stays out of the log. Single quotes leave
$CRONRADAR_API_KEYfor the shell to expand, where a double-quoted Groovy string would interpolate the secret (withCredentials).shruns a script without a#!line assh -xe, echoing each command (BourneShellScript.java);set +xstops that, and Jenkins masks a secret that reaches the log anyway as****. - Monitoring can't fail the build. Each request gives up after 5 seconds, and
|| trueignores its errors. The agent needs curl. - A lost agent looks stuck. If the agent disappears mid-build, the post steps can't run, the last event is
start, and the monitor goescriticalas stuck once the next scheduled run plus the grace period passes. scheduleandgracePeriodapply only when a check-in creates the monitor. They're sent so a deleted monitor comes back on the next run; change an existing monitor in its settings.
Translate the Jenkins Spec for CronRadar#
CronRadar reads standard five-field cron in UTC, without H, @ aliases, or 7 for Sunday (schedule syntax). Every check-in moves the expected time to the schedule's next occurrence, so the schedule must be the minute Jenkins actually runs. Sent 0 2 * * * for a job that runs at 02:37, the monitor goes warning at 02:00 every night, and critical if the grace is under 37 minutes. Sent 59 2 * * *, a 02:37 check-in moves the expectation to 02:59 that night, a run that never comes. That's why the Jenkinsfile above writes 37 rather than H.
To keep H, run the job once so its trigger is registered, then read the time from "Would next run at". It moves when the job is renamed, moved, or built from another branch, and the monitor keeps the old time until you edit it. Distinct minutes chosen by hand spread load as well as hashed ones. Translate the spec like this:
| Jenkins spec | Jenkins shows, for example | Send to CronRadar |
|---|---|---|
TZ=UTC then H 2 * * * |
02:37 UTC | 37 2 * * * |
TZ=UTC then H/15 * * * * |
:07, :22, :37, :52 | 7-59/15 * * * * |
TZ=UTC then @midnight |
01:44 UTC | 44 1 * * * |
TZ=UTC then H H * * 7 |
Sundays at 05:12 UTC | 12 5 * * 0 |
TZ=Europe/London then H 2 * * * |
02:37 London time | 37 1 * * *, with an hour more grace |
London's 02:37 is 01:37 UTC in summer and 02:37 in winter. CronRadar's rule for zones with daylight saving is the earlier UTC time and a grace period of at least an hour plus the job's usual duration.
The grace period covers the time from the scheduled minute to the start check-in: the executor wait plus the checkout. 900 seconds tolerates a 15-minute wait for an agent; choosing a grace period explains sizing it from measurements.
Freestyle Jobs#
Put everything in one Execute shell step: report start, run the job, save its exit code, report complete or fail, and exit with that code. Start the step with #!/bin/sh; without a shebang, Jenkins runs it as sh -xe (Shell.java), and -e exits at the job's failure before the script can report fail. Bind the key under Environment with Use secret text(s) or file(s).
Frequently Asked Questions#
What does H mean in a Jenkins cron?#
H stands for hash. Jenkins replaces it with a value derived from the job's full name, so H 2 * * * runs at one fixed minute past 2 that differs from job to job. Renaming or moving the job changes the value, and H/15 runs every 15 minutes from a hashed offset.
How do I schedule a Jenkins pipeline?#
In a declarative Jenkinsfile, add triggers { cron('H 2 * * *') } inside the pipeline block; in a scripted one, call properties([pipelineTriggers([cron('H 2 * * *')])]). Then run the pipeline once: Jenkins registers the schedule only when a build executes the Jenkinsfile, after which Build periodically appears under the job's Triggers.
Why didn't my Jenkins scheduled build run?#
Common causes: the controller was down or restarting at that minute (Jenkins doesn't replay missed minutes), the job is disabled, no build has registered the Jenkinsfile's trigger yet, or the build started but waited for an executor. If a "Started by timer" build exists, the trigger worked and the problem is in the queue or the build.
Can Jenkins cron use a time zone?#
Yes. Make the first line of the spec TZ= and a Java time zone ID, as in cron('TZ=Europe/London\nH 8 * * *'); without it, Jenkins uses the controller JVM's zone. With daylight saving, a time in the skipped spring hour doesn't run and one in the repeated autumn hour runs twice, so TZ=UTC is the safe choice.
How do I get alerted when a scheduled Jenkins build doesn't start?#
Jenkins's notifications run inside builds, so a build that never existed sends nothing. Have the pipeline check in to an external monitor at its start and end, and alert when a check-in doesn't arrive within a grace period of the scheduled time. With CronRadar, create the monitor before the first run, so even a schedule that never registered produces a never-executed alert.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.