Schedules and Cron
How to write the schedule field, and what the scheduler does with it.
- The five fields
- Cookbook
- Syntax the parser accepts
- Timezones
- Daylight saving time
- What happens when a run is missed
- How often can a job run?
- Checking your work
The five fields
┌───────────── minute 0-59
│ ┌─────────── hour 0-23
│ │ ┌───────── day of month 1-31
│ │ │ ┌─────── month 1-12
│ │ │ │ ┌───── day of week 0-7 (0 and 7 both mean Sunday)
│ │ │ │ │
0 2 * * 1 → 02:00 every Monday
Exactly five fields, always. Shorthand macros like @daily and @hourly are
rejected — write 0 0 * * * instead. Quote the expression in YAML
(schedule: "0 2 * * 1"); an unquoted value starting with * is a YAML syntax
error.
There is no seconds field. The smallest interval is one minute.
Cookbook
| You want | schedule |
|---|---|
| Every day at midnight | "0 0 * * *" |
| Every day at 02:00 | "0 2 * * *" |
| Twice a day, 06:00 and 18:00 | "0 6,18 * * *" |
| Every Monday at 02:00 | "0 2 * * 1" |
| Every weekday at 07:30 | "30 7 * * 1-5" |
| Every Sunday at 23:00 | "0 23 * * 0" |
| 1st of the month at 04:00 | "0 4 1 * *" |
| Last day of the month at 23:00 | "0 23 L * *" |
| Quarterly — Jan/Apr/Jul/Oct 1st at 05:00 | "0 5 1 1,4,7,10 *" |
| Every hour, on the hour | "0 * * * *" |
| Every 15 minutes | "*/15 * * * *" |
| Every 4 hours | "0 */4 * * *" |
| Every 30 minutes during business hours, weekdays | "*/30 9-17 * * 1-5" |
| Second Friday of the month at midnight | "0 0 * * 5#2" |
| Every 10 minutes (smoke test; then delete it) | "*/10 * * * *" |
Two traps in that table worth calling out:
*/4in the hour field means 0, 4, 8, 12, 16, 20 — the step counts from
the start of the field, not from now.0 */7 * * *fires at 00:00, 07:00,
14:00 and 21:00 and then waits only three hours before the next day's 00:00.
Steps that do not divide evenly into the field give you an uneven schedule.- Setting both day-of-month and day-of-week is an OR, not an AND.
"0 2 13 * 5"means "the 13th or any Friday", which is almost never what
someone means. Leave one of the two as*.
Syntax the parser accepts
Within each field:
| Form | Example | Meaning |
|---|---|---|
* | * | Every value |
| Number | 5 | Exactly that value |
| List | 0,15,30,45 | Any listed value |
| Range | 9-17 | Inclusive range |
| Step | */15, 0-30/10 | Every nth value across the field or range |
| Day/month names | MON, mon-fri, JAN | Case-insensitive names |
| Last | L in day-of-month | Last day of the month |
| Nth weekday | 5#2 in day-of-week | The 2nd Friday |
Schedules are parsed with croniter, so
this is standard cron plus the L and # extensions. Anything Jenkins-specific
— H for hashed/spread times — is not supported.
An expression is rejected at load time if it is malformed or if it has no real
occurrences. "0 0 30 2 *" (February 30th) parses as valid cron and would
never fire, so regscale jobs validate refuses it rather than letting you
discover the silence later. A legal but very rare schedule is accepted:
"0 0 29 2 *" next fires in 2028, and that is your business.
Timezones
Each job carries its own timezone, an IANA name, and its cron expression is
evaluated as wall-clock time in that zone:
- name: monthly-poam-export
command: ["fedramp", "export_poam", "--regscale_id", "42"]
schedule: "0 6 1 * *"
timezone: America/New_York # 06:00 Eastern, whatever the host thinks
The default is UTC. The host's own timezone is never consulted, which is
deliberate — the same jobs.yaml behaves identically on a laptop in Denver and
a container in us-east-1.
Common zone names: UTC, America/New_York, America/Chicago,
America/Denver, America/Los_Angeles, America/Anchorage,
Pacific/Honolulu, Europe/London, Europe/Dublin, America/Toronto.
Use a region/city name, not an abbreviation. EST and PST are fixed
offsets that do not follow daylight saving; America/New_York does. Windows
names like Eastern Standard Time are not IANA names and are rejected.
Two places show you times in different zones, which surprises people:
regscale jobs listprintsNEXT RUNin UTC, always, so jobs in
different zones sort against each other.- The daemon's startup banner prints each next fire time in that job's own
zone, because that is the zone its cron expression is written in:
next fire ~2026-09-21 02:00:00 EDT.
Both are correct and they refer to the same instant.
Daylight saving time
Any zone that observes DST has, twice a year, a day with a missing hour and a
day with a repeated hour. For a job scheduled inside one of those windows, the
number of runs on that day is not guaranteed: a schedule landing in the skipped
hour fires either once or not at all for that hour, and one landing in the
repeated hour fires once or twice. The scheduler will not fire it zero times on
the day overall, and will not fire it more than twice.
That is an honest description of the underlying cron library's behavior, not a
gap we are hiding. The practical guidance:
- If exactness matters, use
timezone: UTC. UTC has no transitions, so
"0 7 * * *"in UTC is exactly 24 hours apart, every time. The cost is that
the job drifts by an hour against local business hours twice a year. - Avoid scheduling between 01:00 and 03:00 local time in a DST zone. In US
zones the transitions happen at 02:00 local; 04:00 is a perfectly good
overnight slot and is never ambiguous. - If the job is idempotent, stop worrying. Running a sync twice is harmless
for most integrations, and one extra run on one night a year is not worth
designing around.
What happens when a run is missed
The scheduler tracks, per job, the occurrence it last ran for — not the time
it last ran. On every pass it computes the most recent occurrence that is due
and compares it to what is recorded.
Three consequences:
- A job that was due while the scheduler was down runs once, as soon as the
scheduler is back. Bring a container up at 09:00 with a job scheduled for
"0 2 * * *"and that day's 02:00 run happens at 09:00. - Missed occurrences collapse. If the scheduler was down for a week and
the job runs hourly, you get one run on restart, not 168. The daemon logs
Job <name> missed N occurrence(s); running once for the latestso the gap
is visible in the log. - Nothing double-fires. The occurrence is recorded before the job starts,
and a cross-process lock stops arun-dueinvocation from overlapping a
daemon pass. Runningregscale jobs run-duefrom cron every minute against
a daily job costs you 1439 no-op invocations and one real run.
If you need "catch up on every single missed occurrence", this feature does not
do that, and no cron does either.
How often can a job run?
Two limits, from different places:
- The schedule's own granularity — one minute at best.
- How often the schedule is polled.
regscale jobs daemon --interval 60
checks once a minute;run-duefrom host cron checks when cron runs it.
A job cannot fire more often than the polling interval, and because missed
occurrences collapse, a shorter schedule than the poll simply runs once per
poll. "*/5 * * * *" under a run-due triggered every 15 minutes runs once
every 15 minutes — not three times. Either poll at least as often as your
shortest schedule, or write the schedule you actually get.
Also remember jobs in a pass run sequentially, one subprocess at a time. Ten
jobs that each take 20 minutes need at least 200 minutes of wall clock, so
stagger them rather than starting them all at "0 2 * * *". Stagger by minutes
(0 2, 20 2, 40 2) or, for long integrations, by hours.
Checking your work
Never trust a cron expression you have not seen resolved:
regscale jobs validate # is it even a legal, firing schedule?
regscale jobs list # what is the next run, in UTC?
jobs list is the real test — it prints the next occurrence the scheduler
itself computed. If that date is not what you meant, the expression is wrong no
matter how good it looks. - in the NEXT RUN column means the job is
disabled; invalid means the schedule has no computable next occurrence.
The TUI's Schedule Job modal shows the same preview as you type,
which is the easiest way to get an unfamiliar expression right the first time.
Updated about 8 hours ago
