Scheduling Linux Jobs Without Silent Failures: Cron and systemd Timers

Scheduling Linux Jobs Without Silent Failures: Cron and systemd Timers

A cron job that fails does not complain. It tries, it dies, and cron moves on to the next line as if nothing happened. That is not a bug. It is precisely what cron was built to do, and it is the reason a common backup disaster on a home server is not a corrupted archive but an empty stretch of weeks that nobody noticed. The usual shape of it is mundane. Somebody reorganises a scripts folder, the crontab still points at the old path, the job fails instantly every night, and seven weeks later they go looking for a file from the second week of May and it simply is not there. No error, no warning mail, no log line anyone read, just a quiet gap where a job used to be. If you run anything unattended on a home server or a rented box, you have two real options on a modern Linux system, and the difference between them shows up on the day something breaks.

why cron goes quiet on you

Three reasons cron jobs fail silently: output is mailed to nowhere, the PATH is minimal, and runs missed during downtime are skipped.
None of this is a bug. It is what cron was built to do.

cron’s design is from an era when every Unix box had a working mail system. When your job writes to standard output or standard error, cron mails that output to the user. On a current Debian or Ubuntu machine, there is usually no mail transfer agent installed at all, so that mail goes nowhere. Your job can fail every single night for two months and the only trace is a line in the system journal that nobody reads.

The second trap is the environment. An interactive shell gives you a rich PATH, your aliases, your exported variables. cron gives you almost nothing, typically just /usr/bin:/bin. So a script that calls docker or rsync or restic from /usr/local/bin works perfectly when you test it by hand and dies the moment cron runs it. This is the classic “but it works when I run it manually” complaint, and the usual answer is PATH. To confirm it, run env inside the scheduled job, write the output to a file and compare it against your interactive shell; using full paths to binaries in the script removes most of this class of problem.

Third, and this one matters enormously in an Indian home: cron has no memory. If the machine is powered off at 01:30 because the supply dropped and your inverter gave up, that 01:30 job does not run. It does not run later either. cron wakes up, sees it is now 04:00, and waits patiently for tomorrow. A laptop pressed into service as a server rides out most cuts on its own battery, which is one of the arguments for the arrangement in the old laptop home server guide, but a desktop or a mini PC without a UPS will just miss the slot entirely.

writing a crontab that behaves

Crontab with SHELL and an explicit PATH, a 01:30 backup wrapped in flock and logged to a file, and a 15-minute check that logs only errors.
An explicit PATH, output to a file you control, and flock so runs never overlap.

cron is still the right tool for plenty of jobs. It’s universally available, the syntax fits on one line, and for a simple recurring command it is hard to beat. You just have to write it defensively. Edit your own user’s table with crontab -e rather than dropping files into /etc/cron.d, unless the job genuinely needs to run as another user.

# Set a sane environment at the top of the crontab
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# min hour dom mon dow   command
30 1 * * * /usr/bin/flock -n /tmp/backup.lock /home/anurag/bin/backup.sh >> /home/anurag/logs/backup.log 2>&1

# Every 15 minutes, but only log real problems
*/15 * * * * /home/anurag/bin/check-services.sh >/dev/null 2>> /home/anurag/logs/checks.err

Three things in there earn their keep. Setting PATH explicitly kills the environment problem at the source. Redirecting both streams to a file you control means the output exists somewhere you can actually read, instead of being handed to a mail system that isn’t there. And flock -n refuses to start a second copy if the previous run is still going, which saves you from two backups fighting over the same target at 01:30 on a slow night.

One syntax detail that catches people: inside a crontab, the percent sign is special. If you want date +%F in a cron line, you have to write it as date +\%F. Miss the backslash and your log file gets named with a mangled timestamp, which costs an hour of interrogating date before anyone suspects the crontab.

the systemd timer mental model

Two systemd unit files: backup.service defines a oneshot job as user anurag after the network is up, and backup.timer schedules it at 01:30 with Persistent=true.
Splitting what from when is what makes the job testable on its own.
Terminal: daemon-reload, enable and start backup.timer, list-timers to see next and last run, and journalctl for the service's last 50 lines.
With a timer, ‘did it run and what did it say’ is already answered.
Timeline: power fails at 00:50, the 01:30 run time passes while off, the machine boots at 06:10, and Persistent=true runs the missed backup at 06:11.
Illustrative times. For backups and renewals, this one line matters most.

A systemd timer is two files instead of one line, and that trade is the whole point. The .service unit describes what to run, including which user, what it depends on, and how to handle failure. The .timer unit describes when. Because the work is a proper service unit, everything systemd already knows how to do applies: output goes to the journal automatically, exit status is recorded, and you can make one unit trigger another when it fails.

# /etc/systemd/system/backup.service
[Unit]
Description=Nightly home server backup
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=anurag
ExecStart=/home/anurag/bin/backup.sh
# /etc/systemd/system/backup.timer
[Unit]
Description=Run the nightly backup at 01:30 local time

[Timer]
OnCalendar=*-*-* 01:30:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

Then enable it and confirm the schedule is what you think it is:

sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer

# When does it next fire, and when did it last run?
systemctl list-timers backup.timer

# What actually happened last time?
journalctl -u backup.service -n 50 --no-pager

