Schedules and Cron

How to write the schedule field, and what the scheduler does with it.

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 wantschedule
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:

  • */4 in 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:

FormExampleMeaning
**Every value
Number5Exactly that value
List0,15,30,45Any listed value
Range9-17Inclusive range
Step*/15, 0-30/10Every nth value across the field or range
Day/month namesMON, mon-fri, JANCase-insensitive names
LastL in day-of-monthLast day of the month
Nth weekday5#2 in day-of-weekThe 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 list prints NEXT RUN in 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:

  1. 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.
  2. 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 latest so the gap
    is visible in the log.
  3. Nothing double-fires. The occurrence is recorded before the job starts,
    and a cross-process lock stops a run-due invocation from overlapping a
    daemon pass. Running regscale jobs run-due from 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-due from 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.


Did this page help you?