Why bother switching
A crontab line is short until it fails. Then you find out the output went to a local mail spool nobody reads, the job ran with a different PATH, and the server was rebooting at 03:00 so last night's run never happened. Timers fix each of those:
| Concern | cron | systemd timer |
|---|---|---|
| Output | Mailed, or lost | In the journal, per unit |
| Missed run during downtime | Skipped | Persistent=true runs it at boot |
| Overlapping runs | Possible; needs flock | Never, by default |
| Resource limits | Manual nice/ionice | CPUQuota=, MemoryMax=, IOWeight= |
| Dependencies | None | After=, Requires= |
| Failure hook | Script it yourself | OnFailure= |
Every image we offer, from Ubuntu 24.04 to Rocky Linux 9, runs systemd, so the same units work everywhere.
A complete example
Say you want to prune old uploads nightly. Two files. The service says what to run:
# /etc/systemd/system/prune-uploads.service
[Unit]
Description=Delete uploads older than 30 days
After=network-online.target
[Service]
Type=oneshot
User=app
ExecStart=/usr/bin/find /srv/app/uploads/tmp -type f -mtime +30 -delete
Nice=10
IOSchedulingClass=idle
The timer says when:
# /etc/systemd/system/prune-uploads.timer
[Unit]
Description=Nightly upload prune
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=10m
Persistent=true
[Install]
WantedBy=timers.target
Enable the timer, not the service:
sudo systemctl daemon-reload
sudo systemctl enable --now prune-uploads.timer
systemctl list-timers prune-uploads.timer
NEXT LEFT LAST PASSED UNIT ACTIVATES
Tue 2026-10-28 02:34:12 GMT 11h left - - prune-uploads.timer prune-uploads.service
Run the job now to test it, without waiting for 02:30: sudo systemctl start prune-uploads.service.
OnCalendar syntax
The format is DayOfWeek Year-Month-Day Hour:Minute:Second, with * for any and ranges or lists allowed.
| Crontab | OnCalendar |
|---|---|
*/15 * * * * | *:0/15 |
0 * * * * | hourly |
30 2 * * * | *-*-* 02:30:00 |
0 9 * * 1-5 | Mon..Fri 09:00 |
0 4 1 * * | *-*-01 04:00 |
Never guess. systemd-analyze calendar parses an expression and prints the next runs:
$ systemd-analyze calendar --iterations=3 'Mon..Fri 09:00'
Original form: Mon..Fri 09:00
Normalized form: Mon..Fri *-*-* 09:00:00
Next elapse: Tue 2026-10-28 09:00:00 GMT
Iter. #2: Wed 2026-10-29 09:00:00 GMT
Iter. #3: Thu 2026-10-30 09:00:00 GMT
Time zone: timers use the server's zone unless you append one, e.g. *-*-* 02:30 Europe/London. Pinning it avoids surprises if someone later sets the box to UTC. Around the clock change in late March and late October, a job scheduled between 01:00 and 02:00 local time can be skipped or run twice. Schedule outside that hour, or use UTC.
For intervals measured from the last run rather than the wall clock, use monotonic timers: OnBootSec=5min and OnUnitActiveSec=1h.
Reading the output
Anything the job prints goes to the journal under the service's name:
journalctl -u prune-uploads.service -n 50
journalctl -u prune-uploads.service --since yesterday
systemctl status prune-uploads.service
On Ubuntu and Debian the journal may not persist across reboots by default on minimal images. Check ls /var/log/journal; if it is missing, sudo mkdir -p /var/log/journal && sudo systemctl restart systemd-journald. Cap its size with SystemMaxUse=500M in /etc/systemd/journald.conf so logs never fill a 20 GB Spark disk.
Get told when a job fails
OnFailure= starts another unit when the service exits non-zero. A template unit can notify for any job:
# /etc/systemd/system/[email protected]
[Unit]
Description=Alert on failure of %i
[Service]
Type=oneshot
ExecStart=/usr/local/bin/alert "%i failed on %H"
Then add OnFailure=notify-fail@%n.service to the [Unit] section of any job. /usr/local/bin/alert can be a curl to a chat webhook or a push service; the monitoring guide has an example.
Limits for heavy jobs
A backup or report job should not starve the web server. Add to [Service]:
CPUQuota=50%
MemoryMax=1G
IOWeight=20
On a two-vCPU Boost, CPUQuota=50% means half of one core. Use 100% for one full core.
Migrating a crontab
- Dump the current jobs:
crontab -l -u appandls /etc/cron.d. - Write one service and timer per line. Put the full command in
ExecStartwith absolute paths; there is no shell, so pipes need/bin/sh -c '...'. - Set
User=and anyEnvironment=orEnvironmentFile=the job relied on. - Start the service manually, read the journal, then enable the timer.
- Comment out the crontab line. Delete it after a week of clean runs.