systemd timers
A systemd timer (.timer unit) activates a companion .service unit on a schedule. OnCalendar sets wall-clock events; OnBootSec/OnUnitActiveSec set monotonic intervals. Persistent=true catches up a missed run. systemctl list-timers shows all timers and next activation.
cron has a well-known weakness: when a job runs, it logs nothing to the journal, has no service status, and if it fails you find out from a forgotten email or a broken backup. systemd timers solve this by separating the schedule from the work: a .timer unit defines when, a .service unit defines what, and the journal captures everything the service prints. You can inspect the last run with systemctl status, see whether the timer is active with systemctl list-timers, and get failure alerts via the same mechanisms you already use for services. The cost is a bit more boilerplate — two files instead of one line. For anything that matters, it is worth it.
After this lesson you can write a .timer + .service pair, distinguish OnCalendar (wall-clock) from OnBootSec/OnUnitActiveSec (monotonic) timers, enable Persistent=true to catch up missed runs, and use systemctl list-timers to inspect the current schedule.
A timer unit drives a service unit. By convention, foo.timer activates foo.service. Both files live in /etc/systemd/system/ for local installations or /lib/systemd/system/ for packages. A minimal pair looks like this:
# /etc/systemd/system/db-backup.service
[Unit]
Description=Database backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-db.sh
# No [Install] section — the timer controls when this runs, not a WantedBy target# /etc/systemd/system/db-backup.timer
[Unit]
Description=Daily database backup timer
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.targetThe service has Type=oneshot — it runs to completion and exits; systemd does not expect it to stay alive. The timer has the [Install] section; the service does not. Enable and start with:
systemctl daemon-reload
systemctl enable --now db-backup.timerOnCalendar sets wall-clock schedules with calendar event expressions. The syntax is more readable than cron’s five fields, and systemd validates it:
# Verify a calendar expression before deploying:
systemd-analyze calendar 'Mon-Fri *-*-* 08:00:00'
# Output: Next elapse: Mon 2024-03-18 08:00:00 UTC
# Common calendar events:
OnCalendar=daily # same as *-*-* 00:00:00
OnCalendar=weekly # Mon *-*-* 00:00:00
OnCalendar=monthly # *-*-01 00:00:00
OnCalendar=*-*-* 02:30:00 # every day at 02:30
OnCalendar=Mon-Fri *-*-* 09:00:00 # weekdays at 09:00
OnCalendar=*:0/15 # every 15 minutes
OnCalendar=*-*-* *:00:00 # top of every hoursystemd-analyze calendar is invaluable: paste any expression and it shows you the next five activations. Do this before enabling a timer — catching a wrong expression before it misses a weekly run saves a week of waiting.
Monotonic timers measure elapsed time, not wall-clock time. They are relative to boot or to the last activation:
[Timer]
# Fire 5 minutes after the system boots — useful for post-boot setup:
OnBootSec=5min
# Fire 1 hour after the last time this service finished:
OnUnitActiveSec=1h
# Combine both: fire 10 minutes after boot, then every hour:
OnBootSec=10min
OnUnitActiveSec=1hThe key difference from OnCalendar: monotonic timers do not align to wall-clock boundaries. If your service takes 20 minutes and OnUnitActiveSec=1h, the next activation is 1 hour after the service exits — not on the clock-hour. This is intentional for jobs that must not overlap and where exact timing does not matter.
# Human-readable time units accepted:
# 5min, 1h, 30s, 2d, 1weekPersistent=true catches up a missed OnCalendar run. Without it, if the machine was off when the timer was due, the run is skipped until the next scheduled time. With Persistent=true, systemd fires the service immediately on the next boot if the last activation is older than the scheduled interval:
[Timer]
OnCalendar=daily
Persistent=true
# If the machine was off at midnight, runs the backup shortly after next bootThis is the systemd equivalent of anacron on systems that are not always on. Enable it for any job where “I was rebooting during the window” is an acceptable excuse for a missed backup — which is almost never.
systemctl list-timers shows all active timers and their next activation. This is the first thing to check when you suspect a timer is not running:
# List all active timers:
systemctl list-timers
# NEXT LEFT LAST PASSED UNIT ACTIVATES
# Mon 2024-03-18 02:30:00 UTC 14h left Sun 2024-03-17 02:30:01 UTC 9h ago db-backup.timer db-backup.service
# Mon 2024-03-18 09:00:00 UTC 21h left Sun 2024-03-17 09:00:00 UTC 3h ago apt-daily.timer apt-daily.service
# Mon 2024-03-18 10:00:00 UTC 22h left - - motd-news.timer motd-news.service
# Check the full status of a specific timer:
systemctl status db-backup.timer
# Check the last run of the service the timer activated:
systemctl status db-backup.service
journalctl -u db-backup.service -b --since "yesterday"The LAST column tells you when it last fired; PASSED tells you how long ago. An empty LAST means it has never run — either it just started, or Persistent=true has not triggered a catch-up yet.
Create a timer that prunes Docker images every Sunday at 03:00.
# Step 1: write the service unit
cat > /etc/systemd/system/docker-prune.service << 'EOF'
[Unit]
Description=Prune unused Docker images
[Service]
Type=oneshot
ExecStart=/usr/bin/docker image prune -af --filter "until=168h"
EOF
# Step 2: write the timer unit
cat > /etc/systemd/system/docker-prune.timer << 'EOF'
[Unit]
Description=Weekly Docker image prune
[Timer]
OnCalendar=Sun *-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
# Step 3: reload, enable, start
systemctl daemon-reload
systemctl enable --now docker-prune.timer
# Step 4: verify the timer is active
systemctl list-timers docker-prune.timer
# NEXT: Sun 2024-03-24 03:00:00 UTC 6d left
# Step 5: test the service without waiting for the timer:
systemctl start docker-prune.service
journalctl -u docker-prune.service -b -n 20
# Mar 18 12:34:05 host docker[5821]: Deleted: sha256:abc123...
# Mar 18 12:34:06 host docker[5821]: Total reclaimed space: 2.3GB
# Mar 18 12:34:06 host systemd[1]: docker-prune.service: Succeeded.This is the canonical workflow: write service, write timer, reload, enable, then run the service once manually to confirm it works before the next scheduled window.
▸Why this works
AccuracySec and RandomizedDelaySec. By default, systemd timers fire within a one-minute accuracy window (AccuracySec=1min). This is intentional: systemd may coalesce multiple timers that fire close together to reduce wake-ups. If you need precise timing, set AccuracySec=1s. The inverse is RandomizedDelaySec=: adding a random delay within a range prevents a thundering-herd problem when hundreds of machines with OnCalendar=daily would all hit your backup storage at exactly midnight. A RandomizedDelaySec=1h spreads them over the hour.
▸Common mistake
Forgetting systemctl daemon-reload. After writing or editing a .timer or .service file, systemd does not notice until you reload the unit definitions. Skipping daemon-reload means systemctl will either use the old definition or report “Unit not found.” This is the most common reason a newly written timer appears to do nothing. Always: write file → daemon-reload → enable --now.
A timer unit has `OnCalendar=daily` and `Persistent=true`. The machine was shut down for 3 days. What happens when it boots?
A systemd timer is a .timer unit that activates a companion .service unit. OnCalendar takes human-readable calendar expressions (*-*-* 02:30:00, Mon-Fri *-*-* 09:00:00) validated by systemd-analyze calendar. OnBootSec and OnUnitActiveSec are monotonic: they measure elapsed time since boot or since the last run, not wall-clock boundaries. Persistent=true fires a catch-up run on boot when a calendar-based timer missed its window. Enable a timer with systemctl enable --now foo.timer; always run systemctl daemon-reload after writing unit files. systemctl list-timers shows next activation, last activation, and which service each timer drives. All output goes to the journal — query with journalctl -u foo.service.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.