Ops Journal · Practical notes on running software in production

systemd timer calendar expressions, and the two that catch everyone

Published 2026-09-18 · 7 min read

systemd timers are a better cron in most ways: real logging, dependency handling, and a scheduler that will not silently skip a job because the machine was asleep. The cost is that OnCalendar syntax is its own language, and unlike cron it will happily accept an expression that means something other than what you intended.

There is one tool that removes the guesswork. Learn it and the rest is detail:

systemd-analyze calendar "Mon *-*-* 03:00:00"

Always verify with systemd-analyze calendar

This subcommand parses an expression and tells you when it will next fire. It is the difference between hoping and knowing:

systemd-analyze calendar "*-*-* 03:00:00"
Normalized form: *-*-* 03:00:00
    Next elapse: Thu 2026-10-08 03:00:00 UTC
       From now: 18h left

Two things are worth noticing in that output. First, the normalized form is shown back to you — if you typed something ambiguous, this is where you see what systemd decided it meant. Second, it tells you the timezone it used, which is UTC here because the machine is set to UTC. A timer that fires at 03:00 on a server set to UTC fires at 10:00 in Jakarta, and that mismatch has caused more "why did this run at the wrong time" tickets than any other cause.

Set Timezone= on the timer explicitly when local time matters:

[Timer]
OnCalendar=*-*-* 03:00:00
Timezone=Asia/Jakarta

Trap one: the day-of-week wildcard that is not a wildcard

This is the one that gets people. Compare these two expressions:

systemd-analyze calendar "*-*-* 03:00:00"          # every day at 03:00
systemd-analyze calendar "Mon *-*-* 03:00:00"      # every Monday at 03:00

The second returns:

Normalized form: Mon *-*-* 03:00:00
    Next elapse: Mon 2026-10-12 03:00:00 UTC
       From now: 4 days left

That looks right, and for a weekly job it is. The trap is the assumption that Mon combined with a wildcard day-of-month means "every Monday". It does not — it means Monday, on any day of the month, which for a weekly job is fine. Where it bites is when someone writes Mon *-*-01 expecting the first Monday of the month, and instead gets Monday only when the 1st happens to be a Monday.

To express "first Monday of the month" you have to combine a weekday with a day-of-month and understand that systemd ANDs them:

# Fires only when the 1st of the month is a Monday.
OnCalendar=Mon *-*-01 03:00:00

That is rarely what anyone wants. The common requirement — "run weekly, on Monday" — is simply Mon *-*-* 03:00:00, and the day-of-month wildcard is doing exactly the right thing.

Trap two: the missing time component

An expression with no time is valid, and its meaning surprises people:

systemd-analyze calendar "daily"
systemd-analyze calendar "*-*-*"

Both parse. daily normalizes to midnight, and *-*-* with no time also means midnight. Writing OnCalendar=*-*-* intending "once a day at some point" gives you 00:00:00 exactly, and if a dozen timers all do this you get a thundering herd at midnight. Always specify the time:

[Timer]
OnCalendar=*-*-* 03:00:00

A complete timer, with the parts that matter

# /etc/systemd/system/backup.timer
[Unit]
Description=Nightly backup

[Timer]
OnCalendar=*-*-* 03:00:00
Timezone=Asia/Jakarta
# If the machine was off at 03:00, run as soon as it comes up.
Persistent=true
# Spread load: run somewhere in the first 10 minutes after 03:00.
RandomizedDelaySec=600
Unit=backup.service

[Install]
WantedBy=timers.target

Persistent=true is the line people forget, and it is the reason to move off cron in the first place. Without it, a timer whose time passed while the machine was down is simply skipped. With it, systemd records the last run time and fires the job on the next boot.

RandomizedDelaySec spreads the start time, which prevents every timer on a fleet from firing at the same second. On a single machine it is optional; across a fleet it is the difference between a smooth night and a load spike.

Reloading and confirming

After editing a timer:

sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers backup.timer

daemon-reload is not optional — systemd caches unit files and will keep using the old definition until it re-reads them.

To test the job itself without waiting for the schedule, run the service directly. This is also the quickest way to see the difference between a broken timer and a broken job:

sudo systemctl start backup.service
systemctl status backup.service --no-pager
journalctl -u backup.service -n 50 --no-pager

Checking that it actually ran, later

list-timers shows the next and last run times, which is enough to spot a timer that stopped firing:

systemctl list-timers --all

The LAST column going stale is the earliest signal that something is wrong, usually a syntax error introduced in a later edit that made systemd drop the unit. journalctl -u <name>.timer will show the parse failure.

The habit worth keeping

Run systemd-analyze calendar on every expression before it goes into a file. It costs two seconds and it answers the only question that matters — when will this actually run — with a real date instead of your assumption.