Why I Switched Scheduled Jobs From cron to systemd Timers

A cron job failed for eleven days before anyone noticed. It emailed its error to a local mailbox nobody read, on a host with no MTA configured, so the output went nowhere. That was the morning I started moving scheduled work off cron and onto systemd timers.

This is the honest version, not the evangelism. cron is not broken. For a personal script that runs every night and doesn’t matter if it slips, a one-line crontab is still the right tool and I still use it. But on a server that other people depend on, a scheduled job is a small production system: it needs to be observable, it needs to recover from downtime, it needs to run in the right order, and it should not run as an unconstrained root process with the whole filesystem in reach. cron gives you none of that. systemd, which is already running on every mainstream Linux distribution, gives you all of it — at the cost of turning one line into two files.

The silent failure problem

cron’s model of observability is email. When a job writes to stdout or stderr, cron tries to post it to the user’s local mailbox. On a modern server with no mail transfer agent, that output is discarded. The exit code is never recorded anywhere you can query. There is no history, no retention, no way to ask “did last night’s run succeed?” after the fact. You find out a cron job has been failing when the thing it was supposed to produce is missing.

Free · 4 minutes

Is your engineering team shipping safely, or quietly accumulating risk?

Fourteen questions on how work gets from idea to production — cadence, testing, rollback, and the key-person risk in your delivery. Banded finding on screen, full sheet by email.

A systemd service logs to the journal automatically. Every run of the unit — its stdout, its stderr, its exit status, its start and stop timestamps — is captured by systemd-journald and tagged to that unit. You query one job’s entire history with a single command:

# Everything backup.service has ever done, newest last
journalctl -u backup.service

# Just the last run, with exit status
systemctl status backup.service

# When did each timer last fire, and when does it fire next?
systemctl list-timers --all

That last command is the one I reach for most. It shows every timer on the box, the last trigger time and the next scheduled one, in a single table. cron has no equivalent — the schedule lives in a crontab and the outcomes live nowhere. Making a scheduled job’s success or failure a queryable, retained fact is the same instinct as instrumenting delivery metrics: you cannot manage what you cannot see.

Before and after: one line becomes two files

Here is the trade laid out plainly. The cron version is one line in a crontab:

# /etc/crontab — nightly backup at 02:30
30 2 * * *  backup  /usr/local/bin/backup.sh

The systemd equivalent is a service unit that says what to run and a timer unit that says when. Save them as /etc/systemd/system/backup.service and /etc/systemd/system/backup.timer:

# backup.service
[Unit]
Description=Nightly backup
# Do not start until the network mounts are up
Requires=mnt-backups.mount
After=network-online.target mnt-backups.mount

[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/backup.sh

# Sandboxing: the job can only touch what it needs
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/mnt/backups /var/lib/backup

# Resource limits: it cannot starve the box
MemoryMax=512M
CPUQuota=40%
# backup.timer
[Unit]
Description=Run nightly backup at 02:30

[Timer]
OnCalendar=*-*-* 02:30:00
# Catch up a run missed while the host was off
Persistent=true
# Spread load if many hosts share this schedule
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

Enable it with systemctl daemon-reload && systemctl enable --now backup.timer. Yes, it is more text. Everything in that second block is a capability cron simply does not have — and each line earned its place within the first week.

Persistent catch-up, and why it needs idempotent jobs

Persistent=true is the feature that repaid the move fastest. A plain cron job that was due at 02:30 while the machine was powered off or rebooting simply never runs; the window passes and cron does not look back. With Persistent=true, systemd records the last trigger time to disk under /var/lib/systemd/timers/. When the host comes back up after missing a scheduled run, the timer fires immediately to catch up. For a backup or a reporting job on a machine that is not up around the clock, that is the difference between a gap in the record and no gap.

The catch is worth stating because it bites people: catch-up means a job can fire at an unexpected moment, immediately on boot, possibly bunched with others. If your job is not safe to run again — if a double execution double-charges, double-sends, or corrupts a half-finished state — you now have a correctness problem. The discipline is the same one I apply to any retryable operation: make the work idempotent so that running it twice is harmless. Timers make catch-up easy, which makes idempotency non-optional.

Ordering and sandboxing you would otherwise script by hand

The After= and Requires= lines solve a problem people usually paper over with sleep statements and retry loops at the top of a cron script. If a job depends on a network mount or a database being up, you declare it, and systemd will not start the service until those units are ready. The dependency is data, not defensive code.

The sandboxing block is the part I would least want to give back. A cron job runs with the full ambient privileges of its user and the entire filesystem in reach. The systemd directives above make the root filesystem read-only for the process (ProtectSystem=strict), hide other users’ home directories (ProtectHome=true), give it a private /tmp, and confine its writes to an explicit allow-list of paths. MemoryMax and CPUQuota stop a runaway job from taking the host down with it. Writing that by hand around a shell script is possible and nobody does it; here it is six lines of configuration, kept in version control alongside the rest, exactly as you would keep any baseline config that actually runs.

Where cron is still fine

I have not deleted crontab from my muscle memory. For a throwaday task on your own laptop, or a job whose failure is immediately visible because you would notice the missing output the same day, cron’s brevity wins and the two-file ceremony is overhead you do not need. The line I draw is dependency: the moment a scheduled job matters to someone other than me — the moment its silent failure would be discovered late and cost something — it belongs in a timer, where its history is in the journal, its downtime is caught up, its ordering is declared, and its blast radius is bounded. cron will run your job. systemd will let you prove it ran.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Can you trust the architecture you have?

Architecture diagrams rarely show the reality of how systems actually operate. An independent review establishes what is really there.