Systemd Timers: A Practical Guide to Replacing Cron on Linux

Systemd timers are a modern, observable replacement for cron. This guide explains how timers work, how to create and enable them, and how to translate common cron patterns to OnCalendar. You’ll also get monotonic timer recipes, hardening tips, verification commands, troubleshooting steps, and a reusable email‐on‐failure notifier.

Table of Contents

Introduction

Cron has been the go‑to scheduler on Unix for decades. It’s simple and everywhere—but on modern Linux, it also spreads jobs across multiple crontabs, hides failures unless you wire up mail/logging, and can’t easily express operational needs like backfilling missed runs, sandboxing, or per‑job resource limits. Systemd timers keep the simplicity of “run X at time Y,” while giving you first‑class observability (journalctl), resilient catch‑up (Persistent=true), and strong unit‑level controls (users, cgroups, security hardening). If you manage production servers—or just want scheduled tasks on a laptop that sleeps—you’ll likely prefer timers.

This article is a pragmatic guide to migrating from cron to systemd timers. We’ll build a mental model (service does the work; timer decides when), create and enable units, map common cron patterns to OnCalendar, add hardening and resource limits, verify with real logs, and troubleshoot the sharp edges. Copy‑pasteable templates are included throughout.

Goal: Give admins a clear, copy‑pasteable path to migrate cronjobs to systemd timers, with explanations, templates, and troubleshooting.


Why replace cron with systemd timers?

  • Unified management: systemctl controls creation, enablement, status, logs, and sandboxing.
  • Better logging: Output goes to journald out of the box (journalctl -u your.service).
  • Reliability: Persistent=true makes timers catch up on missed runs (e.g., rebooted host or laptop asleep).
  • Flexibility: Calendar syntax (like cron) plus monotonic triggers (run X minutes after boot/last run).
  • Security & resource controls: Use User=/DynamicUser=, ProtectSystem=, PrivateTmp=, Nice=, CPUQuota=, MemoryMax=, etc.

When not to replace cron:

  • You only need a single, simple system-wide job and you already have working cron+logging.
  • You need non-systemd environments (containers/base images without systemd).

How systemd timers work (the 60‑second tour)

  • A .service unit defines the work to do.
  • A .timer unit defines when to start the service.
  • Timers can be calendar (e.g., 02:15 daily) or monotonic (e.g., 15 minutes after boot, or every 5 minutes since last activation).
  • Timers live beside services under /etc/systemd/system/ (system-wide) or ~/.config/systemd/user/ (per-user, no root required).
  • Timers are enabled/started like services, and their next/last run is visible via systemctl list-timers.

Rule of thumb: one .timer triggers one .service of the same base name (e.g., backup.timerbackup.service).


Quick start: create, install, run, and verify

We’ll migrate a cron job that runs /usr/local/bin/backup.sh every day at 02:15.

1) Create the service unit

Create /etc/systemd/system/backup.service:

Tip: Keep the job’s logic in a script and call it from ExecStart=. That keeps the unit tiny and makes local testing easy.

2) Create the timer unit

Create /etc/systemd/system/backup.timer:

Key fields:

  • OnCalendar: calendar expression (see templates below).
  • Persistent=true: catch up after downtime.
  • AccuracySec: jitter window; set lower for punctual jobs.
  • RandomizedDelaySec: add jitter for fleets to avoid thundering herds.

3) Reload & enable

4) Verify schedule and logs

5) Run ad‑hoc for testing


User timers vs system timers

  • System timers: /etc/systemd/system/*.timer, managed with sudo, run as specified User= (or root if omitted).
  • User timers: ~/.config/systemd/user/*.timer, managed with systemctl --user, run as your user without sudo.
    • Enable lingering to allow timers when not logged in: loginctl enable-linger <username>.

Examples (user scope):


Calendar syntax cheat‑sheet (cron → systemd)

Systemd’s OnCalendar supports rich expressions. Some handy forms:

DST & time zones: Systemd timers interpret times in the system’s localtime unless you use UTC suffix (e.g., 02:00 UTC). On DST transitions, systemd attempts to run at the correct local time; for skipped times, it schedules the next valid occurrence.

Cron → systemd mapping table

Cron Meaning OnCalendar equivalent
*/5 * * * * Every 5 minutes *:0/5
17 * * * * At minute 17 past every hour *-*-* *:17:00
15 2 * * * 02:15 daily *-*-* 02:15:00
0 9 * * 1-5 09:00 Mon–Fri Mon..Fri 09:00
30 10 * * 6,7 10:30 Sat & Sun Sat,Sun 10:30
0 3 1 * * 03:00 on the 1st of each month *-*-01 03:00:00
0 0 1 1 * Midnight every Jan 1 *-01-01 00:00:00
0 3 * * 0 Sundays at 03:00 Sun 03:00
0 23 L * * Last day of month 23:00 (Vixie cron extension) *-*-* 23:00 + constrain with date math: *-*-28..31 23:00 & AccuracySec=1h (or run daily and guard in script)

For complex “last weekday” styles, prefer a small wrapper script that exits non‑zero unless today matches the rule.


