Cron has been scheduling jobs since the 1970s and it still works fine. But if your server is already running systemd – which it almost certainly is on any modern Ubuntu, Debian, or Fedora box – you have a second option that integrates more tightly with the rest of the system. I spent a while being skeptical of systemd timers because the unit file syntax felt verbose for something cron handles in one line. Then I ran into a few real situations where that verbosity actually paid off.
Cron: the quick and familiar path
Crontab syntax is compact. Run something every day at 3am:
0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
That’s it. The job runs as the user who owns the crontab, output goes to a log file (or gets emailed if you configure MAILTO), and you manage everything with crontab -e. System-level jobs go in /etc/cron.d/ or the hourly/daily/weekly/monthly directories under /etc/cron.*.
Where cron gets annoying:
- No native dependency tracking – if a job needs the network to be up, you handle that inside the script
- Output handling is clunky – either redirect to a file or set up a mail relay, neither of which is great
- No built-in catch-up for missed runs (unless you use anacron)
- Race conditions when multiple crons overlap – you have to implement locking yourself with
flock
See the Wikipedia article on Cron for a good overview of the syntax variants across platforms – there are subtle differences between Vixie cron, Busybox cron, and others.
Systemd timers: more setup, more features
A systemd timer is actually two unit files: a .timer that defines the schedule, and a .service that defines what runs. Verbose, yes. But the service file gives you dependency ordering, resource limits, sandboxing, and journal logging for free.
Example: a daily database backup. Create /etc/systemd/system/db-backup.service:
[Unit]
Description=Daily database backup
After=network.target mysql.service
Requires=mysql.service
[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/db-backup.sh
StandardOutput=journal
StandardError=journal
Then /etc/systemd/system/db-backup.timer:
[Unit]
Description=Run db-backup daily at 03:00
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
Enable and start it:
systemctl daemon-reload
systemctl enable --now db-backup.timer
Check the next scheduled run and recent history:
systemctl list-timers db-backup.timer
journalctl -u db-backup.service --since "24 hours ago"
A few things worth pointing out here. Persistent=true means if the machine was off at 3am, the job runs shortly after the next boot – that’s the anacron behavior built in. RandomizedDelaySec=300 spreads the load if you have many timers firing at the same clock time. And because output goes to the journal, you get structured logs you can query with journalctl instead of grepping flat files.
The After=mysql.service line is the part cron simply can’t do: the job won’t run if MySQL isn’t up. For a backup script that’s fairly important.
OnCalendar vs OnBootSec vs OnActiveSec
Systemd timers have multiple trigger types. The most common:
OnCalendar– wall-clock schedule, same concept as cron. Accepts human-readable strings:daily,weekly,Mon *-*-* 08:00:00, etc.OnBootSec– relative to system boot. Useful for “run 5 minutes after startup”:OnBootSec=5minOnActiveSec– relative to when the timer itself was last activated. Good for polling loops with variable runtime.OnUnitActiveSec– relative to when the associated service last ran successfully.
Full calendar event syntax is documented at freedesktop.org/software/systemd/man/systemd.time.html. The systemd-analyze calendar command is useful for testing expressions before deploying them:
systemd-analyze calendar "Mon..Fri *-*-* 09:00:00"
Adding resource limits and sandboxing
One underused feature of systemd service units is the ability to constrain what the job can do. If you’re running a backup script that should never use more than a certain amount of memory or CPU, you can express that directly in the unit file:
[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/db-backup.sh
MemoryMax=512M
CPUQuota=25%
PrivateTmp=true
NoNewPrivileges=true
PrivateTmp=true gives the service its own /tmp mount, isolated from other processes. NoNewPrivileges=true prevents the script from gaining elevated permissions through setuid binaries. Neither of these makes sense in a crontab context – they’re systemd-native concepts.
You can go further with ProtectSystem=strict, ProtectHome=true, and ReadWritePaths= to whitelist only the directories the script needs to touch. This is overkill for a simple log rotation script but makes a lot of sense for anything that touches external network resources or processes untrusted input.
When to use which
Use cron when: the task is simple, you’re already managing a pile of crontabs and don’t want to introduce another system, you’re on a minimal container image without full systemd, or the person maintaining the server after you is more comfortable with crontab than unit files.
Use systemd timers when: you need the job to run as a specific user with resource limits (memory cap, CPU quota), you want journal logging without redirecting to a file, the job has a systemd service dependency (After=, Requires=), or you want missed-run catch-up without installing anacron separately.
For most things on a typical Linux server, either works. I tend to reach for cron for quick one-liners and systemd timers for anything that runs as part of a broader service stack. If you’re already managing the backup or maintenance script as a systemd service for other reasons, the timer is the natural companion – you already have the unit file, you just need to add a schedule.