Cron has run scheduled tasks since 1975. It works, but systemd timers offer significant advantages: integrated logging, dependency management, randomized delays, and calendar-based scheduling that actually makes sense.
Why Switch from Cron?# Logging: Timer output goes to journald. No more digging through mail or custom log files.
Dependencies: Wait for network, mounts, or other services before running.
Accuracy: Monotonic timers don’t drift. Calendar timers handle DST correctly.
Visibility: systemctl list-timers shows all scheduled jobs and when they’ll run next.
Resource Control: Full cgroup integration for CPU, memory, and I/O limits.
Basic Structure# Every timer needs two files:
Service unit (.service) — what to runTimer unit (.timer) — when to run it1
2
3
4
5
6
7
# /etc/systemd/system/backup.service
[Unit]
Description = Daily backup job
[Service]
Type = oneshot
ExecStart = /usr/local/bin/backup.sh
1
2
3
4
5
6
7
8
9
10
# /etc/systemd/system/backup.timer
[Unit]
Description = Run backup daily
[Timer]
OnCalendar = daily
Persistent = true
[Install]
WantedBy = timers.target
Enable and start:
1
2
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
Timer Types# Calendar-Based (OnCalendar)# Human-readable scheduling:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[Timer]
# Daily at midnight
OnCalendar = daily
# Every Monday at 9 AM
OnCalendar = Mon *-*-* 09:00:00
# Every 15 minutes
OnCalendar = *:0/15
# First of month at 6 AM
OnCalendar = *-*-01 06:00:00
# Weekdays at 8:30 AM
OnCalendar = Mon..Fri *-*-* 08:30:00
# Every 4 hours
OnCalendar = *-*-* 00/4:00:00
Test calendar expressions:
1
2
systemd-analyze calendar "Mon..Fri *-*-* 09:00:00"
systemd-analyze calendar --iterations= 5 "daily"
Boot-Relative (OnBootSec)# Run after system boot:
1
2
3
4
5
6
[Timer]
# 5 minutes after boot
OnBootSec = 5min
# 1 hour after boot
OnBootSec = 1h
Activation-Relative (OnUnitActiveSec)# Run periodically after service completes:
1
2
3
4
5
6
7
[Timer]
# Every 30 minutes after last run
OnUnitActiveSec = 30min
# Combined: first run 5min after boot, then every hour
OnBootSec = 5min
OnUnitActiveSec = 1h
Practical Examples# Log Rotation# 1
2
3
4
5
6
7
8
9
10
# /etc/systemd/system/logrotate.service
[Unit]
Description = Rotate logs
[Service]
Type = oneshot
ExecStart = /usr/sbin/logrotate /etc/logrotate.conf
Nice = 19
IOSchedulingClass = best-effort
IOSchedulingPriority = 7
1
2
3
4
5
6
7
8
9
# /etc/systemd/system/logrotate.timer
[Timer]
OnCalendar = daily
AccuracySec = 1h
RandomizedDelaySec = 30min
Persistent = true
[Install]
WantedBy = timers.target
Database Backup# 1
2
3
4
5
6
7
8
9
10
11
12
# /etc/systemd/system/db-backup.service
[Unit]
Description = PostgreSQL backup
Requires = postgresql.service
After = postgresql.service
[Service]
Type = oneshot
User = postgres
ExecStart = /usr/local/bin/pg-backup.sh
StandardOutput = journal
StandardError = journal
1
2
3
4
5
6
7
8
# /etc/systemd/system/db-backup.timer
[Timer]
OnCalendar = *-*-* 02:00:00
RandomizedDelaySec = 15min
Persistent = true
[Install]
WantedBy = timers.target
Certificate Renewal# 1
2
3
4
5
6
7
8
# /etc/systemd/system/certbot-renew.service
[Unit]
Description = Certbot renewal
[Service]
Type = oneshot
ExecStart = /usr/bin/certbot renew --quiet
ExecStartPost = /bin/systemctl reload nginx
1
2
3
4
5
6
7
8
# /etc/systemd/system/certbot-renew.timer
[Timer]
OnCalendar = *-*-* 00,12:00:00
RandomizedDelaySec = 1h
Persistent = true
[Install]
WantedBy = timers.target
Health Check# 1
2
3
4
5
6
7
8
# /etc/systemd/system/healthcheck.service
[Unit]
Description = Application health check
[Service]
Type = oneshot
ExecStart = /usr/local/bin/healthcheck.sh
TimeoutStartSec = 30
1
2
3
4
5
6
7
8
# /etc/systemd/system/healthcheck.timer
[Timer]
OnBootSec = 1min
OnUnitActiveSec = 5min
AccuracySec = 10s
[Install]
WantedBy = timers.target
Key Timer Options# Persistence# 1
2
3
[Timer]
# Run missed executions after downtime
Persistent = true
If the system was off when a timer should have fired, it runs immediately on boot.
Accuracy and Randomization# 1
2
3
4
5
6
[Timer]
# Allow up to 1 hour deviation (saves power, batches wake-ups)
AccuracySec = 1h
# Random delay up to 30 minutes (prevents thundering herd)
RandomizedDelaySec = 30min
Wake from Suspend# 1
2
3
[Timer]
# Wake system from sleep to run
WakeSystem = true
Service Configuration# Run as Specific User# 1
2
3
[Service]
User = appuser
Group = appgroup
Environment Variables# 1
2
3
[Service]
Environment = "NODE_ENV=production"
EnvironmentFile = /etc/myapp/env
Working Directory# 1
2
[Service]
WorkingDirectory = /opt/myapp
Resource Limits# 1
2
3
4
[Service]
MemoryMax = 512M
CPUQuota = 50%
IOWeight = 100
Failure Handling# 1
2
3
4
5
6
7
[Service]
# Restart on failure (for long-running services)
Restart = on-failure
RestartSec = 30
# For oneshot, just log failures
Type = oneshot
Monitoring Timers# List All Timers# 1
systemctl list-timers --all
Output:
N T T E u u X e e T 2 2 0 0 2 2 6 6 - - 0 0 2 2 - - 2 2 5 5 0 0 7 6 : : 0 1 0 5 : : 0 0 0 0 E E S S T T L 5 1 E 8 3 F m m T i i n n l l e e f f t t L T T A u u S e e T 2 2 0 0 2 2 6 6 - - 0 0 2 2 - - 2 2 5 5 0 0 6 6 : : 0 0 0 0 : : 0 0 0 0 E E S S T T P 1 1 A m m S i i S n n E D a a g g o o U b h N a e I c a T k l u t p h . c t h i e m c e k r . t i m e r
Check Timer Status# 1
systemctl status backup.timer
View Service Logs# 1
2
3
4
5
6
7
8
# Last run
journalctl -u backup.service -n 50
# Follow logs
journalctl -u backup.service -f
# Since last boot
journalctl -u backup.service -b
Manual Trigger# 1
2
# Run the service now (doesn't affect timer schedule)
sudo systemctl start backup.service
User Timers# Non-root users can create timers in ~/.config/systemd/user/:
1
mkdir -p ~/.config/systemd/user
1
2
3
4
5
6
7
# ~/.config/systemd/user/sync.service
[Unit]
Description = Sync files
[Service]
Type = oneshot
ExecStart = %h/bin/sync-files.sh
1
2
3
4
5
6
7
# ~/.config/systemd/user/sync.timer
[Timer]
OnCalendar = hourly
Persistent = true
[Install]
WantedBy = timers.target
1
2
3
systemctl --user daemon-reload
systemctl --user enable --now sync.timer
systemctl --user list-timers
Note: User timers only run while the user is logged in, unless you enable lingering:
1
sudo loginctl enable-linger username
Migration from Cron# Cron to Systemd Cheat Sheet# Cron Systemd OnCalendar 0 * * * **-*-* *:00:00 (hourly)*/15 * * * **:0/150 0 * * *daily or *-*-* 00:00:000 0 * * 0weekly or Sun *-*-* 00:00:000 0 1 * *monthly or *-*-01 00:00:000 9 * * 1-5Mon..Fri *-*-* 09:00:00
Convert a Cron Job# Before (cron):
3 0 2 / u s r / l o c a l / b i n / b a c k u p . s h v a r / l o g / b a c k u p . l o g 2 > & 1 After (systemd):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# backup.service
[Unit]
Description = Backup
[Service]
Type = oneshot
ExecStart = /usr/local/bin/backup.sh
StandardOutput = journal
StandardError = journal
# backup.timer
[Timer]
OnCalendar = *-*-* 02:30:00
Persistent = true
[Install]
WantedBy = timers.target
Debugging# Test Calendar Expressions# 1
2
3
4
5
# Parse and show normalized form
systemd-analyze calendar "Mon *-*-* 09:00"
# Show next N occurrences
systemd-analyze calendar --iterations= 10 "Mon..Fri *-*-* 09:00"
Verify Units# 1
systemd-analyze verify /etc/systemd/system/backup.*
Check for Errors# 1
2
journalctl -u backup.timer -p err
journalctl -u backup.service -p err
Systemd timers aren’t just a cron replacement—they’re an upgrade. Better logging, proper dependencies, and calendar expressions that don’t require a decoder ring.
Start with one cron job, convert it, and see the difference. Once you’re checking journalctl instead of grepping log files, you won’t go back.