Guide to Systemd Timers: Replacing Cron Jobs for Reliable Scheduling
Use systemd timers instead of cron when you want journal logs, missed-run handling, dependencies, and resource controls.
Guide to Systemd Timers: Replacing Cron Jobs for Reliable Scheduling
cron is still fine for a lot of jobs. If you need to run a shell script every night and you already have working log redirection, there is no prize for replacing it. The reason many teams move scheduled Linux work to systemd timers is not fashion. It is because systemd timers give the job a real service unit, predictable logs, dependency handling, missed-run behavior, and resource limits.
That matters when the task is not just "run a command." A backup job may need a mounted disk. A cache warmer may need the network to be usable. A report exporter may need to run as a locked-down service account and leave readable logs for the next person on call. A systemd timer lets you describe those needs in the same place you manage the rest of the service lifecycle.
Understanding Systemd Timers
systemd timers are systemd unit files that control when other systemd units, typically service units, are activated. Unlike cron, which is a standalone daemon, systemd timers are an integral part of the systemd init system. This deep integration brings several significant benefits, especially concerning reliability, logging, and resource management.
A systemd timer always works in conjunction with another unit, most commonly a service unit. The .timer file defines when an event should occur, and the corresponding .service file defines what action should be performed when that event is triggered. This clear separation of concerns makes systemd timers highly modular and flexible.
Key Advantages of Systemd Timers Over Cron
While cron is functional, systemd timers address many of its limitations, offering a more robust and feature-rich scheduling solution:
- Reliability and Persistence: If a calendar timer uses
Persistent=trueand the system is powered off during a scheduled run, systemd records that the run was missed and starts the associated service after the next boot. Plain cron usually does not catch up without a separate tool such as anacron. - Integration with
systemd: Timers benefit fromsystemd's powerful logging (viajournalctl), dependency management, and resource control (cgroups). This means better monitoring, clearer error reporting, and the ability to define complex start-up sequences or resource limits for scheduled tasks. - Reproducibility and Version Control:
systemdunit files are plain text files that can be easily stored in version control systems. This allows for reproducible deployments and easier tracking of changes to scheduled tasks across multiple systems. - Event-based Scheduling: Beyond simple time-based scheduling,
systemdtimers can be triggered relative to system boot (OnBootSec) or after the last activation of a unit (OnUnitActiveSec), providing more dynamic scheduling options. - Flexible Time Expressions:
systemdoffers a rich set of calendar event expressions, often more readable and versatile thancron's syntax, including hourly, daily, weekly, and specific dates/times. - Resource Management and Dependencies:
systemdservices launched by timers inherit thesystemdenvironment, including cgroup settings, and can declare dependencies on othersystemdunits (e.g., waiting for network or a database to be available before running). - Standard Output/Error Handling:
systemdautomatically capturesstdoutandstderrof services launched by timers and directs them to the system journal, making debugging and auditing much simpler than withcron's email-based output or manual redirection.
Configuring Systemd Timers
Configuring a systemd timer involves creating two unit files: a service unit (.service) and a timer unit (.timer). These files are typically placed in /etc/systemd/system/ for system-wide timers or ~/.config/systemd/user/ for user-specific timers.
1. The Service Unit (.service file)
The service unit defines the actual command or script to be executed. It's a standard systemd service file, but often designed to be run non-interactively and to perform a specific task.
Example: /etc/systemd/system/mytask.service
[Unit]
Description=My Scheduled Task Service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/mytask.sh
User=myuser
Group=mygroup
# Optional: Limit resources on newer systemd releases
# CPUWeight=50
# MemoryMax=1G
[Install]
WantedBy=multi-user.target
Explanation:
[Unit]: Contains generic information about the unit.Description: A human-readable description.
[Service]: Defines the service-specific configuration.Type=oneshot: Indicates that the service runs a single command and then exits. This is common for scheduled tasks.ExecStart: The command or script to execute. Provide the full path.User,Group: Defines the user and group under which the command will run. Always run tasks with the least privileges necessary.CPUWeight,MemoryMax: Optional cgroup controls. They are useful when a scheduled job should not starve the rest of the host.
[Install]: Defines how the unit should be enabled.WantedBy=multi-user.target: While present, this section is often less critical for timer-triggered services as the timer unit itself usually determines the activation. However, it can be useful if you also want the service to be manually activatable or to integrate into othersystemdtargets.
2. The Timer Unit (.timer file)
The timer unit defines when the corresponding service unit should be activated. It must have the same name as its service counterpart (e.g., mytask.timer for mytask.service).
Example: /etc/systemd/system/mytask.timer
[Unit]
Description=Runs mytask.service daily
[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=600
AccuracySec=1min
[Install]
WantedBy=timers.target
Explanation:
[Unit]: Generic information.Description: A description for the timer.
[Timer]: Defines the timer-specific configuration.OnCalendar: The most common setting, defining a calendar event. It uses expressions like:daily: Every day at midnight.weekly: Every Monday at midnight.monthly: On the first day of every month at midnight.hourly: Every hour on the minute.*-*-* 03:00:00: Every day at 3:00 AM.Mon..Fri 08:00..17:00: Weekdays between 8 AM and 5 PM.Mon *-*-* 03:00:00: Every Monday at 3 AM.
OnBootSec: Activates the service after a specified time from system boot. E.g.,OnBootSec=10min.OnUnitActiveSec: Activates the service after a specified time from the last activation of the service. E.g.,OnUnitActiveSec=1hto run hourly after the previous run completes.Persistent=true: Crucial for reliability. If the system is off during a scheduled run, the service will be triggered shortly after the next boot.RandomizedDelaySec=600: Adds a random delay of up to 600 seconds. This is useful when many machines share the same timer and you do not want every host to hit a database, API, or backup server at exactly the same second.
A Real Cron-to-Timer Migration
Suppose you currently have this root cron entry:
15 2 * * * /usr/local/sbin/backup-app.sh >> /var/log/backup-app.log 2>&1
It works on a quiet machine, but it has the usual weak spots. If the backup disk is not mounted, the script may fail halfway through. If the server is off at 2:15 AM, the run is skipped. If the script writes a useful error, someone has to remember which custom log file to check. If the script starts using too much memory, cron will not help you contain it.
The systemd version separates the command from the schedule:
# /etc/systemd/system/backup-app.service
[Unit]
Description=Back up application data
RequiresMountsFor=/mnt/backups
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=backup
Group=backup
WorkingDirectory=/srv/app
ExecStart=/usr/local/sbin/backup-app.sh
MemoryMax=1G
CPUWeight=40
Nice=10
# /etc/systemd/system/backup-app.timer
[Unit]
Description=Run application backup every night
[Timer]
OnCalendar=*-*-* 02:15:00
Persistent=true
RandomizedDelaySec=15min
AccuracySec=1min
Unit=backup-app.service
[Install]
WantedBy=timers.target
There are a few details worth noticing. RequiresMountsFor=/mnt/backups tells systemd that the path must be mounted before the service starts. After=network-online.target and Wants=network-online.target are useful only if your network manager actually provides a wait-online service; on many distributions that service is disabled by default. If the backup only writes to a local disk, leave the network dependency out.
Type=oneshot fits scripts that do their work and exit. Do not use it for a daemon that stays running. WorkingDirectory= saves you from scripts that accidentally depend on being launched from a shell in a particular directory. User=backup is usually better than running the job as root and hoping every command inside the script is careful.
After saving the files:
sudo systemctl daemon-reload
sudo systemctl enable --now backup-app.timer
systemctl list-timers backup-app.timer
To test the job immediately, start the service, not the timer:
sudo systemctl start backup-app.service
journalctl -u backup-app.service -n 100 --no-pager
That one distinction prevents a lot of confusion. Starting backup-app.timer arms the schedule. Starting backup-app.service runs the actual backup.
Choosing the Right Timer Expression
OnCalendar= is the closest replacement for cron syntax, but it reads differently. You can check what systemd thinks an expression means before you ship it:
systemd-analyze calendar 'Mon..Fri 03:30'
systemd-analyze calendar '*-*-01 04:00:00'
systemd-analyze calendar 'Sun *-*-* 23:00:00'
Use calendar timers for wall-clock work: nightly backups, weekly reports, monthly cleanup, certificate checks, and other tasks where the human calendar matters. Use monotonic timers for "run after something happened" behavior:
[Timer]
OnBootSec=10min
OnUnitActiveSec=1h
That pattern starts the service ten minutes after boot, then again one hour after the last activation. It is a good fit for polling, local cleanup, and lightweight maintenance loops. It is not the same as "at minute zero of every hour." If the job takes twelve minutes, the next run is counted from the activation timing, not from your wall clock expectation.
Also think about overlap. For a normal service unit, systemd will not start a second copy of the same active unit just because the next timer event arrived. If your job can run longer than its interval, decide whether that is acceptable. Sometimes the right answer is a lock in the script, such as flock, because it can produce a clear "previous run still active" message. Sometimes the right answer is to lengthen the interval.
Operational Habits That Save Time
The timer view is your first dashboard:
systemctl list-timers --all
It shows the last run, the next run, and the unit each timer activates. If the timer is listed but the service never runs, check the calendar expression and whether the timer is enabled. If the service runs and fails, ignore the timer for a moment and inspect the service:
systemctl status backup-app.service
journalctl -u backup-app.service --since today
When you edit either unit file, run:
sudo systemctl daemon-reload
sudo systemctl restart backup-app.timer
Restarting the timer after schedule changes is a good habit because it makes the next activation time refresh immediately. If you only changed the script itself, you usually do not need daemon-reload.
For user timers, use systemctl --user and place units under ~/.config/systemd/user/. They are useful for developer workstations and per-user automation, but they have one important catch: by default, user services are tied to the user's login session. If you need a user timer to keep running after logout, enable lingering with loginctl enable-linger username. That is a deliberate administrative choice, not something to hide inside the article as a magic fix.
When Cron Is Still the Better Tool
Do not move everything blindly. Cron is easier to read for tiny user-local tasks, especially on older servers or minimal containers where systemd is not PID 1. If your only requirement is "run this harmless command every five minutes," cron may be the clearest answer.
Systemd timers pay off when the job has service-like needs: controlled identity, logs in the journal, resource limits, dependencies, catch-up behavior, or standard deployment through unit files. In practice, I reach for timers when the scheduled task would wake someone up if it failed. The extra unit file is worth it when it gives the next operator a direct path from "what ran?" to "what failed?" to "what changed?"
One final habit is worth adopting during migrations: keep the old cron entry commented nearby only until the timer has run successfully a few times, then remove it. Duplicate schedules are a quiet source of damage. Two backup jobs can compete for the same lock, two cleanup jobs can delete files earlier than expected, and two report jobs can send duplicate emails. After enabling the timer, check systemctl list-timers --all, confirm the service journal, and make sure the old cron path is no longer active.