Ahosting Logo
Knowledge Base

How to Schedule Jobs with Cron and Systemd Timers

Cron and timers, and the silence problem they sharecronsystemd timerFailure outputmailed to the user, which usually goesnowherein the journal, with the unitMissed while offskipped entirelycan run on next bootOverlap protectionnone, unless you add lockingbuilt inTimezoneserver timeserver time, and configurableWhichever you use, redirect output somewhere you will read. A cron job that has failed nightly for six months looksexactly like one that works.

Scheduled jobs on a VPS run through cron or through systemd timers. Both work; they fail differently, and the difference in how they report failure is the main reason to choose one over the other.

Cron: the familiar one

crontab -e

Edits the crontab for the current user. Jobs run as that user, which is what you want; a job that does not need root should not run as root.

0 3 * * * /usr/bin/php /home/user/app/cleanup.php

Minute, hour, day of month, month, day of week. Three o'clock every morning.

Use full paths for everything. Cron runs with a minimal environment and almost no PATH, so a command that works in your shell fails here with "command not found", which is the single most common cron problem.

Cron's silence is the real problem

A cron job that fails produces output, and that output goes to mail for the user, which on most VPS installations is not delivered anywhere.

So the job stops working and nothing tells you. Weeks later somebody notices the backups are old.

Two fixes. Redirect output to a log:

0 3 * * * /path/to/script >> /var/log/myjob.log 2>&1

The 2>&1 is what captures errors; without it you log the successes and lose the failures.

Better, make the job itself report when it fails. A message to a monitoring service or a chat channel. Anything is better than mail nobody reads.

Systemd timers: more setup, better reporting

A timer is two files: a service saying what to run, and a timer saying when.

[Unit]
Description=Nightly cleanup

[Service]
Type=oneshot
User=appuser
ExecStart=/usr/bin/php /home/user/app/cleanup.php

And the timer:

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target

More to write than a cron line. What you get for it is worth understanding.

Output goes to the journal automatically, so journalctl -u mycleanup shows every run and its result. No redirect to configure and nothing lost. Managing logs walks through reading it.

Persistent=true runs a missed job when the machine comes back. Cron simply skips anything scheduled while the server was down.

And you can see status: systemctl list-timers shows when each last ran and when it runs next, which is a question cron cannot answer.

Overlapping runs

The failure that turns a slow job into an outage.

A job scheduled every five minutes that sometimes takes six starts overlapping itself. Instances accumulate, memory fills, and the server becomes unresponsive from a job that ran fine for months.

Systemd handles this: a service already running is not started again.

With cron you must handle it yourself, using flock:

*/5 * * * * /usr/bin/flock -n /tmp/myjob.lock /path/to/script

-n means give up rather than wait. Without a lock, any job that can run long needs to be assumed to overlap eventually.

Timezones

Cron uses the system timezone, so a job set for 03:00 runs at three in the server's time, which may not be yours.

Check with timedatectl. Setting the server to UTC and doing the arithmetic yourself is the arrangement that survives daylight saving. A job scheduled during the hour that repeats or disappears runs twice or not at all, once a year, which is a genuinely confusing bug.

Test it as the user, not as yourself

The most common reason a job works when you run it and fails on schedule.

sudo -u appuser /usr/bin/php /home/user/app/cleanup.php

That reproduces the permissions the scheduled run has. A script that reads a file only your account can read works in your shell and fails at three in the morning.

For the environment difference, run it with a stripped environment: env -i in front of the command shows what cron sees, which is much less than your shell provides.

Application schedulers are different again

WordPress has its own scheduler that runs on visits rather than on a clock. On a low-traffic site, scheduled tasks run late or not at all, and the standard fix is a real cron job calling it, with the built-in trigger disabled. Why scheduled posts do not publish goes into it.

The general point applies to any application: find out whether its scheduler is real or visit-driven before relying on it for anything that matters.

Know what is scheduled

On a server you inherited, jobs hide in several places.

crontab -l
sudo crontab -l
ls /etc/cron.d/
systemctl list-timers --all

Check each user's crontab as well as root's. A job set up by someone who left, still running, still doing something, is a common find, and occasionally the explanation for a nightly load spike nobody could account for.

Choosing between them

Cron for something simple, on a system where you already have logging and alerting.

Systemd timers for anything that matters: automatic logging, missed-run handling, overlap protection and a status command, without you building any of it.

Whichever you use, the job should say something when it fails. A scheduled task nobody hears from is indistinguishable from one that stopped. There is more on making an alert arrive in uptime monitoring.

Read the whole schedule, including what you did not write

Scheduled work accumulates in at least four places, and no single screen shows all of them.

crontab -l
sudo ls /etc/cron.d/ /etc/cron.daily/ 2>/dev/null
systemctl list-timers --all --no-pager
for u in $(cut -d: -f1 /etc/passwd); do sudo crontab -l -u "$u" 2>/dev/null | sed "s/^/$u: /"; done

The last command is the one that surprises people, since a job belonging to a service account runs without appearing in anybody's personal schedule.

Doing this on an unfamiliar machine is the fastest way to understand what it actually does, and it regularly turns up work nobody knew was still running, pointed at systems that no longer exist.

Know how long each job takes

A schedule is written from an assumption about duration, and that assumption stops being true as data grows.

systemd-analyze blame 2>/dev/null | head
journalctl -u myjob.service --since '7 days ago' | grep -iE 'started|finished|succeeded' | tail -20
/usr/bin/time -v /path/to/job.sh 2>&1 | grep -E 'Elapsed|Maximum resident'

A job that took two minutes when it was written and now takes fifty is on its way to overlapping with its next run, and nothing announces that transition.

Record the duration alongside the schedule. When it approaches the interval, the fix is to change the interval or the job, and doing it deliberately is much cheaper than discovering it when two copies collide.

Stagger what would otherwise start together

Every job written to run at the top of the hour runs at the same moment, and on a machine with several of them that produces a spike.

[Timer]
OnCalendar=hourly
RandomizedDelaySec=300
AccuracySec=1min

A randomised delay spreads them without changing the schedule anyone reads. In an ordinary schedule the equivalent is choosing different minutes deliberately rather than using zero for everything.

This matters most on shared or virtualised machines, where the neighbours are also running their backups at midnight. Moving a heavy job to an odd time is free and frequently produces a larger improvement than tuning the job itself.