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:

  1. A .timer unit (the schedule)
  2. 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

Cronsystemd Timer
0 * * * *OnCalendar=hourly
0 0 * * *OnCalendar=daily
0 0 * * 0OnCalendar=weekly
*/15 * * * *OnCalendar=*:0/15
0 9 * * 1-5OnCalendar=Mon..Fri *-*-* 09:00
@rebootOnBootSec=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.