Skip to content
Go back

Systemd Timers vs Cron: Scheduling That Doesn't Suck

By SumGuy 6 min read
Systemd Timers vs Cron: Scheduling That Doesn't Suck

The Cron Problem Nobody Talks About

You’ve got a cron job. It’s been running for three years. You haven’t checked it once. That’s either the best or worst situation you could be in — and you won’t know which until something breaks.

Here’s the thing: cron is old. Not “vintage” old. Not “still good” old. It’s Ken Thompson-in-1974 old. And while it’s survived this long because it works, it also survived this long by doing one thing and refusing to do anything else. No logging unless you set it up. No dependencies between jobs. No way to catch up if the system was offline. No way to test a cron expression without waiting for it to run.

You could stare at a cron expression like 0 2 * * 0 /usr/local/bin/backup.sh and still not be 100% sure when it runs. (2 AM every Sunday, by the way. Your 2 AM self will appreciate the comment explaining that.)

Systemd timers are what we built to fix all this. They’re not going away. Every modern Linux distro has them. And if you’re still only using cron, you’re leaving a lot of operational sanity on the table.

Why This Matters (Beyond Hipster Points)

Systemd timers give you:

This isn’t theology. This is “3 AM your monitoring system finds the job didn’t run and you have the logs right there” vs “3 AM you’re SSH’d in running tail -f /var/log/syslog hoping you catch the grep.”

Anatomy of a Systemd Timer

A systemd timer is two files: a .timer unit and a .service unit. They work together like a tag team.

Let’s say you want to back up your database every day at 3 AM.

First, the service file — the actual job:

/etc/systemd/system/backup-db.service
[Unit]
Description=Daily PostgreSQL Backup
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/local/bin/backup-db.sh
StandardOutput=journal
StandardError=journal

Type=oneshot means “run once and exit” (unlike services that stay running). Everything goes to journal so you can grep it later.

Now the timer:

/etc/systemd/system/backup-db.timer
[Unit]
Description=Daily PostgreSQL Backup Timer
Requires=backup-db.service
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target

That’s it. OnCalendar is the schedule. Persistent=true means “if we were offline, run it as soon as we boot.”

Enable and start:

Terminal window
$ sudo systemctl enable backup-db.timer
Created symlink /etc/systemd/system/timers.target.wants/backup-db.timer /etc/systemd/system/backup-db.timer.
$ sudo systemctl start backup-db.timer
$ sudo systemctl list-timers
NEXT LEFT LAST PASSED UNIT ACTIVATES
Mon 2026-04-14 03:00:00 UTC 10h left Sun 2026-04-13 03:00:13 UTC 52min ago backup-db.timer backup-db.service

See? You know exactly when it runs next. And the logs:

Terminal window
$ sudo journalctl -u backup-db.service -n 10
Apr 13 03:00:13 prodbox systemd[1]: Starting Daily PostgreSQL Backup...
Apr 13 03:00:13 prodbox backup-db.sh[4521]: Backup started: /srv/backups/db-2026-04-13.sql.gz
Apr 13 03:00:45 prodbox backup-db.sh[4521]: Backup complete: 2.3 GB
Apr 13 03:00:45 prodbox systemd[1]: Finished Daily PostgreSQL Backup.

No wondering. No digging. The truth is right there.

OnCalendar: The Schedule Syntax

OnCalendar is the field that replaces cron. The format is day-of-week year-month-day hour:minute:second.

Common examples:

# Every day at 3 AM
OnCalendar=*-*-* 03:00:00
# Every Monday at noon
OnCalendar=Mon *-*-* 12:00:00
# Every 15th of the month at 2:30 PM
OnCalendar=*-*-15 14:30:00
# First Monday of every month at 9 AM
OnCalendar=Mon *-*-1..7 09:00:00
# Every 6 hours (use OnUnitActiveSec for this instead, see below)

Here’s the magic: test it without waiting:

Terminal window
$ systemd-analyze calendar '*-*-* 03:00:00'
Original form: *-*-* 03:00:00
Normalized form: *-*-* 03:00:00
Next elapse: Tue 2026-04-14 03:00:00 UTC
(in 10h 24min 12s)

You can instantly validate the schedule. No guessing.

For relative timers (every N seconds/minutes/hours), use OnBootSec or OnUnitActiveSec:

[Timer]
# Run 5 minutes after boot
OnBootSec=5min
# Run every hour (300 seconds after the last run)
OnUnitActiveSec=1h

Persistent Timers and Missed Runs

Say your server rebooted during the 3 AM backup. With cron, that backup just… doesn’t happen. Nobody notices until Thursday when your restore test fails.

With Persistent=true:

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

Systemd tracks the last run time. On boot, if a scheduled time was missed, it runs the job immediately. Your backup catches up.

Real-World Example: Certificate Renewal

Here’s a practical one — renewing ACME certificates (without using a renewal daemon):

/etc/systemd/system/renew-certs.service
[Unit]
Description=Renew ACME Certificates
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/acme/renew.sh
StandardOutput=journal
StandardError=journal
OnFailure=notify-admins@%n.service

The timer:

/etc/systemd/system/renew-certs.timer
[Unit]
Description=Renew ACME Certificates Weekly
Requires=renew-certs.service
[Timer]
OnCalendar=Sun *-*-* 02:00:00
Persistent=true
RandomizedDelaySec=1h
[Install]
WantedBy=timers.target

RandomizedDelaySec=1h spreads out the load if you’re running this on 1000 servers — they won’t all hammer your ACME server at 2 AM.

The OnFailure= handler (in the service) can send you a Slack message if it breaks.

When Cron Is Still Fine

Systemd timers aren’t a cult. Cron is still good for:

If your job is literally 0 1 * * 6 root rm -rf /tmp/old-* and you’ve never thought about it since 2015, leave it. The oxygen spent migrating it to systemd is better spent elsewhere.

But if your job is production infrastructure? Cron is a liability. Its silence is a feature until the moment it’s a catastrophe.

Converting Cron to Systemd: Step by Step

Say you’ve got:

0 2 * * * /usr/local/bin/clean-logs.sh
  1. Create the service file (use the script as-is):
/etc/systemd/system/clean-logs.service
[Unit]
Description=Clean Old Logs
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/clean-logs.sh
StandardOutput=journal
StandardError=journal
  1. Create the timer:
/etc/systemd/system/clean-logs.timer
[Unit]
Description=Clean Old Logs Daily
Requires=clean-logs.service
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target
  1. Remove the cron entry: crontab -e, delete the line.

  2. Enable and start:

Terminal window
sudo systemctl daemon-reload
sudo systemctl enable clean-logs.timer
sudo systemctl start clean-logs.timer
  1. Test:
Terminal window
sudo systemctl start clean-logs.service
sudo journalctl -u clean-logs.service -f

Done. Now you’ve got logging, dependency ordering, persistent runs, and a way to test everything. Your future self at 3 AM will send thank-you notes.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
LangGraph vs CrewAI vs AutoGen: AI Agent Frameworks for Mere Mortals
Next Post
Whisper & Faster-Whisper: Self-Hosted Speech-to-Text That Actually Works

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts