Skip to content

systemd-timer: scheduling instead of cron

cron works, but its logs are flat text files with no structure, and service dependencies require workarounds like embedding Requires= logic inside shell scripts. systemd-timer fixes this: unified management interface, logs in journald, dependencies through the familiar After= and WantedBy= directives — all in one stack.

Structure: .service and .timer

A timer is a separate unit that triggers a .service. The separation is intentional: the service can be invoked manually or on a schedule.

/etc/systemd/system/
├── backup.service
└── backup.timer

backup.service is a regular unit, startable via systemctl start backup.service.

backup.timer is the trigger. Without it, the service will not run on a schedule.

# /etc/systemd/system/backup.service
[Unit]
Description=Backup to storage
After=network.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup daily

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target

Persistent=true — if the machine was off when the timer fired, it catches up after boot.

Calendar Timers (OnCalendar)

OnCalendar= format is the closest analog to cron expressions, but with different syntax.

ExampleFires
OnCalendar=dailyEvery day at 00:00
OnCalendar=*-*-01 03:00First day of each month at 03:00
OnCalendar=*-*-* 02:00Every day at 02:00
OnCalendar=09..17:00Every hour from 09:00 to 17:00
OnCalendar=*:0/15Every 15 minutes
OnCalendar=Mon..Fri 09:30Weekdays at 09:30

Multiple values can be specified comma-separated:

[Timer]
OnCalendar=09:00,12:00,18:00

To validate syntax before applying:

systemd-analyze calendar '*-*-01 03:00'

Output shows the next firing date. Useful when “every second Tuesday” becomes *-*-1..31 03:00 — verify before deployment.

Monotonic Timers

Monotonic timers count from an event, not the clock.

DirectiveFires
OnBootSec=5min5 minutes after boot
OnStartupSec=10min10 minutes after systemd manager starts
OnUnitActiveSec=1h1 hour after the service last ran
OnUnitInactiveSec=1d1 day after the service stopped

OnBootSec and OnStartupSec are similar, but OnBootSec resets on each boot while OnStartupSec counts from when the systemd manager started. In practice, the difference shows in containers and during live migration.

Combination OnBootSec + OnUnitActiveSec implements “every hour, but not before boot”:

[Timer]
OnBootSec=10min
OnUnitActiveSec=1h

Verification: systemctl list-timers

After enabling and starting:

sudo systemctl enable --now backup.timer
sudo systemctl list-timers --all
NEXT                        LEFT     LAST                        PASSED  UNIT            ACTIVATES
Mon 2024-11-18 00:00:00 MSK  6h left  Sun 2024-11-17 00:00:08 MSK 18h ago backup.timer   backup.service

Without --all shows only active timers. NEXT is when it fires, LEFT is how long until then.

If the timer does not appear in the list — check status:

systemctl status backup.timer
systemctl status backup.service
journalctl -u backup.service -n 50

Common cause of a silent timer is a missing WantedBy=timers.target.

User-Level Timers (systemd –user)

Not every task needs root. Deployment scripts, home-directory cache cleanup, periodic git fetch — better under a user.

Units go in ~/.config/systemd/user/:

mkdir -p ~/.config/systemd/user
# ~/.config/systemd/user/sync.service
[Unit]
Description=Git sync

[Service]
Type=oneshot
WorkingDirectory=%h/projects/monorepo
ExecStart=/usr/bin/git fetch --all

[Install]
WantedBy=default.target
# ~/.config/systemd/user/sync.timer
[Unit]
Description=Git sync every hour

[Timer]
OnBootSec=2min
OnUnitActiveSec=1h

[Install]
WantedBy=timers.target

Activation:

systemctl --user enable --now sync.timer

User timers need linger if they should run without login:

sudo loginctl enable-linger username

Common Mistakes

Missing OnCalendar or monotonic timer. Without a [Timer] directive, the timer will never fire. systemctl start backup.timer starts the unit, but without a schedule it just sits there.

Forgot WantedBy. Without [Install], the unit does not persist across reboots. systemctl enable backup.timer completes without errors, but it will not appear in list-timers.

ExecStart in .timer. A timer only triggers its linked service. Placing ExecStart in .timer causes systemd to ignore it and pull from .service.

Persistent with no prior run. On first activation, Persistent=true does nothing — there is no “last run” in history. The service fires only at the next scheduled time.

Logging: cron vs timer in journald

Cron sends output to syslog or cron.log, structure is a text line with timestamp. Parsing requires grep or awk.

systemd-timer writes service stdout/stderr directly to journald:

journalctl -u backup.service -f

Filtering by time, unit, severity — standard journalctl flags:

FlagEffect
-u backup.serviceThis unit only
-n 100Last 100 lines
-fFollow in real time
--since "1 hour ago"Time range filter
-p errErrors only

Actual firing time is recorded in metadata. Build execution history without parsing text logs.

journalctl -u backup.timer -o short-iso -n 20

Output includes real start time, simplifying debugging of missed triggers.

Summary

Switching from cron to systemd-timer pays off when the workload already lives in a systemd environment. Unified management, dependencies via After=, logs in journald — gains are tangible. For a crontab one-liner, systemd-timer is overkill, but for scripts with dependencies, logging, and boot persistence — a mature tool.