That journalctl command is the part I want you to notice. With cron, “did it run and what did it say” is a question you have to have planned for in advance. With a timer, it is already answered.

Persistent=true is the power cut setting

Persistent=true tells systemd to record when the timer last ran. If the machine was off at 01:30, the job runs shortly after the next boot instead of being skipped. For backups, certificate renewals and anything that must happen daily rather than exactly at a time, this single line is the reason I prefer timers on hardware that loses power. RandomizedDelaySec then spreads several timers out a little so five jobs don’t all hammer the disk at once.

side by side

Comparison table of cron and systemd timers on setup, output, missed runs, overlap protection, failure handling and testing.
Anything whose failure you would want to know about within a day belongs in a timer.
Concerncronsystemd timer
Setup effortOne lineTwo files plus enable
Where output goesMail, which usually means nowherejournald, always
Missed run after downtimeSkipped silentlyCaught up with Persistent=true
Overlap protectionAdd flock yourselfBuilt in, a service won’t double-start
Reacting to failureRoll your ownOnFailure= triggers another unit
DependenciesNoneCan wait for network, mounts, other units
Testing before the scheduleWait, or run by handsystemctl start backup.service

My working rule: anything whose failure I would want to know about within a day goes into a systemd timer. Backups, database dumps, syncing files off-site. Small conveniences where a missed run costs nothing stay in cron, because a one-liner is easier to reason about six months later. And a job that never needs to touch this machine at all may not belong on it either; if it is a chore for a software project you keep on GitHub, such as checking that the project’s deployed site still answers, scheduled GitHub Actions can run it, and whether the box is awake stops being anyone’s problem.

making failure loud

Flow: when backup.service fails, its OnFailure handler starts a notification unit that sends a Telegram or webhook message.
Nobody watches logs on a home server, so make the machine tell you.

Neither scheduler fixes the real problem, which is that nobody watches logs on a home server. So make the machine tell you. With systemd, add a failure handler to the service unit:

# add to the [Unit] section of backup.service
OnFailure=notify-failure@%n.service

That template unit can be a three-line script that pushes a message to Telegram or hits a webhook. Point it at a self-hosted automation box of the kind described in the n8n self-hosting guide, and one workflow can fan out alerts from every machine in the flat.

The stronger pattern is a dead man’s switch, and it catches the failure mode described at the top of this article. Have the job ping a URL after it succeeds. If the ping doesn’t arrive within the expected window, something alerts you. Notice what this detects that failure alerts cannot: a job that never started at all, because the path was wrong or the timer was never enabled. Absence of success is the signal, not presence of an error.

common mistakes

Five scheduling mistakes: not verifying the first run, relative paths, a UTC server clock, forgetting daemon-reload, and running everything as root.
Verify once, then trust it.
  • Assuming a job runs because you added it. Check systemctl list-timers or your log file the next morning. Verify once, then trust it.
  • Using relative paths in the script. cron and systemd both start with a working directory you didn’t choose. Use absolute paths, or set WorkingDirectory=.
  • Forgetting the machine’s timezone. A server left on UTC will run your “2 AM” job at 07:30 IST. Check with timedatectl and set it to Asia/Kolkata if the schedule is meant to match your day.
  • Letting log files grow without limit. A verbose job appending nightly will eventually fill a small VPS disk. Rotate them, or write to the journal and let systemd cap the size.
  • Editing unit files and wondering why nothing changed. systemd reads them at load, so sudo systemctl daemon-reload is not optional.
  • Running everything as root out of habit. A backup script only needs read access to your data and write access to one target. Set User= and keep the blast radius small, which is the same thinking behind my Ubuntu server security checklist.

FAQ

Should I migrate all my cron jobs to timers?

No, and I’d push back on anyone who says otherwise. Migrate the jobs where silent failure would hurt, and where a missed run after a power cut needs catching up. A cron line that clears a cache folder or rotates a scratch directory is fine exactly as it is. Two schedulers coexisting is not untidy, it’s proportionate.

How do I run something every five minutes with a timer?

Use OnCalendar=*:0/5 for wall-clock five-minute marks. If you’d rather have a gap measured from the end of the last run, use OnUnitInactiveSec=5min, together with OnBootSec=1min so the first run has a starting point; OnUnitActiveSec= measures from when the service last started, not when it finished. The systemd-analyze calendar command will show you the next few firing times for any expression, and I’d recommend checking every new schedule that way before trusting it.

Do timers work on a Raspberry Pi or a cheap VPS?

Yes. Raspberry Pi OS, Ubuntu and Debian all use systemd, so timers are available with nothing to install. The only places you’ll miss them are minimal container images and a few lightweight distributions that ship a different init. If you’re setting up a rented box for the first time, the basics in my VPS beginner guide will get you to the point where these commands work.

The cheapest safeguard of all costs five minutes: look at whether a job you already rely on actually ran last night. That is how a seven-week gap gets caught while it is still a two-week gap.

Update history: corrected the advice on spacing runs from the end of the previous one (OnUnitInactiveSec, not OnUnitActiveSec) and pointed the crontab reference at Debian 13.

Sources

Android: connect to Wi-Fi explains using Wi-Fi without mobile data; fallback behavior remains device-specific.