systemd timer calendar expressions, and the two that catch everyone
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.