How to Use systemd Timers Instead of Cron (Logs, Retries and Catch-Up Runs)

Cron (tutorial 20) works and will keep working. But systemd timers fix cron's three irritations: output is logged where everything else is logged, a job missed because the server was off at 2 am runs when it comes back, and systemctl list-timers shows exactly when everything will next run. Two small files per job.
Quick reference
| Task | What |
|---|---|
| The job | /etc/systemd/system/backup.service |
| The schedule | /etc/systemd/system/backup.timer |
| Enable | sudo systemctl enable --now backup.timer |
| See all timers | systemctl list-timers |
| Run the job now | sudo systemctl start backup.service |
| Logs | journalctl -u backup.service |
| Test a schedule expression | systemd-analyze calendar "Mon..Fri 07:30" |
1. The service: what to run
sudo nano /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
oneshot means "run once and exit" — a script, not a daemon. No [Install] section is needed; the timer starts it.
2. The timer: when to run it
sudo nano /etc/systemd/system/backup.timer
[Unit]
Description=Run backup nightly at 02:00
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
RandomizedDelaySec=5m
[Install]
WantedBy=timers.target
- OnCalendar is the schedule (more forms below).
- Persistent=true is the killer feature: if the server was off at 02:00, the job runs as soon as it's next up.
- RandomizedDelaySec spreads jobs out so ten timers don't all fire at the same second.
3. Enable and check
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers
The table shows NEXT, LEFT, LAST and PASSED for every timer on the system — including Ubuntu's own (apt, logrotate, fstrim).
Run it immediately to test, and read the output:
sudo systemctl start backup.service
journalctl -u backup.service -n 50
Everything the script printed is in the journal, with timestamps. No >> log 2>&1 needed.
4. Schedule expressions
| OnCalendar | Meaning |
|---|---|
| --* 02:00:00 | daily at 2 am |
| daily | midnight |
| hourly / weekly / monthly | on the hour / Monday 00:00 / 1st 00:00 |
| Mon..Fri 07:30 | weekdays at 7:30 |
| Sun 03:00 | Sundays at 3 am |
| *:0/15 | every 15 minutes |
| --01 00:00:00 | first of the month |
Check any expression before trusting it:
systemd-analyze calendar "Mon..Fri 07:30"
It prints the next run time.
Alternatively, OnBootSec=5min plus OnUnitActiveSec=1h means "5 minutes after boot, then every hour" — handy for things like the DuckDNS update that just need to happen regularly.
5. Convert a cron line
0 4 * * 0 docker system prune -af
becomes a service with ExecStart=/usr/bin/docker system prune -af and a timer with OnCalendar=Sun 04:00. Full paths still apply (which docker).
6. Run as a normal user
mkdir -p ~/.config/systemd/user
Put the two files there, use systemctl --user enable --now name.timer, and run loginctl enable-linger $USER once so the timers run when you're not logged in. Useful for jobs that touch your own files without root.
Troubleshooting
Timer listed but job never runs. The service file has an error: systemctl status backup.service and journalctl -u backup.service.
Changed the files, nothing changed. sudo systemctl daemon-reload, then restart the timer.
Runs at the wrong time. The server's time zone (tutorial 9). OnCalendar can also take a zone: --* 02:00:00 Europe/London.
Persistent job ran twice after a long downtime. It doesn't — one catch-up run only. If it seems to, two timers point at the same service.
Wrapping up
A .service for the job, a .timer for the schedule, Persistent=true so missed runs catch up, list-timers to see the whole calendar and journalctl -u for the output. Keep cron for one-liners; move anything that matters to a timer.