Example: migrate a cronjob step‑by‑step

Original crontab line (root):

Service: /etc/systemd/system/backup.service

Timer: /etc/systemd/system/backup.timer

Commands


Monotonic timers (not cron‑like, but super useful)

Monotonic timers trigger relative to events (boot, when the timer was enabled, when its service last started or stopped), instead of at specific wall‑clock times. They’re perfect for “every N minutes/hours” jobs, staggered warmups after boot, and periodic health checks that shouldn’t care about time zones or DST.

Key fields

  • OnActiveSec= — run N after this timer is activated (e.g., after enable --now or start).
  • OnBootSec= — run N after system boot.
  • OnStartupSec= — run N after the system manager starts (slightly later than boot; rarely needed on servers).
  • OnUnitActiveSec= — run N after the triggered unit (the .service) was last activated (i.e., started).
  • OnUnitInactiveSec= — run N after the triggered unit was last deactivated (i.e., finished/exited).
  • AccuracySec= — scheduling window; larger values coalesce wakeups and save power.
  • RandomizedDelaySec= — add jitter to distribute load across fleets.
  • WakeSystem= — if yes and the platform supports RTC wake, the system can wake from sleep for the trigger.

Monotonic timers measure intervals using uptime clocks and don’t care about wall‑clock jumps or DST. They also don’t backfill past runs that would have occurred while the machine was powered off; the interval starts counting from boot or the last activation event.

Common patterns (copy‑paste)

Warm up 10 minutes after boot, then hourly

Use when a job needs the system to settle before the first run.

Health check every 5 minutes, forever

Starts 5 minutes after enabling the timer; then every 5 minutes. Good for watchdogs.

Run 2 minutes after the previous run finished

Useful when you want a cool‑down between completions of the job.

Short, frequent poll with jitter (avoid dog‑piling)

Add jitter on fleets to spread work.

One‑shot at boot + 30s (no repeats)

Great for cache warming or seeding state.

Wake a sleeping laptop for a maintenance task every night at uptime intervals

Note: This is uptime‑based; if you need wall‑clock 02:00 local, use a calendar timer instead.

Choosing between OnActiveSec, OnUnitActiveSec, and OnUnitInactiveSec

Field Interval measured from When to use
OnActiveSec= When the timer became active Simple “every N” starting after enable; independent of the service runtime.
OnUnitActiveSec= When the service started “Every N since last start” even if the service runs a long time.
OnUnitInactiveSec= When the service finished “Wait N after completion” before the next run; avoids overlap naturally.

If your service runtime varies and you must avoid overlap, prefer OnUnitInactiveSec= and cap runtime with RuntimeMaxSec= or guard with flock in the script.

Service companion settings (to keep intervals sane)

Verifying monotonic schedules

Practical examples

Example A — Log rotate helper every 6 hours regardless of DST

/etc/systemd/system/log-rotate-helper.service

/etc/systemd/system/log-rotate-helper.timer

Example B — Run backups after boot delay, then once per day after each completion

Ensures one backup per day with a 15‑minute post‑boot grace and no overlap even if backups take hours.

Example C — Retry worker with cool‑down

Pair with exit codes in the service. If you need exponential backoff, build it into the script or re‑queue via systemd-run.

Gotchas & tips

  • Monotonic timers don’t catch up for time spent powered off. If catch‑up semantics are required (e.g., “run once for each missed day”), use a calendar timer with Persistent=true and let the script skip if not needed.
  • Intervals are counted from the chosen reference event; large AccuracySec can delay triggers slightly—set it consciously for SLO‑critical tasks.
  • RandomizedDelaySec applies to both calendar and monotonic timers; it’s your friend on multi‑host fleets.
  • For user timers on laptops, consider WakeSystem=yes but balance against battery life.

Observability & verification

  • List timers: systemctl list-timers --all
  • Explain next runs: systemd-analyze calendar 'Mon..Fri 02:30'
  • Show unit config: systemctl cat job.service job.timer
  • Logs: journalctl -u job.service (system) or journalctl --user -u job.service (user)
  • Dry‑run calendar: systemd-analyze calendar --iterations=5 'Sat *-*-01..07 03:00'

Email notifications on failure

There are two clean ways to alert on failures:

  1. Systemd‑native handler using OnFailure= → triggers a separate .service when your job fails (non‑zero exit, timeout, OOM, etc.).
  2. In‑script notification → your script emails when it detects an error. This works everywhere but mixes concerns.

Recommended: OnFailure= with a template notifier

Add to your job’s service unit (not the timer):

Create a reusable notifier template /etc/systemd/system/notify@.service:

This template receives the failing unit name in %I (instance of %n), gathers recent logs, and emails them. Swap mail for sendmail, msmtp, or an HTTP webhook via curl as needed.

Reload and test:

Tip: If your script is chatty, tighten the log slice by changing -n 200 or the --since window.

Per‑user timers (no root) with notifications

For --user units, create ~/.config/systemd/user/notify@.service with the same content and use a mailer available to the user (e.g., msmtp). Enable linger if you want notifications when logged out: loginctl enable-linger <user>.

