A cron job turns your server from a machine you have to keep watching into an employee that takes a backup at 3:30 a.m., logs resource usage every 5 minutes and sends a report on the first of every month, all while you are asleep. This guide covers cron jobs in Linux from the ground up: the five-field crontab syntax with a table of real examples, the @daily and @reboot shortcuts, and, most important of all, why a command that works perfectly in your terminal fails silently under cron.
What are cron jobs in Linux, and what should you hand over to them?
cron is an old and extremely stable Linux service (a daemon) that checks its schedule tables once a minute and runs every line whose time has come. Each of those lines, a schedule plus a command, is called a cron job, and together they are kept in a file called a crontab (short for cron table).
The jobs classically handed to cron share one trait: they are periodic, and each run has a clear start and a clear end:
- Nightly backups of files and databases, at an hour when the server is quiet.
- Periodic cleanup: temporary files, old logs and expired caches, before the disk fills up.
- Reporting and monitoring: logging resource usage every few minutes, a weekly report, health checks on services.
- Maintenance: SSL certificate renewal, file synchronization, refreshing lists and indexes.
There is one common use we deliberately set apart: "keeping alive" a Telegram bot or any other long-running process. At the end of this article you will see why that one is not a job for cron. If you are still finding your feet in the terminal, take a look at our Linux commands cheat sheet first.
cron comes preinstalled on almost every distribution; if a minimal image doesn't have it:
sudo apt install cron && sudo systemctl enable --now cron # Debian / Ubuntu
sudo dnf install cronie && sudo systemctl enable --now crond # AlmaLinux / Rocky
How to use crontab: edit, list and back up your jobs
Every Linux user has a personal crontab, and the jobs in it run with that user's permissions. So any job that doesn't need root belongs in a regular user's crontab.
crontab -e # edit your crontab
crontab -l # list current jobs
crontab -l > ~/cron-backup.txt # save a plain-text backup
sudo crontab -u www-data -l # view another user's table (root only)
On Debian and Ubuntu, the first time you run crontab -e it asks which editor you want; if you picked vi by mistake, switch with the select-editor command. On AlmaLinux, vi opens without asking; choose your editor with EDITOR=nano crontab -e. Changes take effect as soon as you save: no restart and no reload needed.
crontab -r deletes your entire table without asking a single question. On the keyboard, r sits right next to e, and one typo means every schedule has vanished, with no confirmation and no way back. Make a habit of saving the output of crontab -l to a file after every significant change.Crontab syntax: the five fields, once and for all
Every crontab line consists of five time fields followed by the command. The fields are separated by spaces, and their order never changes:
┌───────────── minute (0-59)
│ ┌─────────── hour (0-23)
│ │ ┌───────── day of month (1-31)
│ │ │ ┌─────── month (1-12)
│ │ │ │ ┌───── day of week (0-7, 0 or 7 = Sunday)
│ │ │ │ │
* * * * * command-to-run
Four operators build every possible case: an asterisk means "any value", a comma builds a list (1,15), a hyphen builds a range (9-17) and a slash defines a step (*/5 means every 5 units). With just these four operators, the table below covers the most common real-world schedules:
| Cron expression | When it runs | Typical use |
|---|---|---|
*/5 * * * * | Every 5 minutes | Usage snapshots, health checks |
30 3 * * * | Every night at 3:30 a.m. | Nightly backup |
0 0 1 * * | Midnight on the 1st of every month | Monthly report, log archiving |
0 9 * * 1 | Every Monday at 9:00 a.m. | Weekly report |
0 4 * * 5 | Every Friday at 4:00 a.m. | Heavy end-of-week jobs |
0 */6 * * * | Every 6 hours, on the hour | Periodic sync |
*/10 9-17 * * * | Every 10 minutes, only from 09:00 to 17:50 | Business-hours tasks |
An old trap: if you restrict both "day of month" and "day of week", cron combines them with OR, not AND. The expression 0 3 13 * 5 runs on the 13th of every month and on every Friday, not just on Friday the 13th. For combined conditions, move the date logic into the script.
A second limitation is worth knowing right away: cron's smallest unit is one minute, and "every 30 seconds" does not exist in its syntax. The usual workaround is two lines, one of which waits 30 seconds first. But a real need for sub-minute intervals usually means the job should be a long-running service, not a cron job:
* * * * * /usr/local/bin/poll.sh
* * * * * sleep 30; /usr/local/bin/poll.sh
Special strings: @daily, @reboot and the rest
For the most common schedules there are also more readable shortcuts:
@reboot /usr/local/bin/warmup.sh # once, right after boot
@hourly /usr/local/bin/sync.sh # minute 0 of every hour
@daily /usr/local/bin/cleanup.sh # every day at 00:00
@weekly /usr/local/bin/report.sh # Sunday at 00:00
@monthly /usr/local/bin/archive.sh # 1st of month at 00:00
@reboot is great for jobs like warming up a cache, but it runs "when the cron service starts", not necessarily once the network is ready. And @daily means exactly midnight: ten @daily jobs mean ten simultaneous runs and a load spike, so spread the times out on purpose, for example 3:10, 3:30 and 3:50.
The rest of this article is about something no reference table will tell you: why a correctly scheduled job doesn't run.
Cron job not running? Why cron jobs fail silently
The most repeated sentence in the cron world is "the command works in the terminal, but not in cron." The reason is almost always the same: cron runs your command in an environment far more minimal than your shell, and it doesn't show you the error anywhere.
The minimal environment and the PATH problem
When you log in to the server, bash reads your profile files (.bashrc and .profile) and builds a long PATH. cron reads none of them: its PATH is usually just /usr/bin:/bin, and its default shell is /bin/sh, not bash. The result is that a command like certbot, which your terminal finds without trouble, fails in cron with "command not found", and you never see that error.
# put these at the top of `crontab -e` once
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# or find the absolute path once, and always use it
which certbot
# /usr/local/bin/certbot (snap install: a symlink to /snap/bin/certbot)
That example says it all: the link that Certbot's official snap install instructions create lives in /usr/local/bin, outside cron's default PATH. That said, don't write a manual cron job to renew certificates. The snap package installs the snap.certbot.renew.timer timer and Ubuntu's apt package installs certbot.timer; either one runs twice a day, and you can see it with systemctl list-timers | grep certbot. The details are in our guide to installing a free SSL certificate with Certbot.
The golden rule: always write absolute paths in a crontab, for commands and for files alike. That one habit removes half of all cron problems at the root.
The % sign has a special meaning
In a crontab line, the percent character is turned into a newline, and everything after the first percent sign becomes the command's standard input. The perennial victim of this rule is date.
# BROKEN: cron cuts this line at the first %
0 2 * * * tar -czf /backup/www-$(date +%F).tar.gz -C /var/www .
# CORRECT: escape % with a backslash inside a crontab line
0 2 * * * tar -czf /backup/www-$(date +\%F).tar.gz -C /var/www .
No terminal, and nobody to answer questions
cron runs commands without an interactive TTY. A sudo that asks for a password, an ssh that wants a yes/no confirmation, or any tool waiting for an answer will either die or hang, so everything has to be non-interactive (a -y switch, batch mode, key-based authentication). The HOME variable is set from /etc/passwd, but the working directory is also HOME, not your project folder, so relative paths break. Start the command with cd /opt/app or make the paths in the script absolute.
grep CRON /var/log/syslog (on the RHEL family: /var/log/cron). Minimal Ubuntu 24.04 images may not have rsyslog installed, in which case there is no syslog file at all; the approach that works everywhere is the systemd journal: journalctl -u cron on Ubuntu and journalctl -u crond on AlmaLinux. This log only tells you that cron "called" the command, not that the command succeeded. To see errors, you need the next section.Keep the output: cron job logs, MAILTO and flock
By default, cron sends any output (stdout and stderr) by local email to the owner of the crontab. But most VPSes have no MTA (such as Postfix) installed, so that email goes nowhere and the error message simply vanishes. Debian's cron is honest enough to say so in its log: (CRON) info (No MTA installed, discarding output). The simple, permanent fix is to redirect to a log file:
30 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
>> means "append to the end of the file" and 2>&1 means "send errors to the same place". If a night goes badly, you have evidence in the morning; just hand growing log files over to logrotate.
If your server has an MTA or an email relay, you can get reports by email too: set the MAILTO variable at the top of the crontab. An empty value (MAILTO="") turns sending off entirely:
MAILTO=admin@example.com
0 4 * * 1 /usr/local/bin/weekly-report.sh
flock in cron: don't let runs pile up
A job that runs every 5 minutes but sometimes takes 7 gradually piles up several simultaneous copies of itself on the server: two parallel rsync runs, two simultaneous dumps of the same database, and a load that each run makes heavier for the next. The standard fix is flock. Its -n switch says that if the previous copy still holds the lock, this run should quietly skip:
*/5 * * * * flock -n /var/lock/bwsnap.lock /usr/local/bin/bwsnap.sh >> /var/log/bwsnap.log 2>&1
User crontab vs /etc/crontab and cron.d: the sixth field that catches everyone out
Cron jobs have three main homes, and what separates them is exactly one field. A personal crontab has five fields and runs with its owner's permissions, but /etc/crontab and the files in /etc/cron.d/ are system-wide and have one extra field: the username the command runs as, between the schedule and the command:
# /etc/crontab and /etc/cron.d/* use SIX fields: user before command
30 3 * * * root /usr/local/bin/backup.sh
# a personal crontab (crontab -e) uses FIVE fields, no user column
30 3 * * * /usr/local/bin/backup.sh
The classic blunder cuts both ways. You copy a line from /etc/crontab into a personal crontab, and cron tries to run a command called root ("root: command not found"). Or the other way round: you put a five-field line in cron.d, and cron takes the first word of the command to be a username, a user that doesn't exist, so the job never runs. A subtler point: on Debian and Ubuntu, file names in /etc/cron.d may contain only letters, digits, underscores and hyphens. The cron(8) documentation says explicitly that dots are not allowed, so a file called backup.sh there is silently ignored. cronie on AlmaLinux doesn't document this restriction, but follow the same rule anyway.
The third route is the /etc/cron.daily, cron.weekly and cron.monthly folders: put an executable script in one of them and run-parts will run it, often with the help of anacron, which makes up for runs a powered-off server missed once it is switched back on (Ubuntu Server doesn't install anacron by default, and /etc/crontab itself runs these folders at a fixed time). On Debian and Ubuntu the same naming rule applies here too (AlmaLinux's run-parts accepts dots and skips only leftover files, such as those ending in .rpmsave or .rpmnew), and run-parts --test shows, without running anything, which files are actually picked up:
run-parts --test /etc/cron.daily
# /etc/cron.daily/apt-compat
# /etc/cron.daily/dpkg
# /etc/cron.daily/logrotate
# /etc/cron.daily/man-db
Cron time zone: your 3:30 is not the server's 3:30
cron works on the system's local time, and most cloud images are delivered set to UTC. Whatever your own time zone, a job scheduled for 3:30 fires at 3:30 on the server's clock, not on yours. Take a reader in Tehran (UTC+3:30): a "3:30 a.m." backup actually runs at 7:00 a.m. local time, right at the start of the busy hours. In that example the same gap also moves the boundary of a "day" in vnstat's daily reports by 3.5 hours.
timedatectl | grep "Time zone"
# Time zone: Etc/UTC (UTC, +0000)
sudo timedatectl set-timezone Asia/Tehran
sudo systemctl restart cron # cron reads the timezone at startup
Use your own zone in place of Asia/Tehran; timedatectl list-timezones lists them all. The final restart matters: cron reads the time zone when it starts and keeps using the old one until it is restarted (on AlmaLinux the service is called crond). Also note one difference between the two families: cronie lets you run a single table in a different zone with a CRON_TZ=Asia/Tehran variable at the top of the crontab, but the cron on Debian and Ubuntu has no such option and runs every job in one time zone, the system's.
Test it now, trust it tomorrow
Run the command in a cron-like environment
Instead of waiting until midnight, run the same command right now in a minimal environment. env -i throws away all of your shell's variables and keeps only what you pass in yourself, exactly like cron:
env -i HOME="$HOME" PATH=/usr/bin:/bin SHELL=/bin/sh /bin/sh -c '/usr/local/bin/backup.sh'
If it works here, it will work in cron. If it fails, you see right in front of you the same error that cron was silently swallowing.
* * * * * (every minute) and watch the log with tail -f; after one or two successful runs, put the real schedule back. Those two minutes of waiting are faster than any amount of guessing.Monitoring: silence means bad news
A log file is only useful to someone who reads it, and nobody reads the backup log every day. For critical jobs, turn the logic around: the script calls a URL at the end of a successful run, and if that call doesn't arrive on time, the monitoring system raises an alert. This pattern is called a dead man's switch, and services such as Healthchecks are built for exactly this:
# success ping: silence means trouble
30 3 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 --retry 3 https://hc-ping.com/your-uuid-here >/dev/null
The complementary pattern is an alert only on failure, for example a Telegram message. Define the variables at the top of the crontab so the main line stays readable:
TOKEN=123456:AAErealtokenhere
CHAT=11111111
30 3 * * * /usr/local/bin/backup.sh || curl -s -X POST "https://api.telegram.org/bot$TOKEN/sendMessage" -d chat_id="$CHAT" -d text="backup FAILED on $(hostname)"
The difference matters: a failure alert only catches the command itself failing, while the ping pattern also gives away a server that is down or a cron daemon that has stopped working. Building a complete Telegram alert, together with monitoring of RAM, CPU and traffic, is covered in our Linux server monitoring guide.
Systemd timer vs cron: an honest comparison
Every modern Linux distribution has systemd, and systemd has its own scheduler: timer units. Neither is "better" in absolute terms; the two tools are built for two kinds of work:
| Criterion | cron | systemd timer |
|---|---|---|
| Setup | One line in the crontab | Two files (service + timer) and a few commands |
| Making up a missed run (server was off) | No (unless you add anacron) | Persistent=true: it runs at the next boot |
| Logging | You have to redirect it yourself | Automatic, in journalctl |
| Preventing overlapping runs | Manual, with flock | Built in: it won't start again while the service is still active |
| Spreading the load | No (cronie: the RANDOM_DELAY variable at the top of the crontab) | RandomizedDelaySec |
| Keeping a long-running process alive | Not cron's job | Restart=always |
The plain verdict: for a simple periodic job, cron is perfectly adequate and takes no more than one line. Reach for a systemd timer when making up missed runs is critical, when you want structured logs, or when the job depends on another service. And one rule without exceptions: systemd keeps a long-running process alive, not cron. There is an example just below.
WordPress cron jobs and shared hosting: what to do without a terminal
WordPress has its own built-in scheduler (WP-Cron), but it is woken up by visits: every time someone opens a page, WordPress checks whether any job is overdue. That causes two problems at once. On a low-traffic site, scheduled posts and plugin backups run hours late, and on a busy site the same extra check repeats on every request. The standard fix is to turn this behavior off and hand the work to a real cron:
# wp-config.php
define( 'DISABLE_WP_CRON', true );
# crontab: fire WP-Cron every 5 minutes instead of on page views
*/5 * * * * /usr/bin/wget -q --delete-after https://example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1
# or, with WP-CLI installed, run only the events that are actually due
*/5 * * * * cd /var/www/example.com && /usr/local/bin/wp cron event run --due-now >/dev/null 2>&1
On a VPS, you put one of these two lines in the web server user's crontab -e; in our guide to speeding up WordPress this is one of the first steps. On shared hosting you have no terminal, but the same cron is there behind the scenes. In cPanel, the Cron Jobs page under the Advanced section fills in the same five fields, with a Common Settings menu to help, and in DirectAdmin the same page sits under Advanced Features. Both panels remind you of two things. Give absolute paths: for a PHP script, for example, use the server's versioned binary such as /opt/cpanel/ea-php82/root/usr/bin/php, not a bare php. And either leave the Cron Email field empty or end every command with >/dev/null 2>&1; otherwise you will get an email every 5 minutes. cPanel's documentation warns that a very frequent job can start again before the previous run has finished. flock is usually available (it is part of util-linux): /usr/bin/flock -n $HOME/job.lock /opt/cpanel/ea-php82/root/usr/bin/php $HOME/script.php. If your host doesn't allow it, choose a sensible interval (5 to 15 minutes).
Three real crontab examples, ready to copy
1) A nightly 3:30 backup with tar
# crontab -e
30 3 * * * flock -n /var/lock/backup.lock /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
#!/bin/bash
# /usr/local/bin/backup.sh -- make it executable: chmod +x
set -euo pipefail
STAMP=$(date +%F) # inside a script, % needs no escaping
mkdir -p /backup
tar -czf /backup/www-$STAMP.tar.gz -C /var/www .
find /backup -name 'www-*.tar.gz' -mtime +7 -delete
Notice the subtle difference: in the crontab line the percent sign needed a backslash, inside the script it doesn't. That is one of the reasons we recommend always keeping the logic in a script and leaving the crontab to do nothing but scheduling. A more professional version, with incremental backups, encryption and transfer to an off-site destination, is in our Linux server backup guide.
2) A bandwidth snapshot every 5 minutes
*/5 * * * * /usr/local/bin/bwsnap.sh eth0 >/dev/null 2>&1
The bwsnap.sh script is the reboot-proof counter reader we built line by line in our VPS bandwidth guide. Because kernel counters are cumulative, a 5-minute interval loses no data; a shorter interval only uses CPU and adds no accuracy.
3) Keeping a Telegram bot alive: a job not to give to cron
The common, wrong pattern is * * * * * pgrep -f bot.py || python3 /opt/bot/bot.py. It means up to 59 seconds of downtime after every crash and, if the pgrep pattern is imprecise, two copies of the bot running in parallel and sending duplicate replies. The right tool is a systemd service with automatic restart:
# /etc/systemd/system/telegram-bot.service
[Unit]
Description=Telegram bot
After=network-online.target
Wants=network-online.target
[Service]
User=botuser
ExecStart=/usr/bin/python3 /opt/bot/bot.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now telegram-bot
Now the bot comes back a few seconds after every crash, and it starts on its own after a reboot too. Setting up a bot from scratch, with the token, the webhook and security tips, is covered in our guide to running a Telegram bot on a VPS.
Frequently asked questions
What is a cron job, and how is it different from crontab?
cron is the Linux scheduling service that checks its tables every minute; a cron job is one line in such a table, a five-field schedule plus a command. crontab is both the name of the table and the name of the command you use to view and edit it (crontab -l and crontab -e). So you write cron jobs into your crontab, and the cron daemon runs them on time.
Why does my command work in the terminal but not as a cron job?
Because cron doesn't read your shell profile. Its PATH is usually just /usr/bin:/bin, its default shell is sh rather than bash, it has no interactive terminal, and it treats the percent sign as a newline. Write absolute paths, escape % with a backslash, and test the command once in a minimal environment with env -i so you can see the real error.
How can I be sure my nightly cron job actually ran?
Build three layers of evidence: the run record in syslog (grep CRON /var/log/syslog), the job's output redirected to a log file with >> and 2>&1, and, for critical jobs, a dead man's switch. With that pattern, the script calls a URL when it finishes successfully, and you get an alert if the call doesn't arrive. The third layer even exposes a server that is down or a cron daemon that has stopped.
Is a cron job or a systemd timer better?
For simple periodic jobs, cron is enough and far simpler. A systemd timer is worth the extra setup when you need missed runs made up after downtime, automatic logging in journalctl, or dependencies on other services. Keeping a long-running process alive, such as a Telegram bot, isn't cron's job at all; hand it to a systemd service with Restart=always.
How do I set up a cron job in cPanel shared hosting?
Open the Cron Jobs page under the Advanced section of cPanel: it has the same five Linux schedule fields, and its Common Settings menu fills them in for cases such as every 5 minutes or once a day. In DirectAdmin the page is under Advanced Features. Use absolute paths, such as the versioned PHP binary /opt/cpanel/ea-php82/root/usr/bin/php, and leave Cron Email empty or end the command with >/dev/null 2>&1 to avoid an email on every run.
Next step
Start today: give every cron job you already have a log file, make its paths absolute, check your server's time zone, and set up a health ping for the nightly backup. And if you don't have a test server, create an hourly cloud server: it is ready in about 60 seconds, you set up a few real cron jobs, break them and fix them, and you pay only for the hours the server existed. Delete it when you are done and the charges stop.