Cron Jobs and Task Scheduling: Automating Server Maintenance Without Manual Intervention

11 min read 2,173 words
Cron Jobs and Task Scheduling: Automating Server Maintenance Without Manual Intervention featured image

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-upgrades package.
  • TLS certificate renewal, which Certbot installs as its own scheduled unit rather than something you write.
  • Disk and log growth checks, since a full /var partition 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.

  1. Absolute paths everywhere. Write /usr/bin/php and /usr/bin/mysqldump rather than relying on PATH resolution.
  2. Set the environment explicitly at the top of the crontab, so the job does not inherit surprises.
  3. Escape percent signs. An unescaped % is a newline to cron, so date +%F silently truncates the command.
  4. 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.

BehaviourCronsystemd timer
Missed run while the machine was offSkippedCaught up with Persistent=true
Exit status recordedOnly if you log itStored by systemd
Output locationMail or redirectionThe journal, per unit
Overlapping runsPossibleBlocked by default
Resource limits and sandboxingNoneFull 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.d files, 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, which run-parts skips without comment.
  • Server timezone assumptions. Confirm with timedatectl before 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.

Frequently Asked Questions

Should I use cron or a systemd timer on Ubuntu 24.04?
Use a systemd timer for anything where a missed run matters, where you want the exit status recorded, or where the job needs to run as a specific user with resource limits. Cron is still perfectly reasonable for simple, frequent, low-stakes jobs, and a one line crontab entry is faster to write than two unit files. Mixing both on the same server is normal.
Why does my script work manually but fail under cron?
Almost always the environment. Cron gives the job a minimal PATH, no shell profile and a different working directory. Test with env -i /bin/sh -c 'your-command' to reproduce those conditions, then add absolute paths and any variables the script depends on.
Where does cron output go if I do not redirect it?
Cron tries to email it to the job owner. On a server with no mail transfer agent configured, that attempt fails and the output is lost. Redirect to a file with >> /path/to.log 2>&1, or pipe through logger so it lands in the journal.
Do I still need cron jobs for WordPress?
WordPress schedules its own tasks through wp-cron, which only fires when someone visits the site. On a quiet business site that means scheduled posts and updates can run late or not at all. Disabling wp-cron and calling it from a real cron entry every five or fifteen minutes gives predictable behaviour.
How often should backups run?
The honest answer depends on how much data the business can afford to lose. Daily is the common default for a brochure site or a small CMS. An e-commerce database with orders arriving through the day usually needs more frequent dumps, or point in time recovery from the database's own binary logs. Decide the acceptable loss window first, then pick the schedule that meets it.