Systemd Timers vs Cron: Resource Control and Journal Logging

Systemd timers should replace cron for nearly every scheduled task on modern Linux. They log to the journal, manage dependencies, and add random delays to avoid resource stampedes. They also catch up on runs missed during a reboot. The one reason to keep cron is legacy support on minimal systems without systemd. If your distro shipped in the last decade, you have everything to switch.
This guide covers the real problems with cron. It explains how systemd timers work and migrates several cron jobs step by step. It also covers the sandboxing and resource controls that make timers a better fit for production.
Why Cron Falls Short
Cron has been the default task scheduler on Unix systems since the 1970s. It works, and that long track record is why so many admins never question it. However, its limits get painful as systems grow more complex.
Cron captures stdout and stderr from your job. It then emails the output to a local mailbox nobody reads, or it discards the output entirely. Debugging a failed cron job means adding manual logging to every script. You redirect output to a file, add timestamps yourself, and hope you also captured stderr. Compare that to systemd’s journal. The journal captures everything with timestamps, priority levels, and structured metadata.
Cron also cannot express dependencies. You cannot tell it to run a job after the network is up, or only when a mount point is ready. Say you schedule a backup at 2 AM and the NFS share is not mounted yet when cron fires. Your job fails silently. You end up wrapping scripts in retry loops or sleep delays, which is brittle and ugly.
Resource control is another gap. A cron job can use unlimited CPU, RAM, and I/O. There is no built-in way to apply cgroups limits to a cron task. A runaway backup script can starve your database or web server, and cron will not stop it.
If the machine is powered off at the scheduled time, the job just does not run. Anacron is a partial fix, but it only handles daily, weekly, and monthly jobs. If you need hourly jobs that survive reboots, anacron cannot help.
Cron’s five-field time format (*/5 * * * *) is compact but hard to read and easy to get wrong. Setting “every weekday at 3 AM in the US/Eastern timezone” takes mental gymnastics and timezone math. Systemd’s OnCalendar syntax reads like plain English by comparison.
Finally, cron offers no sandboxing at all. Cron jobs run with the full environment and permissions of the owning user. There is no filesystem isolation, no private /tmp, and no way to restrict network access. A compromised cron script gives an attacker lasting access with no audit trail beyond what the script itself logs.
Systemd Timer Fundamentals
Every systemd timer uses two unit files. A .timer file defines when to run. A matching .service file defines what to run. If you create mybackup.timer, systemd looks for mybackup.service on its own. Both files live in /etc/systemd/system/ for system-wide timers, or ~/.config/systemd/user/ for per-user timers.
The Timer File
The timer file is where you define your schedule. The most common directive is OnCalendar=. It uses readable calendar expressions instead of cron’s cryptic five-field syntax.
Here is a comparison of common schedules:
| Cron Syntax | OnCalendar Equivalent | Meaning |
|---|---|---|
0 2 * * * | *-*-* 02:00:00 | Every day at 2 AM |
*/15 * * * * | *:0/15 | Every 15 minutes |
0 3 * * 1-5 | Mon..Fri *-*-* 03:00:00 | Weekdays at 3 AM |
0 0 1 * * | *-*-01 00:00:00 | First of every month |
0 6 * * 0 | Sun *-*-* 06:00:00 | Every Sunday at 6 AM |
You can validate any calendar expression before deploying it:
systemd-analyze calendar "Mon..Fri *-*-* 03:00:00"This prints the normalized form and the next several trigger times. It catches mistakes before they cause missed runs.
Beyond calendar scheduling, systemd offers monotonic timers. OnBootSec=5min fires five minutes after boot. OnUnitActiveSec=30min fires 30 minutes after the service last finished. These work well for upkeep that should run at set intervals no matter the wall-clock time.
Three other directives count for most setups:
Persistent=truetells systemd to record the last run time on disk. If a run was missed because the system was off, the timer fires right away on the next boot.RandomizedDelaySec=300adds a random offset of 0 to 5 minutes. When you have 50 servers all running certbot renewal, this jitter stops them from hammering the Let’s Encrypt API at once.AccuracySec=controls the coalescing window. The default is 1 minute, so systemd may batch timer wakeups to save power. Set it to1sif you need precise scheduling.
The Service File
The service file defines what actually runs. For scheduled tasks, you almost always want Type=oneshot. This tells systemd the process runs to completion and exits rather than staying resident.
[Unit]
Description=My scheduled task
[Service]
Type=oneshot
ExecStart=/usr/local/bin/myscript.shThis is where systemd timers pull well ahead of cron. You can add resource limits, sandboxing, dependencies, and failure alerts right in this file.
Real Examples: Migrating Common Cron Jobs
The best way to learn systemd timers is to convert real cron tasks. Here are several common migrations with complete unit files you can copy and paste.
Daily Backup Script
Cron version:
0 2 * * * /usr/local/bin/backup.shSystemd timer (/etc/systemd/system/backup.timer):
[Unit]
Description=Daily backup timer
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
RandomizedDelaySec=600
AccuracySec=1s
[Install]
WantedBy=timers.targetSystemd service (/etc/systemd/system/backup.service):
[Unit]
Description=Daily backup
After=network-online.target
Requires=mnt-backup.mount
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
Nice=19
IOSchedulingClass=idle
MemoryMax=512M
StandardOutput=journal
StandardError=journalThe Nice=19 and IOSchedulingClass=idle settings make the backup run at the lowest priority, so it does not slow running workloads. MemoryMax=512M stops a runaway tar or rsync from eating all your RAM. The After= and Requires= directives make sure the network and backup mount are ready before the script starts. Cron cannot do this at all.
Enable and start:
systemctl daemon-reload
systemctl enable --now backup.timerFor a backup strategy that pairs well with scheduled timers, automated ransomware-proof restore points on ZFS give you immutable copies of your data. Cron’s timing limits make those hard to manage reliably.
Certbot Certificate Renewal
Cron version:
0 0,12 * * * /usr/bin/certbot renew --quietSystemd timer (/etc/systemd/system/certbot-renew.timer):
[Unit]
Description=Certbot renewal timer
[Timer]
OnCalendar=*-*-* 00,12:00:00
RandomizedDelaySec=3600
Persistent=true
[Install]
WantedBy=timers.targetSystemd service (/etc/systemd/system/certbot-renew.service):
[Unit]
Description=Certbot renewal
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
ReadWritePaths=/etc/letsencrypt /var/lib/letsencrypt /var/log/letsencryptThe RandomizedDelaySec=3600 spreads Let’s Encrypt
load across a full hour. That is what their docs recommend. The sandboxing directives limit Certbot
to only the directories it needs. If you use Traefik as a reverse proxy
, it can renew certificates on its own. Still, this timer approach works just as well for non-containerized services.
Rsync Mirror Sync
This one shows a pattern cron handles poorly. With cron, if you schedule rsync every 15 minutes and a sync takes 20 minutes, you get overlapping runs. Systemd avoids this on its own.
Systemd timer (/etc/systemd/system/mirror-sync.timer):
[Unit]
Description=Mirror sync timer
[Timer]
OnUnitActiveSec=15min
AccuracySec=1s
[Install]
WantedBy=timers.targetSystemd service (/etc/systemd/system/mirror-sync.service):
[Unit]
Description=Mirror sync via rsync
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/rsync -avz --delete source/ /srv/mirror/
CPUQuota=50%
IOWeight=50With OnUnitActiveSec=15min instead of a fixed calendar schedule, the next sync starts 15 minutes after the last one finishes. No overlap, no race conditions, no flock wrapper needed.
User-Level Timer
You do not need root to use systemd timers. Place your unit files in ~/.config/systemd/user/ and manage them with the --user flag:
mkdir -p ~/.config/systemd/user
# Create your .timer and .service files there
systemctl --user daemon-reload
systemctl --user enable --now mytask.timer
systemctl --user list-timersThis works well for dev tasks, personal backups, or anything that should run as your regular user without touching system config.
Quick One-Off Tasks with systemd-run
Sometimes you need a quick scheduled task without creating unit files. The systemd-run command handles this with transient timers.
systemd-run --on-calendar="*-*-* 18:00:00" /usr/local/bin/report.shThis creates a temporary timer that fires at 6 PM daily. It goes away when you stop it or reboot. For one-off delayed runs:
systemd-run --on-active="30m" /usr/local/bin/cleanup.shThis runs cleanup.sh once, 30 minutes from now. It is the systemd version of the at command, but with journal logging and resource control too.
Advanced Features: Sandboxing and Resource Control
Systemd timers inherit all of systemd’s service controls. That includes cgroups resource limits and filesystem sandboxing, which cron cannot reach at all.
Resource Limits
Add these directives to the [Service] section to set hard limits through cgroups
v2:
MemoryMax=512M
CPUQuota=50%
IOWeight=50
TasksMax=32A backup script that leaks memory gets killed at 512M instead of taking down your server. A CPU-hungry compression job gets throttled to 50% of a core instead of starving your web application.
Filesystem Sandboxing
ProtectHome=read-only
ProtectSystem=strict
ReadWritePaths=/var/backups
PrivateTmp=yes
NoNewPrivileges=yesProtectSystem=strict mounts the entire filesystem read-only except for paths you explicitly allow with ReadWritePaths=. PrivateTmp=yes gives the service its own isolated /tmp directory. NoNewPrivileges=yes prevents the process from gaining additional privileges through setuid binaries or capability escalation.
This level of isolation would require a custom SELinux policy or AppArmor profile with cron. With systemd, it is a few lines in a unit file.
Failure Notifications
OnFailure=notify-admin@%n.serviceWhen the job fails, systemd triggers a separate notification service. You can wire this to send an email, post to a Slack webhook, or push to Gotify . This replaces cron’s unreliable “email on any output” behavior with targeted failure alerts.
Wake from Suspend
For critical timers on laptops or systems that suspend:
WakeSystem=trueThis tells systemd to set a hardware wake alarm so the system wakes from suspend to run the timer. Cron has no equivalent.
Managing and Monitoring Your Timers
Once you have migrated your cron jobs, here are the commands you will use daily.
To list all timers on the system with their next trigger time, last trigger time, and remaining time:
systemctl list-timers --all
To check the status of a specific timer and its associated service:
systemctl status mybackup.timer # Is the timer active? When did it last fire?
systemctl status mybackup.service # What was the exit code? Any errors?
To read logs from a service, use journalctl instead of grepping through custom log files:
journalctl -u mybackup.service --since today
journalctl -u mybackup.service -n 50
journalctl -u mybackup.service -p err # Only errors
Every timer’s output lives in the journal, queryable by time, priority, and unit name.
Before enabling a new timer, validate the unit files for syntax errors:
systemd-analyze verify mybackup.timer mybackup.serviceThe same systemd-analyze tool is also invaluable for tracking down sluggish startup caused by misbehaving unit dependencies
.
If you need to change a timer’s settings without modifying the original file (useful for package-managed unit files), use drop-in overrides:
systemctl edit mybackup.timerThis creates an override at /etc/systemd/system/mybackup.timer.d/override.conf that survives package updates.
To test a service manually without waiting for the timer to fire:
systemctl start mybackup.serviceThis runs the service once immediately. Use it to verify your job works before relying on the schedule.
Removing Cron Safely
Most modern distributions still ship cron by default, but once you have migrated all your jobs, you can remove it. First, verify nothing is left:
crontab -l # Check your user crontab
sudo crontab -l # Check root crontab
ls /etc/cron.d/ /etc/cron.daily/ /etc/cron.hourly/ /etc/cron.weekly/ /etc/cron.monthly/Some system packages drop files into /etc/cron.d/ or the periodic directories. Check whether systemd timer equivalents already exist for those tasks (many distributions have migrated their own maintenance jobs to timers). Once you are confident nothing depends on cron:
# Debian/Ubuntu
sudo apt remove cron
# Fedora/RHEL
sudo dnf remove cronieSystemd timers have been stable and production-ready since systemd 209, released in 2014. Every major distribution supports them fully. Start with your least critical cron job, verify it works as a timer, and work your way up from there. For managing unit files across multiple machines, Ansible playbooks provide idempotent deployment of timer configurations alongside your other system settings.
Botmonster Tech