Cron works. It’s also from 1975. systemd timers offer logging integration, dependency handling, and more flexible scheduling. Here’s how to use them.
Why Timers Over Cron?#
- Logging: Output goes to journald automatically
- Dependencies: Wait for network, mounts, or other services
- Flexibility: Calendar events, monotonic timers, randomized delays
- Visibility:
systemctl list-timers shows everything - Consistency: Same management as other systemd units
Basic Structure#
A timer needs two files:
- A
.timer unit (the schedule) - A
.service unit (the job)
Place them in /etc/systemd/system/ (system-wide) or ~/.config/systemd/user/ (user).
Simple Example#
1
2
3
4
5
6
7
8
9
10
| # /etc/systemd/system/backup.timer
[Unit]
Description=Daily backup timer
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
|
1
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
|
Enable and start:
1
2
| sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
|
Calendar Syntax#
The OnCalendar directive uses a flexible format:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| # Predefined
OnCalendar=hourly
OnCalendar=daily
OnCalendar=weekly
OnCalendar=monthly
# Specific times
OnCalendar=*-*-* 06:00:00 # Daily at 6am
OnCalendar=Mon *-*-* 09:00:00 # Mondays at 9am
OnCalendar=*-*-01 00:00:00 # First of every month
OnCalendar=*-01,07-01 00:00:00 # Jan 1 and Jul 1
# Multiple times
OnCalendar=*-*-* 09:00,18:00:00 # 9am and 6pm daily
# Every N minutes/hours
OnCalendar=*:0/15 # Every 15 minutes
OnCalendar=*-*-* *:00:00 # Every hour on the hour
|
Test your calendar expression:
1
2
| systemd-analyze calendar "Mon *-*-* 09:00:00"
systemd-analyze calendar "*:0/15" --iterations=5
|
Monotonic Timers#
Run relative to system boot or last trigger:
1
2
3
4
5
6
7
8
9
10
11
12
13
| [Timer]
# 5 minutes after boot
OnBootSec=5min
# 1 hour after last run
OnUnitActiveSec=1h
# 10 minutes after timer activation
OnActiveSec=10min
# Combine: 5 min after boot, then every hour
OnBootSec=5min
OnUnitActiveSec=1h
|
Randomized Delays#
Spread load across multiple machines:
1
2
3
| [Timer]
OnCalendar=hourly
RandomizedDelaySec=15min
|
The job runs sometime within 15 minutes of the hour.
Persistence#
Run missed jobs after downtime:
1
2
3
| [Timer]
OnCalendar=daily
Persistent=true
|
If the machine was off at the scheduled time, the job runs on next boot.
Accuracy#
Control timer precision:
1
2
3
| [Timer]
OnCalendar=*:0/5
AccuracySec=1s # Within 1 second of scheduled time
|
Default is 1 minute. Lower values use more resources.
Service Configuration#
Environment and Working Directory#
1
2
3
4
5
6
| [Service]
Type=oneshot
WorkingDirectory=/opt/myapp
Environment=NODE_ENV=production
EnvironmentFile=/etc/myapp/env
ExecStart=/opt/myapp/run-job.sh
|
User and Permissions#
1
2
3
4
5
| [Service]
Type=oneshot
User=appuser
Group=appgroup
ExecStart=/opt/myapp/job.sh
|
Timeouts and Resources#
1
2
3
4
5
6
| [Service]
Type=oneshot
ExecStart=/usr/local/bin/long-job.sh
TimeoutStartSec=3600 # 1 hour max
MemoryMax=1G # Memory limit
CPUQuota=50% # CPU limit
|
Notifications and Alerts#
1
2
3
4
5
| [Service]
Type=oneshot
ExecStart=/usr/local/bin/job.sh
ExecStartPost=/usr/local/bin/notify-success.sh
OnFailure=notify-failure@%n.service
|
Dependency Management#
Wait for Network#
1
2
3
4
5
6
7
8
| [Unit]
Description=Job requiring network
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/network-job.sh
|
Wait for Mount#
1
2
3
4
5
6
7
8
| [Unit]
Description=Backup to NFS
After=mnt-backup.mount
Requires=mnt-backup.mount
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
|
Wait for Another Service#
1
2
3
4
5
6
7
8
| [Unit]
Description=Database maintenance
After=postgresql.service
Requires=postgresql.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/db-maintenance.sh
|
Managing Timers#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| # List all timers
systemctl list-timers
# List including inactive
systemctl list-timers --all
# Timer status
systemctl status backup.timer
# Enable timer (starts on boot)
systemctl enable backup.timer
# Start timer now
systemctl start backup.timer
# Stop timer
systemctl stop backup.timer
# Run the service manually (test)
systemctl start backup.service
# View logs
journalctl -u backup.service
journalctl -u backup.service --since "1 hour ago"
|
User Timers#
For user-specific jobs without root:
1
| mkdir -p ~/.config/systemd/user
|
1
2
3
4
5
6
7
8
9
10
| # ~/.config/systemd/user/sync.timer
[Unit]
Description=Sync my files
[Timer]
OnCalendar=*:0/30
Persistent=true
[Install]
WantedBy=timers.target
|
1
2
3
4
5
6
7
| # ~/.config/systemd/user/sync.service
[Unit]
Description=File sync job
[Service]
Type=oneshot
ExecStart=%h/bin/sync-files.sh
|
Manage with --user flag:
1
2
3
4
| systemctl --user daemon-reload
systemctl --user enable --now sync.timer
systemctl --user list-timers
journalctl --user -u sync.service
|
Enable lingering for timers to run without login:
1
| loginctl enable-linger username
|
Practical Examples#
Log Rotation#
1
2
3
4
5
6
7
8
9
10
11
| # /etc/systemd/system/logrotate.timer
[Unit]
Description=Daily log rotation
[Timer]
OnCalendar=daily
AccuracySec=1h
Persistent=true
[Install]
WantedBy=timers.target
|
Certificate Renewal#
1
2
3
4
5
6
7
8
9
10
11
| # /etc/systemd/system/certbot-renew.timer
[Unit]
Description=Certbot renewal
[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target
|
1
2
3
4
5
6
7
8
| # /etc/systemd/system/certbot-renew.service
[Unit]
Description=Certbot renewal service
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet
ExecStartPost=/bin/systemctl reload nginx
|
Database Backup#
1
2
3
4
5
6
7
8
9
10
| # /etc/systemd/system/db-backup.timer
[Unit]
Description=Database backup
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target
|
1
2
3
4
5
6
7
8
9
10
| # /etc/systemd/system/db-backup.service
[Unit]
Description=Database backup job
After=postgresql.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/local/bin/pg-backup.sh
TimeoutStartSec=1800
|
Health Check#
1
2
3
4
5
6
7
| # Every 5 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
|
Migration from Cron#
| Cron | systemd Timer |
|---|
0 * * * * | OnCalendar=hourly |
0 0 * * * | OnCalendar=daily |
0 0 * * 0 | OnCalendar=weekly |
*/15 * * * * | OnCalendar=*:0/15 |
0 9 * * 1-5 | OnCalendar=Mon..Fri *-*-* 09:00 |
@reboot | OnBootSec=0 |
Debugging#
1
2
3
4
5
6
7
8
9
10
11
| # Check timer syntax
systemd-analyze verify /etc/systemd/system/backup.timer
# See when timer will fire
systemd-analyze calendar "OnCalendar=*-*-* 06:00:00"
# Check service logs
journalctl -u backup.service -f
# Check for failed units
systemctl --failed
|
Quick Reference#
1
2
3
4
5
| # Create timer + service
# Enable: systemctl enable --now name.timer
# Check: systemctl list-timers
# Logs: journalctl -u name.service
# Test: systemctl start name.service
|
systemd timers bring scheduled jobs into the modern era. Better logging, better control, better visibility.