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:

  1. Service unit (.service) — what to run
  2. Timer unit (.timer) — when to run it
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
 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:

NTTEuuXeeT22002266--0022--22550076::0105::0000EESSTTL51E83FmmTiinnlleeffttLTTAuuSeeT22002266--0022--22550066::0000::0000EESSTTP11AmmSiiSnnEDaaggooUbhNaeIcaTklutph.cthiemcekr.timer

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

CronSystemd OnCalendar
0 * * * **-*-* *:00:00 (hourly)
*/15 * * * **:0/15
0 0 * * *daily or *-*-* 00:00:00
0 0 * * 0weekly or Sun *-*-* 00:00:00
0 0 1 * *monthly or *-*-01 00:00:00
0 9 * * 1-5Mon..Fri *-*-* 09:00:00

Convert a Cron Job

Before (cron):

302/usr/local/bin/backup.shvar/log/backup.log2>&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.