
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

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

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



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

| Concern | cron | systemd timer |
|---|---|---|
| Setup effort | One line | Two files plus enable |
| Where output goes | Mail, which usually means nowhere | journald, always |
| Missed run after downtime | Skipped silently | Caught up with Persistent=true |
| Overlap protection | Add flock yourself | Built in, a service won’t double-start |
| Reacting to failure | Roll your own | OnFailure= triggers another unit |
| Dependencies | None | Can wait for network, mounts, other units |
| Testing before the schedule | Wait, or run by hand | systemctl 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

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

- Assuming a job runs because you added it. Check
systemctl list-timersor 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
timedatectland 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-reloadis 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.
- crontab(5) manual page — the authoritative crontab field syntax, MAILTO behaviour and percent-sign escaping.
- systemd.timer(5) manual page — timer unit options including Persistent and RandomizedDelaySec.
- systemd.time(7) manual page — the OnCalendar expression grammar these examples use.