Alternative: notify inside the script

Keep it simple with Bash’s || operator and set -euo pipefail:

Pros: portable. Cons: mixes job logic with alerting and depends on local mail config.

Sending only on repeated failures

Use systemd’s restart policy plus a notifier hooked to a separate “give up” unit:

Or, run the notifier from a path unit that watches a flag file your script writes only after N consecutive failures.

Minimizing noise

  • Include a Runbook URL in the email body.
  • Add the timer schedule and host: %H (hostname) and systemctl cat <unit> snippets help responders.
  • Consider routing through a central alerting system (e.g., use curl to post to Opsgenie/PagerDuty/Slack webhook) instead of raw email.

Troubleshooting

Timer never fires

  • systemctl status job.timer — is it loaded, enabled, and active?
  • systemctl list-timers --all | grep job — confirm next run time.
  • Missing WantedBy=timers.target in [Install]? Re‑enable: systemctl enable --now job.timer.
  • Wrong path or permissions in ExecStart=? Check journalctl -u job.service.
  • For user timers when logged out, enable lingering: loginctl enable-linger <user>.

Missed during reboot/maintenance window

  • Add Persistent=true to the [Timer] section.

Wrong time zone / DST surprises

  • Confirm timedatectl shows the expected local timezone.
  • Use explicit UTC in OnCalendar when coordinating cross‑region jobs.

Command needs PATH or env vars

  • Set Environment= in the service or use an EnvironmentFile=/etc/default/jobname.
  • Always specify absolute paths in ExecStart=.

Service keeps running forever

  • Add TimeoutStartSec=10min (or an appropriate limit) to the [Service].

Multiple jobs pile up

  • Add RefuseManualStart=/RefuseManualStop= if needed.
  • Use ConcurrencyPolicy= isn’t available in systemd; instead set ExecStartPre=/bin/systemd-run --scope --property=... or use a lock file in the script. Simpler: guard your script with flock.

Needs root?

  • Prefer least privilege: run as a non‑root User=/Group= with adequate permissions. Use sudo within the script only if absolutely necessary.

SELinux/AppArmor denials

  • Check journalctl -t setroubleshoot (RHEL family) or dmesg for AVCs; adjust policies or relax hardening options.

Hardening & resource controls (copy‑paste snippets)

Minimal sandbox for safe scripts:

Resource limits:


Patterns & templates library

Hourly, at minute 0

Every N minutes

Business days at 08:00 America/New_York

Weekly maintenance, Sunday 02:30 UTC with jitter

Quarter-hourly with catch-up after downtime

At boot + 10m, then every day


Safe migration checklist

Use this end‑to‑end flow to move a fleet from cron to timers with minimal risk.

  1. Inventory everything

  1. Extract logic into scripts
  • Put each job’s logic in /usr/local/bin/<job>.sh with absolute paths, strict mode, and clear exit codes.

  1. Create a service unit per job
  • Minimal Type=oneshot, point ExecStart= at the script, and set User= to the least‑privileged account.
  • Add sandboxing (see Hardening & resource controls below).
  1. Create a matching timer
  • Translate the cron expression to OnCalendar= (use the mapping table or systemd-analyze calendar 'expr').
  • Add Persistent=true if you need catch‑up after downtime.
  1. Load and enable

  1. Verify schedule & next run

  1. Smoke‑test the service

  1. Run the first scheduled execution under watch
  • Keep journalctl -f -u <job>.service open during the first real run.
  • Tune AccuracySec/RandomizedDelaySec to fit your environment (tight for single hosts, jitter for fleets).
  1. Disable the original cron entries
  • Comment out lines in crontabs or remove files under /etc/cron.d/ only after the timer proves reliable.
  1. Document & standardize
  • Commit unit files to config management.
  • Adopt naming: <app>-<purpose>.service + <app>-<purpose>.timer.
  • Add a runbook with: how to start/stop, where logs live, SLO (max duration), owner/escalation.

Quick audit loop (post‑migration)


FAQ

Where do I put units?

  • System-wide: /etc/systemd/system/
  • Per-user: ~/.config/systemd/user/

How do I uninstall?

Can a timer trigger multiple services?

  • Use one timer per service. For fan‑out, trigger a wrapper script that calls others, or use a target/wants relationship.

What about systemd.path units?

  • Use .path to trigger on filesystem events (e.g., when a file appears). Combine with .timer if you need both time- and event-driven runs.

Appendix: Useful commands reference


Ready-to-use skeleton

/etc/systemd/system/job.service

/etc/systemd/system/job.timer


That’s it! With these templates and checks, you can confidently migrate cron to systemd timers, gain better observability, and harden scheduled jobs with modern Linux tooling.

Have a project or a problem?

Talk with a senior engineer for practical recommendations—no obligation.

Leave a comment

Leave a Reply

Your email address will not be published. Required fields are marked *

Related Posts

Categories

Get a free consultation from Reliable Penguin

Submit the form—or for immediate service call 866-649-7984.