Cron Jobs on a Linux VPS: Schedule Tasks Without Surprises

Cron is the standard way to run a command on a schedule on Linux: nightly backups, cache cleanup, report emails, queue workers that need a nudge. The syntax takes five minutes to learn. The surprises come later, when a job that works in your terminal silently does nothing at 3 AM. This guide covers the syntax and then the four things that cause most cron failures: environment, the percent sign, logging and overlapping runs.
Where cron jobs live
Each user has a personal crontab. Edit it with crontab -e, never by editing files under /var/spool/cron directly. The editor checks the syntax when you save.
crontab -e # edit your own crontab
crontab -l # list it
sudo crontab -e # edit root's crontab
There are also system-wide locations: /etc/crontab and files in /etc/cron.d/. Lines there have an extra field, the user that runs the command:
# /etc/cron.d/backup (note the extra user field)
30 3 * * * root /usr/local/bin/backup.sh
Then there are the folders /etc/cron.hourly, /etc/cron.daily, /etc/cron.weekly and /etc/cron.monthly. Drop an executable script in one and it runs on that schedule. One trap on Debian and Ubuntu: these folders are run by run-parts, which skips file names that contain a dot. A script called backup.sh in /etc/cron.daily never runs. Name it backup. run-parts --test /etc/cron.daily lists what will actually run.
The five fields
# ┌──────── minute (0-59)
# │ ┌────── hour (0-23)
# │ │ ┌──── day of month (1-31)
# │ │ │ ┌── month (1-12)
# │ │ │ │ ┌ day of week (0-7, 0 and 7 = Sunday)
# │ │ │ │ │
*/5 * * * * /usr/local/bin/check-queue.sh
30 3 * * * /usr/local/bin/backup.sh
0 9 * * 1-5 /usr/local/bin/report.sh
0 0 1 * * /usr/local/bin/monthly-cleanup.sh
@reboot /usr/local/bin/start-worker.sh
A field can be a number, a list (1,15), a range (1-5) or a step (*/5, every 5 units). @reboot, @daily and @hourly are shortcuts.
One rule surprises almost everyone. When both day-of-month and day-of-week are set, cron runs the job when either matches, not both:
# runs on the 13th of every month AND on every Friday
0 0 13 * 5 /usr/local/bin/job.sh
If you need "Friday the 13th", schedule it every Friday and check the date inside the script. Also remember that cron uses the server's time zone. Check it with timedatectl before you schedule anything tied to business hours.
Gotcha 1: cron has almost no environment
Your interactive shell loads .bashrc, .profile, your PATH additions, nvm, pyenv and so on. Cron loads none of that. It runs your command with /bin/sh, a minimal PATH (on Ubuntu, usually just /usr/bin:/bin) and your home directory as the working directory. The result is a job that works when you run it by hand and fails under cron with something like:
/bin/sh: 1: mariadb-dump: not found
(CRON) info (No MTA installed, discarding output)
Three fixes, best first:
Use absolute paths for every program and file in the job.
which mariadb-dumptells you the path.Set the environment at the top of the crontab. Cron accepts plain
NAME=valuelines.Put the work in a script that sets its own PATH and
cds to the right directory.
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""
30 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
To reproduce cron's environment before you trust a job, run it with an almost empty environment:
env -i SHELL=/bin/sh PATH=/usr/bin:/bin HOME="$HOME" /bin/sh -c '/usr/local/bin/backup.sh'
If it fails here, it will fail under cron.
Gotcha 2: the percent sign
In a crontab line, an unescaped % ends the command. Everything after it is sent to the command as standard input. This breaks the most common backup line on the internet:
# wrong: cron cuts the line at the first %
0 2 * * * mariadb-dump --all-databases | gzip > /var/backups/db-$(date +%F).sql.gz
# right
0 2 * * * mariadb-dump --all-databases | gzip > /var/backups/db-$(date +\%F).sql.gz
Escape each % with a backslash, or move the command into a script, where % has no special meaning. One more small rule: the crontab must end with a newline. Most editors add it, but a file installed with crontab file.txt may not have one, and the last job is then ignored.
Gotcha 3: output goes nowhere
By default, cron emails any output to the job owner. On a typical VPS there is no mail server, so the output is thrown away and the syslog records "No MTA installed, discarding output". Your errors disappear with it.
Redirect both standard output and standard error to a log file you can read: >> /var/log/backup.log 2>&1, as in the example above. The order matters: 2>&1 must come after the file redirect. A user crontab can only write where that user has permission, so for a non-root user use a path such as $HOME/logs/.
For longer jobs, let the script log itself with timestamps. You then know when each run started and whether it finished:
#!/bin/bash
set -euo pipefail
exec >> /var/log/backup.log 2>&1
echo "[$(date -Is)] start"
# ... the real work ...
echo "[$(date -Is)] done"
To confirm that cron started a job at all, check the cron daemon's own log:
journalctl -u cron --since today
grep CRON /var/log/syslog | tail -n 20
Log files grow forever unless you rotate them. Add a small file in /etc/logrotate.d/ for each log you create.
Gotcha 4: overlapping runs
Cron does not know whether the previous run has finished. If a job scheduled every 5 minutes sometimes takes 7, two copies run at once. With backups, imports or queue processing, that means corrupted files, duplicated work or a server that slows down until every run takes longer than 5 minutes and the copies pile up.
The flock utility, part of util-linux and installed on every Ubuntu server, fixes this with one prefix:
30 3 * * * flock -n /run/lock/backup.lock timeout 2h /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
flock -ntakes a lock on the file. If another run already holds it, the new run exits immediately instead of waiting.The lock is released automatically when the process ends, even if it crashes. There is no stale PID file to clean up.
timeout 2hkills a run that hangs, so one stuck job cannot block all future runs.
Use a separate lock file for each job. /run/lock is a good place: it is in memory and cleared at boot.
The alternative: systemd timers
Ubuntu already uses systemd, and systemd timers can replace cron jobs. A timer needs two small files: a service that says what to run, and a timer that says when.
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup every night
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=10m
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers
journalctl -u backup.service -n 50
systemd-analyze calendar "Mon..Fri *-*-* 09:00"
What you gain:
Logs by default. All output goes to the journal with timestamps and exit codes.
systemctl status backup.serviceshows whether the last run failed.No overlaps. systemd does not start a service that is still running, so you do not need flock.
Missed runs.
Persistent=trueruns a job that was missed while the server was off, as soon as it boots.Full environment control and limits. You can set
User=,WorkingDirectory=,Environment=, memory and CPU limits in the service file.
What it costs: two files instead of one line, and a calendar syntax you need to look up. systemd-analyze calendar shows when an expression will next trigger, which removes the guessing.
| Situation | Rule of thumb |
|---|---|
| Simple one-liner, short runtime | cron |
| Job that must not overlap or must catch up after downtime | systemd timer (or cron with flock) |
| You need to know every time it failed | systemd timer |
| Application scheduler (Laravel, WordPress) | one cron line that calls the app's scheduler every minute |
A short checklist
Absolute paths, or PATH set at the top of the crontab.
Every
%escaped.Output redirected to a log file, with rotation.
flock -nfor anything that can run longer than its interval.Tested once with
env -ibefore you rely on it.
Run it on IM HOST
Cron and systemd timers need a server that stays on and gives you root. IM HOST Linux VPS plans start from $5/month with full root on KVM; daily backup is available as an optional add-on. Cloud VPS plans include an automatic backup every 3 days for the life of the subscription. If your scheduled tasks are mostly API calls and data moves between apps, n8n Hosting comes with n8n pre-installed and unlimited workflows and executions. All plans include 24/7 engineer support and a 30-day money-back guarantee.
More from our blog
Discover more practical guides and product insights from the IM Host team.
View all articles