EQVPS

How to schedule tasks on a VPS with cron and systemd timers

Run backups, cleanups and scripts on a schedule. Cron in five minutes with the three traps that silently break jobs, then systemd timers for logs, catch-up after downtime and exact timing. Every behaviour below was tested on a fresh Ubuntu 24.04 server.

A server earns its keep by doing things while you sleep: backups, log rotation, certificate renewal, a report at nine. Linux has two schedulers for that. Cron is forty years old and takes one line. Systemd timers take two small files and give you logs, catch-up after downtime and exact timing. This guide covers both on Ubuntu 24.04, with every behaviour checked on a fresh server from our image, including the three mistakes that make a cron job fail without a word.

You need a user with sudo; the Ubuntu setup guide creates one.

1. Cron in five minutes

Cron is installed and running on our Ubuntu and Debian images. Each user has a table of jobs; edit yours with:

crontab -e

Each line is five time fields and a command:

# min hour day month weekday  command
  30  3    *   *     *        /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
  */5 *    *   *     *        /usr/local/bin/check.sh
  0   9    *   *     1-5      /usr/local/bin/report.sh
  @reboot                     /usr/local/bin/start-tunnel.sh

That is: 03:30 every night, every 5 minutes, 09:00 on weekdays, and once after each boot. */5 means every fifth value, 1-5 a range, 1,15 a list. The server's time zone applies, which is UTC on our images; set yours once with the hostname and time zone guide and cron follows.

Check what ran with journalctl -u cron. Each run logs the user and the command, which is where the three traps show up.

Trap 1: the output vanishes. Cron mails a job's output to the user. Our image has no mail server, so the journal said (CRON) info (No MTA installed, discarding output) and the output was gone. Redirect it yourself, as the first example does with >> /var/log/backup.log 2>&1.

Trap 2: the percent sign. In a crontab, % ends the command and turns the rest into standard input. We scheduled date +%F > /tmp/today. The journal shows what cron really ran: CMD (date +). No file was written and no error reached us. Write \% instead, or put the command in a script and schedule the script.

Trap 3: a different environment. Cron runs jobs with /bin/sh, not bash, in your home directory, with a short environment. On Ubuntu 24.04 the PATH was better than its reputation, the full system path including /usr/local/bin and /snap/bin, for root and for a normal user. What is still missing is anything under your home: ~/.local/bin, nvm's node, pipx, cargo. Use full paths in the job, or start the line with . $HOME/.profile;.

Jobs that belong to the system rather than a user go in /etc/cron.d/ as a file with one extra field, the user:

30 3 * * * root /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

Ubuntu also runs everything in /etc/cron.hourly, cron.daily, cron.weekly and cron.monthly at 17 minutes past each hour, 06:25, 06:47 on Sundays and 06:52 on the 1st. Drop an executable script there and it runs with the rest.

2. Systemd timers

A timer is two units: a service that does the work, and a timer that says when. Create the script first, then the units.

sudo nano /usr/local/bin/backup.sh
sudo chmod +x /usr/local/bin/backup.sh
sudo nano /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
sudo nano /etc/systemd/system/backup.timer
[Unit]
Description=Run the backup every night at 03:30

[Timer]
OnCalendar=*-*-* 03:30
Persistent=true
AccuracySec=1m

[Install]
WantedBy=timers.target

Enable the timer, not the service:

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

list-timers shows the next run, how long until it, and the last run. Logs need no setup: journalctl -u backup.service has every start, finish and line of output, with timestamps. To run the job right now, sudo systemctl start backup.service, which is also how you test the script before trusting the schedule.

Calendar syntax. OnCalendar reads weekday year-month-day hour:minute:second, with * for any. Test an expression before you use it:

systemd-analyze calendar "Mon..Fri *-*-* 03:00" --iterations 2

It prints the normalized form and the next dates. Some expressions we checked:

OnCalendarMeaning
*-*-* 03:30Every day at 03:30
Mon..Fri *-*-* 09:00Weekdays at 09:00
*:0/15Every 15 minutes
weeklyMondays at 00:00
*-*-01 04:00The 1st of each month at 04:00
*-*-* 03:00 Europe/Kyiv03:00 Kyiv time; ran at 01:00 UTC for us

The three settings worth knowing. Persistent=true records the last run on disk and catches up a run missed while the server was off or the timer stopped. We stopped our every-two-minutes timer, waited past a scheduled run, and started it again: the missed run fired within the same second as the start. Without Persistent, it simply waits for the next slot.

AccuracySec is the surprise. Systemd groups timers within a one-minute window by default to save wake-ups, so our first run came at 07:42:18, not 07:42:00. With AccuracySec=1s the next runs landed on 07:46:00 and 07:48:00 to the second. Set it when the minute matters; leave the default for a nightly backup.

RandomizedDelaySec=10m does the opposite on purpose: it spreads the start over a window so ten servers do not hit the same backup storage at the same second.

For a one-off job, no files at all:

sudo systemd-run --on-active=30m --unit=cleanup-once /usr/local/bin/cleanup.sh

It creates a transient timer that fires once and disappears; systemctl list-timers shows it until then.

3. Which one to use

CronSystemd timer
Set up1 line2 files
LogsOnly if you redirect outputjournalctl -u name.service
Missed run while server was offLostRuns at boot with Persistent=true
Job still running at the next tickA second copy startsSkipped until the first finishes
Exact timeTo the minuteTo the second with AccuracySec=1s
StatusNonelist-timers, systemctl status

Use cron for a quick line you will recognise in a year. Use a timer for anything whose failure you need to see, or that must not overlap, or that must run after a reboot it missed: backups, certificate renewal, database cleanups. If a cron job can overlap, wrap it: flock -n /tmp/backup.lock /usr/local/bin/backup.sh, and the second copy exits at once.

Both schedulers run a job and stop. For a program that should run around the clock and come back after a crash, you want a service, not a schedule; see how to create a systemd service and, for Windows, how to run a script 24/7 on Windows Server. A good first timer is the one from our restic backup guide; automatic security updates are a timer Ubuntu ships already, apt-daily-upgrade.timer, one of the 17 that run on a fresh server.

FAQ

Why does my cron job work in the terminal but not in cron?

Three usual reasons. Cron runs the job with sh, not bash, so bash-only syntax fails. Tools installed under your home directory, such as nvm, pipx or cargo, are not on cron's PATH, so use full paths. And any % in the command line ends the command: date +%F became date + on our server. Escape it as \% or move the command into a script.

Where does cron output go?

To email, by default. Our image has no mail server, so cron logs 'No MTA installed, discarding output' and the output is gone. Add >> /var/log/myjob.log 2>&1 to the job, or switch to a systemd timer, whose output lands in the journal automatically.

What is the advantage of a systemd timer over cron?

Logs in journalctl, no second copy of a job that is still running, catch-up of runs missed while the server was off with Persistent=true, and a status line that shows the next and last run. Cron wins on brevity: one line instead of two files.

My timer fired 18 seconds late. Why?

By default systemd groups timers within a one-minute window to save wake-ups, which is the AccuracySec setting. Our every-two-minutes timer fired at 07:42:18 instead of 07:42:00. With AccuracySec=1s it fired at 07:46:00 exactly. Set it when the exact minute matters.

What time zone do cron and timers use?

The server's, which is UTC on our images. Set it once with timedatectl, see our hostname and time zone guide, and both cron and timers follow. Timers can also carry their own zone: OnCalendar=*-*-* 03:00 Europe/Kyiv ran at 01:00 UTC in our test.

Comments

No comments yet. Be the first.

Leave a comment

Comments are moderated before they appear.