How systemd Timers Work
By the end of this lesson you will understand how systemd separates scheduling from execution using the two-unit model, know the difference between calendar and monotonic timers, and be able to trace the complete lifecycle from unit creation to log reading.
The Two-Unit Model
Every systemd timer requires two unit files working together:
- Timer unit (
name.timer) — defines when to run. - Service unit (
name.service) — defines what to run.
A .timer unit never executes a command directly. It only activates a .service unit at the scheduled time. The service unit runs the actual script or command.
Automatic Name Matching
If the Unit= directive is omitted in the .timer file, systemd automatically looks for a .service with the same base name:
wp-backup.timer ──► wp-backup.service ──► /usr/local/bin/wp-backup.sh
health-check.timer ──► health-check.service ──► /usr/local/bin/health-check.sh
certbot-renew.timer ──► certbot-renew.service ──► /usr/bin/certbot renew
You can override this with Unit=another-service.service in the [Timer] section, but the default name-matching convention is what most setups use.
State Separation
The timer unit and service unit have separate states. Understanding this is critical for debugging:
| Unit | What It Tells You |
|---|---|
wp-backup.timer | Next run time, last trigger time, schedule expression, enabled state |
wp-backup.service | Exit code, stdout/stderr output, runtime duration, failure reason |
# Always check BOTH units when debugging
systemctl status wp-backup.timer --no-pager
systemctl status wp-backup.service --no-pager
● wp-backup.timer - Run WordPress backup nightly at 02:15
Loaded: loaded (/etc/systemd/system/wp-backup.timer; enabled)
Active: active (waiting) since Sun 2026-03-01 02:15:03 UTC; 21h ago
Trigger: Mon 2026-03-02 02:15:00 UTC; 2h 14min left
Triggers: ● wp-backup.service
● wp-backup.service - WordPress nightly backup
Loaded: loaded (/etc/systemd/system/wp-backup.service; static)
Active: inactive (dead) since Sun 2026-03-01 02:16:10 UTC; 21h ago
Process: 1201 ExecStart=/usr/local/bin/wp-backup.sh (code=exited, status=0/SUCCESS)
Main PID: 1201 (code=exited, status=0/SUCCESS)
Timer Types: Calendar vs Monotonic
systemd supports two fundamentally different scheduling models:
Calendar Timers (Wall-Clock)
Calendar timers fire at specific wall-clock times — like cron. They use the OnCalendar= directive.
[Timer]
OnCalendar=02:15 # Every day at 2:15 AM
OnCalendar=Mon..Fri 06:00 # Weekdays at 6 AM
OnCalendar=monthly # 1st of each month at midnight
Use when: You need a job to run at a specific time of day or on a specific date — backups, reports, maintenance windows.
Monotonic Timers (Relative/Interval)
Monotonic timers count relative to lifecycle events — not the wall clock. They use directives like OnBootSec=, OnUnitActiveSec=, and OnUnitInactiveSec=.
[Timer]
OnBootSec=2min # 2 minutes after system boot
OnUnitActiveSec=1h # 1 hour after last activation
OnUnitInactiveSec=20m # 20 minutes after last run completed
Use when: You need a job to run at regular intervals regardless of the time of day — heartbeats, health checks, retry loops.
Comparison
| Aspect | Calendar (OnCalendar=) | Monotonic (OnBootSec=, etc.) |
|---|---|---|
| Based on | Wall-clock time | Elapsed time since event |
Supports Persistent=true | Yes | No |
| Timezone aware | Yes (append timezone) | No (relative timing) |
| Schedule validation | Yes (systemd-analyze calendar) | No |
| Drifts if system load delays run | No (fixed to clock) | Yes (counts from last event) |
| Best for | Backups, reports, maintenance | Heartbeats, health checks, retries |
Combining Both Types
You can combine calendar and monotonic directives in a single timer:
[Timer]
OnBootSec=30s # Run 30 seconds after boot
OnUnitActiveSec=1h # Then repeat every hour
This runs the service 30 seconds after boot, then every hour after each activation.
The Lifecycle
Step-by-Step Breakdown
| Step | What Happens | Your Action |
|---|---|---|
| 1. Create files | Write the .timer and .service unit files | Place in /etc/systemd/system/ |
| 2. Reload | systemd reads the new files from disk | sudo systemctl daemon-reload |
| 3. Enable | systemd creates symlinks and starts the timer | sudo systemctl enable --now name.timer |
| 4. Wait | Timer counts down to the next scheduled time | Nothing — automatic |
| 5. Fire | Schedule matches → systemd starts the service | Nothing — automatic |
| 6. Execute | The ExecStart command runs | Your script does its work |
| 7. Log | stdout/stderr are captured by journald | journalctl -u name.service |
| 8. Repeat | Timer resets and waits for the next schedule | Automatic |
Where Timer Units Live
System Units (All Users, Root Required)
| Location | Purpose | Who Manages It |
|---|---|---|
/etc/systemd/system/ | Admin-created custom units and overrides | root |
/usr/lib/systemd/system/ | Package-provided units (do not edit directly) | Package manager |
/etc/systemd/system/name.d/override.conf | Override a package unit without replacing it | root |
User Units (Per User, No Root Required)
| Location | Purpose |
|---|---|
~/.config/systemd/user/ | Per-user timer and service units |
User units run inside the user's session:
mkdir -p ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now my-timer.timer
systemctl --user list-timers --no-pager
By default, user units stop when the user logs out. To keep them running:
sudo loginctl enable-linger "$USER"
How systemd Timers Compare to Cron
Side-by-Side
| Feature | Cron | systemd timer |
|---|---|---|
| Schedule format | m h dom mon dow (5 fields) | OnCalendar= expression |
| Configuration | One line per job in crontab | Two files per job (.timer + .service) |
| Logging | Manual (redirect >> file 2>&1) | Automatic (journald) |
| Missed runs | Silently skipped | Persistent=true catches up |
| Overlap prevention | Manual (flock) | flock + systemd oneshot queuing |
| Dependencies | Not available | After=, Wants=, Requires= |
| Security sandbox | Not available | ProtectSystem=, PrivateTmp=, etc. |
| Jitter | Not available | RandomizedDelaySec= |
| Schedule testing | Not available | systemd-analyze calendar |
| Resource limits | Not available | RuntimeMaxSec=, CPUQuota= |
| Visibility | crontab -l (per user) | systemctl list-timers (system-wide) |
Cron Expression → OnCalendar Conversion
| Cron | OnCalendar | Meaning |
|---|---|---|
* * * * * | minutely or *:* | Every minute |
0 * * * * | hourly | Every hour |
0 0 * * * | daily | Midnight daily |
0 2 * * * | 02:00 | Daily at 2 AM |
15 2 * * * | 02:15 | Daily at 2:15 AM |
0 6 * * 1-5 | Mon..Fri 06:00 | Weekdays at 6 AM |
*/5 * * * * | *:0/5 | Every 5 minutes |
*/15 * * * * | *:0/15 | Every 15 minutes |
0 0 1 * * | monthly | 1st of month |
0 0 * * 0 | weekly or Sun *-*-* | Weekly on Sunday |
0 3 * * 6 | Sat 03:00 | Saturday at 3 AM |
When to Use Cron vs systemd Timer
Your First Timer — Hands-On Walkthrough
Step 1 — Create the Service Unit
[Unit]
Description=Write a heartbeat timestamp
[Service]
Type=oneshot
ExecStart=/bin/bash -c 'echo "[$(date +%%F\ %%T)] heartbeat" >> /tmp/heartbeat.log'
Step 2 — Create the Timer Unit
[Unit]
Description=Fire heartbeat every 60 seconds
[Timer]
OnUnitActiveSec=60s
[Install]
WantedBy=timers.target
Step 3 — Reload, Enable, and Verify
sudo systemctl daemon-reload
sudo systemctl enable --now heartbeat.timer
Step 4 — Confirm It Is Scheduled
systemctl list-timers heartbeat.timer --no-pager
NEXT LEFT LAST PASSED UNIT ACTIVATES
Mon 2026-03-02 03:06:00 UTC 58s left n/a n/a heartbeat.timer heartbeat.service
Step 5 — Test Immediately
sudo systemctl start heartbeat.service
cat /tmp/heartbeat.log
[2026-03-02 03:05:02] heartbeat
Step 6 — Read the Journal
journalctl -u heartbeat.service -n 5 --no-pager
You now know the complete lifecycle: create → reload → enable → verify → test → read logs.
Key Takeaways
- A
.timerunit always works with a.serviceunit — it never runs commands directly. - Calendar timers (
OnCalendar=) fire at wall-clock times — like cron but with more power. - Monotonic timers (
OnBootSec=,OnUnitActiveSec=) fire after elapsed time — for heartbeats and retries. - Always check both the
.timerstatus (next/last run) and the.servicelogs when debugging. - systemd timers replace cron with native logging, missed-run catch-up, and security sandboxing.
What's Next
- OnCalendar Syntax — master the calendar expression format for wall-clock scheduling.