Tutorials  /  Linux Basics

Use systemd Timers Instead of Cron Jobs

LLudwig · August 2026 ·11 min read ·Linux Basics, Tutorial

Cron gives you a schedule and, at best, a mail to a local user when something breaks. It does not give you dependency ordering, structured logs, per-job resource limits, or a way to catch up on a run that was missed while the machine was powered off. systemd timers cover all of that because every scheduled job becomes a regular unit that systemctl and journalctl can inspect.

What is a systemd timer?

A systemd timer is a unit file with the suffix .timer that activates another unit, normally a matching .service, either at a wall-clock calendar time or after a monotonic interval such as 15 minutes since the last run.

The timer itself contains no commands. It only says when; the service says what. If a timer is named backup.timer and does not set Unit=, systemd activates backup.service by default. Timers are enabled through timers.target, which is pulled in during a normal boot, and each activation is recorded in the journal with the exit status of the service.

Why replace cron jobs with systemd timers?

systemd timers replace cron jobs because they add journal-based logging, ordering against other units, cgroup resource limits, catch-up runs after downtime, and a calendar parser you can test with systemd-analyze before the job ever runs.

Capability cron systemd timer
Log destination mail to local user, or manual redirect journalctl -u <unit>
Missed run after downtime dropped replayed with Persistent=true
Resource limits none CPUQuota=, MemoryMax=, IOSchedulingClass=
Ordering none After=, Wants=, Requires=
Overlapping runs possible prevented, a service starts only once
Schedule test none systemd-analyze calendar
Load spreading manual sleep RandomizedDelaySec=
Failure handling exit code lost OnFailure= unit, systemctl --failed

cron is still fine for a trivial one-line job on a single host. Once a job needs a specific user, a network dependency, a bounded memory budget, or an audit trail, the unit file is the better place to describe it.

VM

Matching infrastructure at centron

No hardware needed to follow along: ccloud³ VMs with full root access, billed by the hour and ready in seconds. Rent a cloud server →

Prerequisites

  • A Linux host running systemd 232 or newer, which covers Debian 9+, Ubuntu 16.04+, RHEL/Rocky 8+ and current openSUSE. A scalable Linux cloud VM is enough to follow along.
  • Root access or a user with sudo rights.
  • An executable script to schedule. This tutorial uses /usr/local/sbin/backup.sh.

Check the version first, because Persistent= and RandomizedDelaySec= behave as documented from systemd 232 onwards:

Console
$ systemctl --version | head -n 1
systemd 255 (255.4-1ubuntu8)

Write the service unit

Create /etc/systemd/system/backup.service. This unit describes the work and nothing about the schedule:

ini
[Unit]
Description=Nightly rsync backup of /srv to /mnt/backup
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/backup.sh
User=backup
Nice=10
IOSchedulingClass=idle
EnvironmentFile=-/etc/default/backup
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/mnt/backup
  • Type=oneshot tells systemd the process runs to completion and then exits. The unit stays in state inactive (dead) between runs, which is correct for a batch job.
  • User=backup drops privileges. Without it the job runs as root.
  • Nice=10 and IOSchedulingClass=idle keep a long copy job from starving interactive workloads.
  • EnvironmentFile=-/etc/default/backup loads variables if the file exists. The leading - makes a missing file non-fatal.
  • ProtectSystem=strict mounts the whole filesystem read-only for this unit, so ReadWritePaths= must list every directory the script writes to.

No [Install] section is needed. The timer is what gets enabled.

Test the service on its own before adding a schedule:

Console
$ sudo systemctl daemon-reload
$ sudo systemctl start backup.service
$ systemctl status backup.service

Write the timer unit

Create /etc/systemd/system/backup.timer with the same base name as the service:

ini
[Unit]
Description=Run backup.service every night at 02:30

