Amakuru.net

systemd timers

Cheat sheet for systemd timers — unit file structure, OnCalendar syntax, and debugging commands.

systemd timers are the systemd alternative to cron. The main reasons to prefer them: logs go through journalctl automatically, they can depend on other units (network, mounts), and Persistent=true runs a missed job on next boot instead of silently skipping it.

The tradeoff is more ceremony — two files per job instead of one crontab line. Whether that’s worth it depends on how much you care about the logging and the dependency handling.

Unit files

Each timer needs two files: a .service that describes what to run, and a .timer that describes when.

my-script.service

[Unit]
Description=My script

[Service]
Type=oneshot
ExecStart=/path/to/my-script.sh

my-script.timer

[Unit]
Description=Run my script periodically

[Timer]
OnCalendar=hourly
Persistent=true

[Install]
WantedBy=timers.target

Type=oneshot means systemd considers the service done when the process exits — correct for scripts. Persistent=true catches up on missed runs after a reboot. You enable the .timer, not the .service.

RandomizedDelaySec= is worth knowing about: it adds a random delay up to the specified value before each run, which prevents multiple timers from firing simultaneously on the same host (or across a fleet if clocks are in sync).

[Timer]
OnCalendar=hourly
Persistent=true
RandomizedDelaySec=300   # fires somewhere in the first 5 minutes of each hour

File locations

# User timers (no root required)
~/.config/systemd/user/

# System-wide timers
/etc/systemd/system/

# Reload after adding or editing files
systemctl --user daemon-reload   # user scope
systemctl daemon-reload          # system scope (needs sudo)

Operations

# Enable and start immediately
systemctl --user enable --now my-script.timer

# Disable and stop immediately
systemctl --user disable --now my-script.timer

# Start / stop without changing enable state
systemctl --user start my-script.timer
systemctl --user stop my-script.timer

# Run the service right now (for testing, bypasses the timer)
systemctl --user start my-script.service

Schedule syntax: OnCalendar

Full format: DayOfWeek Year-Month-Day Hour:Minute:Second. Most fields accept * (any), ranges (1..5), step values (0/5), and comma-separated lists.

Named shortcuts

OnCalendar=minutely      # every minute
OnCalendar=hourly        # every hour at :00
OnCalendar=daily         # every day at 00:00:00
OnCalendar=weekly        # every Monday at 00:00:00
OnCalendar=monthly       # 1st of the month at 00:00:00
OnCalendar=quarterly     # 1st of Jan, Apr, Jul, Oct
OnCalendar=semiannually  # 1st of Jan and Jul
OnCalendar=yearly        # January 1st at 00:00:00

Time patterns

OnCalendar=*:0/5            # every 5 minutes
OnCalendar=*:0,15,30,45     # every 15 minutes
OnCalendar=*:30:00          # every hour at :30
OnCalendar=0/2:00:00        # every 2 hours
OnCalendar=08:30:00         # daily at 08:30
OnCalendar=*-*-* 06:00,18:00:00   # daily at 06:00 and 18:00

Day patterns

OnCalendar=Mon 09:00:00         # every Monday at 09:00
OnCalendar=Mon,Wed,Fri 12:00:00 # three days a week
OnCalendar=Mon..Fri 09:00:00    # weekdays
OnCalendar=Sat,Sun 08:00:00     # weekends
OnCalendar=*-01-01 00:00:00     # New Year's Day
OnCalendar=*-*-15 12:00:00      # 15th of every month
OnCalendar=*-01..03-* 12:00:00  # January through March
OnCalendar=*-*~1 00:00:00       # last day of every month (~ = count from end)
OnCalendar=*-*~3 00:00:00       # third-to-last day of every month

Complex examples

OnCalendar=Mon..Fri 9/2:00:00       # weekdays, every 2 hours starting at 09:00
OnCalendar=Mon *-*-1..7 09:00:00    # first Monday of the month
OnCalendar=*:*:0/30                 # every 30 seconds (useful for testing)

Viewing and debugging

# List active timers with next/last run times
systemctl --user list-timers

# Include inactive timers
systemctl --user list-timers --all

# Check a specific timer's status
systemctl --user status my-script.timer

# Follow logs for the service
journalctl --user -u my-script.service -f

# Show all logs for timer and service
journalctl --user -u my-script.timer
journalctl --user -u my-script.service

# Validate an OnCalendar expression and see the next trigger times
systemd-analyze calendar "Mon..Fri 09:00:00"

systemd-analyze calendar is the most useful one — paste any expression and it shows the next five scheduled times, which makes it easy to catch off-by-one errors in step syntax or day ranges.

Filtering logs

# Since a specific time
journalctl --user -u my-script.service --since "2025-01-15 08:00:00"

# Since/until a range
journalctl --user -u my-script.service --since "yesterday" --until "today"

# Last N lines
journalctl --user -u my-script.service -n 50

# Only failed runs (non-zero exit)
journalctl --user -u my-script.service -p err

Checking exit codes

# Show last run result and exit code
systemctl --user show my-script.service \
  --property=Result,ExecMainStatus,ActiveEnterTimestamp

Result=success means clean exit. Result=exit-code with ExecMainStatus=1 (or any non-zero) means the script failed. Result=timeout means it was killed for exceeding TimeoutStartSec=.