Cron gives you a schedule and, at best, a mail to a local user when something breaks. It does not give you dependency ordering, structured logs, per-job resource limits, or a way to catch up on a run that was missed while the machine was powered off. systemd timers cover all of that because every scheduled job becomes a regular unit that systemctl and journalctl can inspect.
What is a systemd timer?
A systemd timer is a unit file with the suffix .timer that activates another unit, normally a matching .service, either at a wall-clock calendar time or after a monotonic interval such as 15 minutes since the last run.
The timer itself contains no commands. It only says when; the service says what. If a timer is named backup.timer and does not set Unit=, systemd activates backup.service by default. Timers are enabled through timers.target, which is pulled in during a normal boot, and each activation is recorded in the journal with the exit status of the service.
Why replace cron jobs with systemd timers?
systemd timers replace cron jobs because they add journal-based logging, ordering against other units, cgroup resource limits, catch-up runs after downtime, and a calendar parser you can test with systemd-analyze before the job ever runs.
| Capability | cron | systemd timer |
|---|---|---|
| Log destination | mail to local user, or manual redirect | journalctl -u <unit> |
| Missed run after downtime | dropped | replayed with Persistent=true |
| Resource limits | none | CPUQuota=, MemoryMax=, IOSchedulingClass= |
| Ordering | none | After=, Wants=, Requires= |
| Overlapping runs | possible | prevented, a service starts only once |
| Schedule test | none | systemd-analyze calendar |
| Load spreading | manual sleep |
RandomizedDelaySec= |
| Failure handling | exit code lost | OnFailure= unit, systemctl --failed |
cron is still fine for a trivial one-line job on a single host. Once a job needs a specific user, a network dependency, a bounded memory budget, or an audit trail, the unit file is the better place to describe it.
Matching infrastructure at centron
No hardware needed to follow along: ccloud³ VMs with full root access, billed by the hour and ready in seconds. Rent a cloud server →
Prerequisites
- A Linux host running systemd 232 or newer, which covers Debian 9+, Ubuntu 16.04+, RHEL/Rocky 8+ and current openSUSE. A scalable Linux cloud VM is enough to follow along.
- Root access or a user with
sudorights. - An executable script to schedule. This tutorial uses
/usr/local/sbin/backup.sh.
Check the version first, because Persistent= and RandomizedDelaySec= behave as documented from systemd 232 onwards:
$ systemctl --version | head -n 1
systemd 255 (255.4-1ubuntu8)Write the service unit
Create /etc/systemd/system/backup.service. This unit describes the work and nothing about the schedule:
[Unit]
Description=Nightly rsync backup of /srv to /mnt/backup
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/backup.sh
User=backup
Nice=10
IOSchedulingClass=idle
EnvironmentFile=-/etc/default/backup
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/mnt/backupType=oneshottells systemd the process runs to completion and then exits. The unit stays in stateinactive (dead)between runs, which is correct for a batch job.User=backupdrops privileges. Without it the job runs as root.Nice=10andIOSchedulingClass=idlekeep a long copy job from starving interactive workloads.EnvironmentFile=-/etc/default/backuploads variables if the file exists. The leading-makes a missing file non-fatal.ProtectSystem=strictmounts the whole filesystem read-only for this unit, soReadWritePaths=must list every directory the script writes to.
No [Install] section is needed. The timer is what gets enabled.
Test the service on its own before adding a schedule:
$ sudo systemctl daemon-reload
$ sudo systemctl start backup.service
$ systemctl status backup.serviceWrite the timer unit
Create /etc/systemd/system/backup.timer with the same base name as the service:
[Unit]
Description=Run backup.service every night at 02:30
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=300
AccuracySec=1s
Unit=backup.service
[Install]
WantedBy=timers.targetOnCalendar=is the wall-clock schedule. It may appear multiple times in one timer to define several trigger points.Persistent=truestores the last activation time under/var/lib/systemd/timers/. If the host was off at 02:30, the service runs once shortly after the next boot instead of being skipped.RandomizedDelaySec=300delays each start by a random 0 to 300 seconds. On a fleet of hosts with identical schedules this prevents a synchronised load spike.AccuracySec=1sdisables the default 1-minute coalescing window. Leave the default on battery-powered or heavily virtualised systems, where letting systemd batch wakeups is cheaper.Unit=backup.serviceis optional here because the names match. State it explicitly when timer and service names differ.
OnCalendar syntax
The format is DayOfWeek Year-Month-Day Hour:Minute:Second. Omitted leading fields default to every value, omitted trailing fields default to zero.
| Expression | Meaning |
|---|---|
*-*-* 02:30:00 |
daily at 02:30 |
Mon..Fri 07:00 |
weekdays at 07:00 |
*-*-01 04:00:00 |
first day of each month at 04:00 |
*-*-* *:0/15 |
every 15 minutes |
Sat *-*-1..7 03:00:00 |
first Saturday of the month at 03:00 |
daily, weekly, monthly |
shorthand aliases, all at 00:00 |
Never guess an expression. systemd-analyze parses it with the same code the timer uses:
$ systemd-analyze calendar "Mon..Fri 07:00"
Original form: Mon..Fri 07:00
Normalized form: Mon..Fri *-*-* 07:00:00
Next elapse: Tue 2026-08-18 07:00:00 CEST
(in UTC): Tue 2026-08-18 05:00:00 UTC
From now: 14h leftAn invalid expression produces Failed to parse calendar specification instead of a next elapse, which is the check you want in a deployment pipeline.
Enable and start the timer
Reload the unit files, then enable the timer. Enabling the service instead of the timer is the single most common mistake in this workflow:
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now backup.timer
Created symlink /etc/systemd/system/timers.target.wants/backup.timer -> /etc/systemd/system/backup.timer--now arms the timer immediately so you do not have to reboot. The symlink under timers.target.wants/ is what makes it survive a restart.
Verify the timer
Check the schedule and the last result. systemctl list-timers shows the next and previous activation for every armed timer:
$ systemctl list-timers backup.timer
NEXT LEFT LAST PASSED UNIT ACTIVATES
Tue 2026-08-18 02:34:12 CEST 11h left Mon 2026-08-17 02:31:47 CEST 12h ago backup.timer backup.serviceA NEXT column showing n/a means the timer is loaded but not armed, usually because it was started without being enabled or because every OnCalendar value lies in the past.
The job output goes to the journal, tagged with the service unit:
$ journalctl -u backup.service --since today --no-pager
$ systemctl show backup.timer --property=NextElapseUSecRealtime,PersistentThe following diagram shows how one activation resolves, including the catch-up path that cron does not have:
graph TD
A["timers.target pulls in backup.timer"] --> B{"OnCalendar time reached?"}
B -- no --> C["Timer waits, next elapse visible in list-timers"]
C --> B
B -- yes --> D["backup.service starts, Type=oneshot"]
D --> E{"Exit code 0?"}
E -- yes --> F["Journal records success, timer rearms"]
E -- no --> G["Unit enters failed state, OnFailure handler runs"]
H["Host was powered off at trigger time"] --> I{"Persistent=true?"}
I -- yes --> D
I -- no --> C
Migrate an existing crontab entry
List what is currently scheduled before you change anything:
$ crontab -l
30 2 * * * /usr/local/sbin/backup.sh >> /var/log/backup.log 2>&1
$ sudo ls /etc/cron.d /etc/cron.dailyThe five cron fields map directly onto OnCalendar:
| cron | systemd |
|---|---|
30 2 * * * |
OnCalendar=*-*-* 02:30:00 |
*/10 * * * * |
OnCalendar=*:0/10 |
0 3 * * 0 |
OnCalendar=Sun *-*-* 03:00:00 |
@reboot |
OnBootSec=5min |
@daily |
OnCalendar=daily |
The output redirection disappears. Anything the script writes to stdout and stderr lands in the journal, so drop the >> /var/log/backup.log 2>&1 part and query journalctl -u backup.service instead. If you need the run to be a fixed interval after the previous one rather than at a fixed clock time, use a monotonic timer:
[Timer]
OnBootSec=10min
OnUnitActiveSec=15minThat starts the service 10 minutes after boot and then 15 minutes after each completed activation, which avoids the drift-and-overlap behaviour of */15 on a slow job.
After the timer is verified, remove the crontab line with crontab -e. Leaving both in place means the job runs twice per night.
For jobs that belong to a single user, the same units work under ~/.config/systemd/user/ with systemctl --user enable --now backup.timer. User timers only run while a session for that user exists, so enable lingering if the job must run without a login:
$ sudo loginctl enable-linger aliceTroubleshooting
The timer is enabled but the service never runs. Confirm you enabled the .timer, not the .service, and that systemd-analyze verify accepts both files. A typo in OnCalendar= leaves the timer loaded with NEXT set to n/a:
$ sudo systemd-analyze verify /etc/systemd/system/backup.timer
$ systemctl is-enabled backup.timerThe script works in your shell but fails in the unit. Units get a minimal environment: PATH is /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin, there is no shell profile, and no cd into a home directory. Use absolute paths inside the script, set WorkingDirectory= if needed, and reproduce the exact environment with a transient unit:
$ sudo systemd-run --unit=backup-test --property=User=backup /usr/local/sbin/backup.sh
$ journalctl -u backup-test --no-pagerMissed runs pile up after a long outage. With Persistent=true a timer runs once on the next boot, not once per skipped interval. If even that single catch-up run is unwanted, for example for a job that must only run inside a maintenance window, remove Persistent=true and rely on the calendar alone.
Wrap-up
You now have a scheduled job described by two small unit files, logged in the journal, restricted to one user, and testable without waiting for the clock. Extend it with OnFailure= for alerting, CPUQuota= for noisy batch work, and systemd-analyze verify as a pre-deployment check in your configuration management. Before you decommission the crontab, run systemctl list-timers --all once and confirm every migrated job appears exactly once.
Testen Sie Ihr Setup auf ccloud³
Registrieren Sie sich in der ccloud³ und erhalten Sie 200 € Startguthaben für Ihr Projekt – z. B. für eine PostgreSQL-VM mit automatischen Backups.