Your cron job ran at 2 AM, but the server was rebooted at 1:59 AM for a kernel update. Nothing ran. No alert. No log. You find out three days later when a backup directory is suspiciously empty. If that sounds familiar, you've already met the exact problem systemd timers solve.
I've watched this exact scenario play out on production RHEL and Ubuntu servers more times than I'd like to admit. The fix isn't complicated once you know the tool. Systemd timers give you proper logging, missed-run recovery, dependency management, and better schedule control — without needing to install anything extra on modern Linux systems.
This guide covers everything from writing your first timer pair to converting real cron jobs, with distro-specific notes for Ubuntu 24.04 and Rocky Linux 9 / RHEL 9.
Note:
If you're still using cron and want to understand how it works first before making the switch, check out the cron command guide with examples on LinuxTeck. It'll give you the context that makes the comparison in this article click faster.
Examples
What Are systemd Timers?
Think of a systemd timer as a two-part replacement for a cron entry. Where cron mashes the "what to run" and "when to run it" into a single line, systemd splits those into two separate unit files: a .service file that defines the task, and a .timer file that defines the schedule.
The .service file is the same kind you'd use for a long-running daemon, just set to Type=oneshot so systemd knows the task runs once and exits. The .timer file points at that service and tells systemd when to fire it.
Both files live in /etc/systemd/system/ and share the same base name. So backup.timer automatically pairs with backup.service unless you explicitly specify otherwise. That naming convention is worth remembering because it avoids a lot of confusion when you're managing multiple timers.
From a practical workflow perspective: you write a script, wrap it in a service unit, schedule it with a timer unit, enable the timer, and you're done. The whole thing integrates natively with journald, so every run produces a log entry you can pull up with journalctl — no mailbox required.
Timer File Syntax and Structure
A minimal systemd timer consists of two files. Here's the basic structure of each:
The service file — defines what runs:
LinuxTeck.com
[Unit]
Description=My scheduled task
After=network.target
[Service]
Type=oneshot
ExecStart=/opt/scripts/mytask.sh
[Install]
WantedBy=multi-user.target
The timer file — defines when it runs:
LinuxTeck.com
[Unit]
Description=Run mytask every day at 2:30 AM
Requires=mytask.service
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target
Breaking down the key fields: Type=oneshot tells systemd this service runs once and exits — not a persistent daemon. OnCalendar takes a date-time expression in the format YYYY-MM-DD HH:MM:SS, using * as a wildcard. Persistent=true is the field that makes systemd run the timer immediately on next boot if a scheduled run was missed. WantedBy=timers.target ensures the timer starts automatically at boot when enabled.
Note:
By default, systemd adds a randomized delay of up to 1 minute to timer triggers. This prevents multiple timers from firing simultaneously at the same second. If exact timing matters for your task, add AccuracySec=1s to the [Timer] section to reduce that jitter to 1 second.
Key Timer Directives and Options
| Directive | What It Does | When to Use It |
|---|---|---|
| OnCalendar= | Wall-clock schedule like cron (daily, weekly, or full date-time expression) | Backups, log rotation, cert renewal, reports |
| OnBootSec= | Trigger N seconds/minutes after system boot | Post-boot health checks, cache warming |
| OnUnitActiveSec= | Trigger N time after the timer last activated | Repeating tasks relative to last run, not wall clock |
| OnUnitInactiveSec= | Trigger N time after the service last finished | Cool-down between runs, rate limiting tasks |
| Persistent=true | Runs missed triggers after reboot | Any critical task that must not be silently skipped |
| AccuracySec= | Controls how precisely the trigger time is honored | Use 1s or 1us when exact timing is required |
| RandomizedDelaySec= | Adds a random delay up to the specified value | Staggering multiple timers on the same schedule |
| Unit= | Explicitly specify which service to trigger | When the timer and service names don't match |
Practical Examples: From Beginner to Real-World
I. Enable and Start Your First Timer
After creating both unit files, you need to reload systemd and start the timer. Notice that you enable and start the timer, not the service.
LinuxTeck.com
sudo systemctl enable --now mytask.timer
Tip:
The --now flag combines enabling and starting in one command. Without it, the timer enables for next boot but does not activate immediately.
II. List All Active Timers
This is the first command to run when you want to know what's scheduled and when it last ran or will run next.
LinuxTeck.com
Tue 2026-05-26 02:30:00 IST 13h left Mon 2026-05-25 02:30:01 IST 10h ago mytask.timer mytask.service
Mon 2026-05-26 00:00:00 IST 11h left Sun 2026-05-25 00:00:01 IST 12h ago logrotate.timer logrotate.service
Mon 2026-05-26 00:00:00 IST 11h left Sun 2026-05-25 00:00:23 IST 12h ago man-db.timer man-db.service
3 timers listed.
III. Check Timer Status and Last Run Details
Run this when you need to confirm a timer is active, or check why a specific run didn't behave as expected.
LinuxTeck.com
Loaded: loaded (/etc/systemd/system/mytask.timer; enabled; preset: disabled)
Active: active (waiting) since Sun 2026-05-25 02:30:01 IST; 10h ago
Trigger: Mon 2026-05-26 02:30:00 IST; 13h left
Triggers: ● mytask.service
IV. View Task Logs with journalctl
This is the biggest day-to-day advantage over cron. Every run is logged to the journal automatically, with timestamps, exit codes, and full stdout/stderr output.
LinuxTeck.com
journalctl -u mytask.service -S today
# Show logs for a date range
journalctl -u mytask.service --since "2026-05-24" --until "2026-05-26"
May 25 02:30:01 myserver mytask.sh[14432]: Backup started: /var/backups/db_2026-05-25.tar.gz
May 25 02:30:04 myserver mytask.sh[14432]: Backup complete. Size: 284M
May 25 02:30:04 myserver systemd[1]: mytask.service: Succeeded.
May 25 02:30:04 myserver systemd[1]: Finished My scheduled task.
V. Validate a Calendar Expression Before Using It
Before putting a complex schedule into a timer file, test it with systemd-analyze calendar to confirm it does what you think it does. This saves a lot of troubleshooting later.
LinuxTeck.com
systemd-analyze calendar "Mon..Fri *-*-* 08:00:00"
# See next 5 occurrences of 1st and 15th of each month at 3 AM
systemd-analyze calendar --iterations=5 "*-*-1,15 03:00:00"
Normalized form: Mon..Fri *-*-* 08:00:00
Next elapse: Mon 2026-05-26 08:00:00 IST
(in UTC): Mon 2026-05-26 02:30:00 UTC
From now: 1 day 45min left
Tip:
Always run systemd-analyze calendar on complex expressions before writing them into a timer file. The "Next elapse" and "From now" fields confirm exactly when the timer will fire, so there are no surprises in production.
VI. Create a Boot-Triggered Timer (Monotonic)
Use OnBootSec when you want a task to run a set time after every boot, regardless of the clock. This is useful for post-boot health checks, warming caches, or syncing configs after startup.
LinuxTeck.com
[Timer]
OnBootSec=5min
OnUnitActiveSec=1h
Note:
Monotonic timers like OnBootSec and OnUnitActiveSec reset on every boot. They are not persistent across reboots the way OnCalendar with Persistent=true is. Use them for tasks that should run relative to boot or the previous run, not at a specific wall-clock time.
VII. Real-World: Daily Database Backup Timer
This is the full two-file setup for a daily PostgreSQL dump at 1:30 AM. The Persistent=true line means if the server was down at 1:30 AM, the backup runs at the next boot instead of being silently skipped.
LinuxTeck.com
[Unit]
Description=PostgreSQL Daily Backup
Wants=pg-backup.timer
After=postgresql.service
[Service]
Type=oneshot
User=postgres
ExecStart=/opt/scripts/pg-backup.sh
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
LinuxTeck.com
[Unit]
Description=Run PostgreSQL backup daily at 01:30 AM
Requires=pg-backup.service
[Timer]
OnCalendar=*-*-* 01:30:00
Persistent=true
AccuracySec=1m
[Install]
WantedBy=timers.target
The After=postgresql.service line in the service unit is the dependency chain in action. It tells systemd not to fire the backup until PostgreSQL is confirmed running. Cron has no native way to express this.
VIII. Real-World: SSL Certificate Renewal with OnCalendar
Running Certbot via a systemd timer is cleaner than a cron job because you get full logging and Persistent=true ensures renewal still happens even if the server was off on the scheduled day.
LinuxTeck.com
[Unit]
Description=Renew SSL certificates twice daily
[Timer]
OnCalendar=*-*-* 00,12:00:00
RandomizedDelaySec=3600
Persistent=true
[Install]
WantedBy=timers.target
IX. Distro-Specific: Ubuntu 24.04 Setup
On Ubuntu 24.04, systemd is the default init system. No extra packages needed. The only thing worth noting is that user-level timers (scoped to a login session) go in ~/.config/systemd/user/ and require --user flags.
LinuxTeck.com
sudo systemctl daemon-reload
sudo systemctl enable --now mytask.timer
# User-level timer (runs as your user, only while logged in)
mkdir -p ~/.config/systemd/user/
# place unit files there, then:
systemctl --user daemon-reload
systemctl --user enable --now mytask.timer
# To make user timers survive logout:
loginctl enable-linger $USER
X. Distro-Specific: Rocky Linux 9 / RHEL 9 Setup
On Rocky Linux 9 and RHEL 9, the workflow is identical to Ubuntu for system-level timers. The main difference is SELinux. If your script touches files outside its expected context, SELinux may silently block it. Check the audit log if a timer runs but produces no expected output.
LinuxTeck.com
sudo systemctl daemon-reload
sudo systemctl enable --now mytask.timer
# Check SELinux denials if timer is silent
sudo ausearch -m avc -ts recent
# Verify timer is running
systemctl status mytask.timer
systemctl list-timers | grep mytask
XI. The Common Mistake: Enabling the Service Instead of the Timer
This one trips up almost everyone on the first run. You write the files, then run systemctl enable mytask.service. The service gets enabled but the timer never starts, so nothing runs on schedule.
Warning:
Never enable the .service file when using timers. Enable and start only the .timer file. The timer is responsible for activating the service on schedule.
LinuxTeck.com
sudo systemctl enable mytask.service
# CORRECT - always enable the timer
sudo systemctl enable --now mytask.timer
# Confirm the timer is active and has a next trigger time
systemctl list-timers | grep mytask
Mon 2026-05-26 01:30:00 IST 14h left Sun 2026-05-25 01:30:01 IST 11h ago mytask.timer mytask.service
XII. Manually Run a Timer-Controlled Service for Testing
You don't need to wait for the schedule to test your service. Trigger it manually at any time with systemctl start on the service unit directly. The timer keeps running on its own schedule regardless.
LinuxTeck.com
sudo systemctl start mytask.service
# Check what happened immediately after
journalctl -u mytask.service -n 20
May 25 13:45:02 myserver mytask.sh[17821]: Task started
May 25 13:45:03 myserver mytask.sh[17821]: Task completed successfully
May 25 13:45:03 myserver systemd[1]: mytask.service: Succeeded.
May 25 13:45:03 myserver systemd[1]: Finished My scheduled task.
Why systemd Timers Change How You Work
The practical difference between cron and systemd timers only becomes obvious when something goes wrong. With cron, a failed job at 3 AM produces nothing visible unless you've wired up a separate logging solution or the system's mail daemon is configured and someone checks it. With systemd timers, you run journalctl -u mytask.service and see exactly what happened, when, what the script printed, and what the exit code was. For a sysadmin managing 20 scheduled tasks across production servers, that difference in observability is significant.
The automation workflow also shifts. Because each timer-driven task is a full systemd service, you can control its resource limits, set it to run as a specific user, specify dependencies on other services, and restart it on failure — all using standard systemd directives. A cron job that kills the server because it consumed unbounded CPU is a real scenario. A systemd service with CPUQuota=25% and MemoryLimit=512M in the [Service] block will never do that.
In enterprise environments running RHEL 9 or Rocky Linux 9, the audit trail from journald also matters for compliance. Log retention, structured output, and centralized logging via journald remote forwarding are all things you get for free when tasks run through systemd. Cron output forwarded through email to a root mailbox doesn't meet the same bar. You can read more about this approach in the official systemd.timer documentation, which covers every directive in detail.
None of this means cron is wrong. For a simple one-liner that runs at 4 AM on a personal server, cron is fine. But for any task where you care about reliability, logging, dependencies, or resource control, systemd timers are the better choice on modern Linux systems.
Key Takeaways
- Always enable and start the
.timerfile, never the.servicefile. The timer is what controls the schedule — the service just defines what runs. - Add
Persistent=trueto any timer where a missed run would cause a real problem. Without it, a server reboot at the wrong time silently skips the job. - Use
systemd-analyze calendar "your expression"before writing anyOnCalendarvalue into a unit file. The tool shows you the next five trigger times so there are no surprises in production. - The
After=directive in the service file creates a dependency chain. Use it when your task depends on another service being active first — such as a backup script that requires the database to be running. - Check logs with
journalctl -u yourservice.service -S todayafter any run. You get timestamped stdout, stderr, exit codes, and run duration all in one place. - User-level timers on Ubuntu go in
~/.config/systemd/user/and needloginctl enable-linger $USERto survive logout. Without linger, they stop when you disconnect. - On Rocky Linux 9 and RHEL 9, SELinux can silently block a timer-triggered script if the script accesses paths outside its expected context. Check
ausearch -m avc -ts recentfirst when a timer runs but produces no output.
FAQ
My timer shows "active (waiting)" but the service never ran. What do I check first?
Run systemctl list-timers | grep yourtimer and confirm the "NEXT" column shows a future time. If it's blank or shows "n/a", the timer isn't properly configured. Then run journalctl -u yourservice.service -S today to see if the service was attempted at all. On Rocky Linux or RHEL, also check ausearch -m avc -ts recent for SELinux denials — these are silent and easy to miss.
I added Persistent=true but the missed run still didn't execute after boot. Why?
Two common reasons. First, make sure you ran systemctl daemon-reload after editing the timer file — systemd won't pick up changes otherwise. Second, confirm the timer is actually enabled: systemctl is-enabled yourtimer.timer should return "enabled". A timer that's started but not enabled won't survive a reboot, and Persistent=true only works on enabled timers.
Can I use systemd timers to run a script every 5 minutes during business hours only?
Not with a single OnCalendar line. Systemd's calendar expressions don't support "every N minutes within a time window" natively. The most practical solution is to use OnCalendar=Mon..Fri *-*-* 08..17:0/5:00, which fires every 5 minutes from 08:00 to 17:55 on weekdays. Alternatively, keep the timer simple and put the time-window logic inside the script itself using an if check against $(date +%H).
How do I see the output of my script when it ran via a timer?
Use journalctl -u yourservice.service. All stdout and stderr from the script is captured automatically by journald. Add -S today to filter to today's runs, or -n 50 to see the last 50 log lines. If your script produces a lot of output and you want to reduce journal noise, pipe verbose output to logger with a tag or redirect it to a file inside the script using standard shell redirection.
Do I still need to run daemon-reload every time I edit a unit file?
Yes, every time. Systemd caches unit file contents in memory. If you edit a .service or .timer file without running systemctl daemon-reload, systemd keeps using the old version until you reload. It's one of the most common reasons a "fixed" timer still behaves like the old one. Make it a habit: edit file, run daemon-reload, restart or re-enable the unit.
What's the right way to test a timer without waiting for the scheduled time?
Run the service directly: sudo systemctl start yourtask.service. The timer continues running on its own schedule, and this manual run gives you an immediate log entry to check. You can also use systemctl start yourtask.timer to restart the timer itself and see when it calculates the next trigger. For schedule validation before deployment, systemd-analyze calendar "your expression" shows you the exact next trigger time without touching the running system.
From your first terminal command to advanced sysadmin skills - every guide here is written in plain English with real examples you can run right now.