The maintenance work that keeps a small business server healthy is repetitive and easy to forget. Database dumps, log tidying, certificate renewals, disk checks, package updates. Scheduling those jobs is the obvious answer, and it works until the day the schedule stops firing and nobody finds out for three months.
Ubuntu 24.04 LTS gives you two schedulers that ship by default: classic cron, and systemd timers. They overlap, they behave differently when a server is switched off, and they report failure in completely different ways. This article covers where scheduled work lives on a modern Ubuntu server, how to write a job that does not fail silently, and how to prove that a job actually ran.
What to automate first on a small business server
Start with the tasks whose absence causes real damage. On a typical Ubuntu web server that means four things.
- Database and file backups taken off the machine, because a backup that lives on the same disk protects you from almost nothing.
- Security updates, which Ubuntu can already apply on its own through the
unattended-upgradespackage. - TLS certificate renewal, which Certbot installs as its own scheduled unit rather than something you write.
- Disk and log growth checks, since a full
/varpartition takes down MySQL, PHP-FPM and the web server together.
Everything else is optimisation. Cache warming, sitemap regeneration, report emails and cleanup scripts are all reasonable candidates, but they belong after the four above are working and verified.
Where scheduled jobs live on Ubuntu 24.04
Scheduled work on Ubuntu is spread across several locations, and knowing all of them saves a lot of confused debugging when a mystery script runs at 06:25 every morning.
The cron locations
User crontabs are edited with crontab -e and stored under /var/spool/cron/crontabs/. You should never edit those files directly, because crontab reloads the daemon for you and validates the syntax first.
System-wide jobs sit in /etc/crontab and in drop-in files under /etc/cron.d/. Both formats add a user column between the schedule and the command, which is the single most common reason a copied crontab line refuses to run.
17 3 * * * www-data /usr/bin/php /var/www/example/bin/sitemap.php
That line belongs in a file such as /etc/cron.d/site-sitemap. The www-data field is the user column, and leaving it out is what turns a working personal crontab line into a system job that never fires.
The directories /etc/cron.hourly, /etc/cron.daily, /etc/cron.weekly and /etc/cron.monthly hold executable scripts with no schedule line at all. Files there must be executable and, because run-parts is fussy, must have names without a dot in them.
The systemd timer locations
Ubuntu has been moving packaged maintenance out of cron for several releases. Log rotation, APT's daily update and upgrade runs, filesystem trims and man-page indexing are all systemd timers now. List them with a single command.
systemctl list-timers --all
Reading that output before adding your own job is worth the thirty seconds. If a timer already covers what you were about to script, use it.
Writing a cron entry that does not fail silently
Cron does not run your login shell. It does not read .bashrc, .profile or anything else that sets up your interactive environment. On Debian and Ubuntu a user crontab runs under /bin/sh with a minimal PATH, usually just /usr/bin:/bin. A script that works perfectly when you type it will fail under cron for reasons that have nothing to do with the script.
Four habits remove most of that pain.
- Absolute paths everywhere. Write
/usr/bin/phpand/usr/bin/mysqldumprather than relying onPATHresolution. - Set the environment explicitly at the top of the crontab, so the job does not inherit surprises.
- Escape percent signs. An unescaped
%is a newline to cron, sodate +%Fsilently truncates the command. - Capture output to a file or to the journal instead of letting it disappear.
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""
30 2 * * * /usr/local/bin/db-backup.sh >> /var/log/db-backup.log 2>&1
The 2>&1 matters. Without it, only standard output is captured and the error message you actually need goes nowhere. Setting MAILTO="" is deliberate here: most servers have no mail transfer agent installed, so cron's default attempt to email you produces nothing except confusion.
If you would rather have the output in the journal alongside everything else, pipe it through logger with a tag you can filter on later.
30 2 * * * /usr/local/bin/db-backup.sh 2>&1 | /usr/bin/logger -t db-backup
For the schedule syntax itself, including the five time fields and the shorthand forms, the older walkthrough of cron jobs and crontab syntax covers the format in more detail than is worth repeating here.
What systemd timers give you that cron does not
A timer is two files: a service unit that describes the work, and a timer unit that describes when. That is more typing than a crontab line, and in return you get behaviour cron cannot offer.
| Behaviour | Cron | systemd timer |
|---|---|---|
| Missed run while the machine was off | Skipped | Caught up with Persistent=true |
| Exit status recorded | Only if you log it | Stored by systemd |
| Output location | Mail or redirection | The journal, per unit |
| Overlapping runs | Possible | Blocked by default |
| Resource limits and sandboxing | None | Full unit options |
A backup job written as a timer needs a service unit, saved as /etc/systemd/system/db-backup.service.
[Unit]
Description=Nightly database backup
[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/db-backup.sh
Then the timer itself, saved alongside it as /etc/systemd/system/db-backup.timer.
[Unit]
Description=Run the nightly database backup
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=300
Persistent=true
[Install]
WantedBy=timers.target
Enable it, then confirm systemd agrees with your reading of the calendar expression.
sudo systemctl daemon-reload
sudo systemctl enable --now db-backup.timer
systemd-analyze calendar "*-*-* 02:30:00"
RandomizedDelaySec is a small detail with a real benefit. If you run several servers that all back up at exactly 02:30, they compete for the same network path and the same storage endpoint. A jitter window spreads the load out.
Stopping two copies of the same job from running at once
A nightly backup that normally takes ten minutes will occasionally take ninety, and cron will happily start tomorrow's run while yesterday's is still going. Two mysqldump processes against the same database is a good way to turn a slow night into an outage.
flock from util-linux solves this in one line. The -n flag makes the second invocation exit immediately rather than queue behind the first.
30 2 * * * /usr/bin/flock -n /run/lock/db-backup.lock /usr/local/bin/db-backup.sh
systemd services get this behaviour without asking. A oneshot service that is already active will not be started a second time.
Proving that a scheduled job actually ran
An unverified backup is a hope, not a backup. The same applies to every scheduled job, and there are three levels of confidence worth knowing about.
The first level is the scheduler's own record. Cron writes a line to the journal every time it starts a command, and systemd records the result of every timer run.
journalctl -t CRON --since "24 hours ago"
journalctl -u db-backup.service --since "24 hours ago"
systemctl list-timers db-backup.timer
The second level is the job's own output. Cron told you it started the command. Only your log tells you the command worked, which is why the redirection above is worth the extra characters.
The third level is an external check, and it is the one small businesses skip. If the server loses power, cron cannot email you about the backup it did not take. A dead man's switch fixes that: the job pings an external monitoring endpoint on success, and the monitor alerts you when the ping fails to arrive. Adding curl to the end of a backup script is a couple of minutes of work.
#!/bin/bash
set -euo pipefail
/usr/bin/mysqldump --single-transaction --quick app_db \
| /usr/bin/gzip > "/srv/backups/app_db-$(date +%Y%m%d).sql.gz"
/usr/bin/curl -fsS -m 10 --retry 3 https://example-monitor.invalid/ping/YOUR-UUID
The set -euo pipefail line is doing real work. Without it a failed mysqldump still produces a zero byte file, the script carries on, the success ping fires, and your monitoring tells you everything is fine. Related ideas apply to basic uptime monitoring and to Linux server monitoring tools more generally.
Mistakes that make scheduled jobs fail quietly
These are the ones that come up again and again on client servers, roughly in order of how often they appear.
- Relative paths in the command or inside the script. Cron starts in the user's home directory, not in your project folder.
- Missing user column in
/etc/cron.dfiles, copied from a personal crontab where that column does not exist. - No newline at the end of a crontab file, which makes older cron builds ignore the final entry.
- Unescaped percent signs in date formats, so the command is truncated at the first
%. - Wrong user, so a script that needs database credentials from a specific home directory finds none.
- Filenames with dots in
/etc/cron.daily, whichrun-partsskips without comment. - Server timezone assumptions. Confirm with
timedatectlbefore scheduling anything that matters to the business day.
When a job refuses to run and the reason is not obvious, reproduce cron's environment rather than guessing. Running the command with a stripped environment usually reveals the problem in seconds.
env -i /bin/sh -c '/usr/local/bin/db-backup.sh'
When cron and timers are the wrong tool
Both schedulers are built around wall clock time on one machine. That makes them a poor fit for several common jobs.
Work triggered by a user action, such as sending a confirmation email or resizing an uploaded image, should go to a queue rather than wait for the next minute boundary. The trade-offs there are covered in the comparison of cron jobs against queued background jobs.
Work that must run exactly once across several application servers needs coordination that cron does not provide. Running the same crontab on three web nodes means three simultaneous copies of the job unless something external picks a leader.
Work that needs sub-minute frequency is also outside cron's range, since one minute is its smallest interval. A systemd timer with OnUnitActiveSec=30s handles that case, though a long running worker process is often the better design.
Getting scheduled maintenance into a state you can trust
A scheduling setup worth trusting has three properties. Every job writes somewhere you can read afterwards. Every job that matters has an external check that fires when the job does not. And the list of what is scheduled is short enough that you can still explain what each entry does a year later.
If your server has accumulated scheduled jobs nobody can account for, or backups that have never been restored as a test, N. Cristea can review what is running, confirm what is actually working, and put monitoring around the parts that matter. Get in touch with a short description of the setup and the tasks you are relying on.