[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=300
AccuracySec=1s
Unit=backup.service

[Install]
WantedBy=timers.target
  • OnCalendar= is the wall-clock schedule. It may appear multiple times in one timer to define several trigger points.
  • Persistent=true stores the last activation time under /var/lib/systemd/timers/. If the host was off at 02:30, the service runs once shortly after the next boot instead of being skipped.
  • RandomizedDelaySec=300 delays each start by a random 0 to 300 seconds. On a fleet of hosts with identical schedules this prevents a synchronised load spike.
  • AccuracySec=1s disables the default 1-minute coalescing window. Leave the default on battery-powered or heavily virtualised systems, where letting systemd batch wakeups is cheaper.
  • Unit=backup.service is optional here because the names match. State it explicitly when timer and service names differ.

OnCalendar syntax

The format is DayOfWeek Year-Month-Day Hour:Minute:Second. Omitted leading fields default to every value, omitted trailing fields default to zero.

Expression Meaning
*-*-* 02:30:00 daily at 02:30
Mon..Fri 07:00 weekdays at 07:00
*-*-01 04:00:00 first day of each month at 04:00
*-*-* *:0/15 every 15 minutes
Sat *-*-1..7 03:00:00 first Saturday of the month at 03:00
daily, weekly, monthly shorthand aliases, all at 00:00

Never guess an expression. systemd-analyze parses it with the same code the timer uses:

Console
$ systemd-analyze calendar "Mon..Fri 07:00"
  Original form: Mon..Fri 07:00
Normalized form: Mon..Fri *-*-* 07:00:00
    Next elapse: Tue 2026-08-18 07:00:00 CEST
       (in UTC): Tue 2026-08-18 05:00:00 UTC
       From now: 14h left

An invalid expression produces Failed to parse calendar specification instead of a next elapse, which is the check you want in a deployment pipeline.

Enable and start the timer

Reload the unit files, then enable the timer. Enabling the service instead of the timer is the single most common mistake in this workflow:

Console
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now backup.timer
Created symlink /etc/systemd/system/timers.target.wants/backup.timer -> /etc/systemd/system/backup.timer

--now arms the timer immediately so you do not have to reboot. The symlink under timers.target.wants/ is what makes it survive a restart.

Verify the timer

Check the schedule and the last result. systemctl list-timers shows the next and previous activation for every armed timer:

Console
$ systemctl list-timers backup.timer
NEXT                        LEFT     LAST                        PASSED  UNIT         ACTIVATES
Tue 2026-08-18 02:34:12 CEST 11h left Mon 2026-08-17 02:31:47 CEST 12h ago backup.timer backup.service

A NEXT column showing n/a means the timer is loaded but not armed, usually because it was started without being enabled or because every OnCalendar value lies in the past.

The job output goes to the journal, tagged with the service unit:

Console
$ journalctl -u backup.service --since today --no-pager
$ systemctl show backup.timer --property=NextElapseUSecRealtime,Persistent

The following diagram shows how one activation resolves, including the catch-up path that cron does not have:

graph TD
    A["timers.target pulls in backup.timer"] --> B{"OnCalendar time reached?"}
    B -- no --> C["Timer waits, next elapse visible in list-timers"]
    C --> B
    B -- yes --> D["backup.service starts, Type=oneshot"]
    D --> E{"Exit code 0?"}
    E -- yes --> F["Journal records success, timer rearms"]
    E -- no --> G["Unit enters failed state, OnFailure handler runs"]
    H["Host was powered off at trigger time"] --> I{"Persistent=true?"}
    I -- yes --> D
    I -- no --> C

Migrate an existing crontab entry

List what is currently scheduled before you change anything:

Console
$ crontab -l
30 2 * * * /usr/local/sbin/backup.sh >> /var/log/backup.log 2>&1
$ sudo ls /etc/cron.d /etc/cron.daily

The five cron fields map directly onto OnCalendar:

cron systemd
30 2 * * * OnCalendar=*-*-* 02:30:00
*/10 * * * * OnCalendar=*:0/10
0 3 * * 0 OnCalendar=Sun *-*-* 03:00:00
@reboot OnBootSec=5min
@daily OnCalendar=daily

The output redirection disappears. Anything the script writes to stdout and stderr lands in the journal, so drop the >> /var/log/backup.log 2>&1 part and query journalctl -u backup.service instead. If you need the run to be a fixed interval after the previous one rather than at a fixed clock time, use a monotonic timer:

ini
[Timer]
OnBootSec=10min
OnUnitActiveSec=15min

That starts the service 10 minutes after boot and then 15 minutes after each completed activation, which avoids the drift-and-overlap behaviour of */15 on a slow job.

After the timer is verified, remove the crontab line with crontab -e. Leaving both in place means the job runs twice per night.

For jobs that belong to a single user, the same units work under ~/.config/systemd/user/ with systemctl --user enable --now backup.timer. User timers only run while a session for that user exists, so enable lingering if the job must run without a login:

Console
$ sudo loginctl enable-linger alice

Troubleshooting

The timer is enabled but the service never runs. Confirm you enabled the .timer, not the .service, and that systemd-analyze verify accepts both files. A typo in OnCalendar= leaves the timer loaded with NEXT set to n/a:

Console
$ sudo systemd-analyze verify /etc/systemd/system/backup.timer
$ systemctl is-enabled backup.timer

The script works in your shell but fails in the unit. Units get a minimal environment: PATH is /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin, there is no shell profile, and no cd into a home directory. Use absolute paths inside the script, set WorkingDirectory= if needed, and reproduce the exact environment with a transient unit:

Console
$ sudo systemd-run --unit=backup-test --property=User=backup /usr/local/sbin/backup.sh
$ journalctl -u backup-test --no-pager

Missed runs pile up after a long outage. With Persistent=true a timer runs once on the next boot, not once per skipped interval. If even that single catch-up run is unwanted, for example for a job that must only run inside a maintenance window, remove Persistent=true and rely on the calendar alone.

Wrap-up

You now have a scheduled job described by two small unit files, logged in the journal, restricted to one user, and testable without waiting for the clock. Extend it with OnFailure= for alerting, CPUQuota= for noisy batch work, and systemd-analyze verify as a pre-deployment check in your configuration management. Before you decommission the crontab, run systemctl list-timers --all once and confirm every migrated job appears exactly once.

Jetzt 200 € Guthaben sichern

Testen Sie Ihr Setup auf ccloud³

Registrieren Sie sich in der ccloud³ und erhalten Sie 200 € Startguthaben für Ihr Projekt – z. B. für eine PostgreSQL-VM mit automatischen Backups.

Ludwig Technische Redaktion

Schreibt bei centron über Linux-Administration, Container und Datenbanken – mit Fokus auf Anleitungen, die im Betrieb tatsächlich funktionieren.

Kategorie Linux Basics
Teilen
Noch offene Fragen?

Unser Team hilft Ihnen bei Ihrem konkreten Setup weiter – von Menschen, die die Plattform selbst betreiben.

War dieses Tutorial hilfreich?

Ihre Antwort wird anonym gespeichert und hilft uns, die Tutorials zu verbessern.

Kommentare

Noch keine Kommentare – stellen Sie die erste Frage zu diesem Tutorial.

Zum Kommentieren anmelden

Kommentare stehen centron-Kunden offen. Melden Sie sich in Ihrem Konto an, um eine Frage zu diesem Tutorial zu stellen.

Weiterlesen

Das könnte Sie auch interessieren

Jetzt kostenlos anfangen

Melden Sie sich an und erhalten Sie in den ersten 60 Tagen ein Guthaben von 200 € bei centron.

Dieses Werbeangebot gilt nur für neue Konten. Angebot ausschließlich für Gewerbetreibende.

Jetzt loslegen Sales kontaktieren