VPS Tutorials
VPS Scheduled Tasks Not Running (2026): cron, crontab, systemd timer, and environment variable troubleshooting checklist
When crontab or systemd timer tasks don't run on a VPS, the common cause isn't the command itself but the executing user, PATH environment, working directory, permissions, time zone, service not enabled, or logs not being read correctly. This article troubleshoots layer by layer through the cron service, task syntax, logs, environment variables, systemd timer, and task idempotency.

Scheduled tasks not running on a VPS is one of the easiest things to misdiagnose.
The same command runs fine when you execute it manually over SSH, but it just won't run once you put it into crontab; the script runs fine on its own, but the automatic backup at dawn produces no file; systemctl status doesn't look red either, but the business side just never receives the result. In most cases, this isn't "Linux scheduled task mysticism" but rather the execution environment being completely different from when you log in manually.
This article follows a troubleshooting order: first confirm whether cron or the systemd timer is actually running, then look at logs, users, paths, permissions, environment variables, and time zones, and finally deal with the script's own idempotency and alerting.
Don't start by editing the crontab. First split the problem into two categories:
- Never triggered: there's no task execution record in the logs, usually a problem with the cron service, syntax, user, or timer not being enabled.
- Triggered but failed: there's a record in the logs, but the script errors out, permissions are insufficient, the command can't be found, or the output file isn't generated.
- Executed but the result isn't as expected: for example, the backup file is empty, syncing to object storage fails, or the restart script runs repeatedly.
Looking at the system logs first will save a lot of time. If you're not yet familiar with journalctl and service logs, you can read this first: How to read VPS logs (journalctl, systemctl, Nginx, application logs).
On Debian / Ubuntu it's usually cron, and on RHEL / CentOS / Rocky it's usually crond.
systemctl status cron
systemctl status crond
If the service isn't started:
sudo systemctl enable --now cron
Or:
sudo systemctl enable --now crond
Then look at recent logs:
journalctl -u cron --since "2 hours ago"
journalctl -u crond --since "2 hours ago"
If the corresponding service doesn't exist on the system, first confirm whether the cron package is installed:
dpkg -l | grep cron
rpm -qa | grep cron
Some minimal VPS images don't preinstall cron, especially containerized images or very clean cloud images. Don't assume by default that it must exist.
This is one of the most common pitfalls.
You run as a regular user:
crontab -e
This edits the current user's scheduled tasks. You run as root:
sudo crontab -e
This edits root's scheduled tasks. The two are not the same file.
Check the current user's tasks:
crontab -l
Check root's tasks:
sudo crontab -l
If the script needs to access /root/.ssh, read a backup directory that only root can read, or restart services, it usually needs to go in root's crontab or be changed to a systemd timer. Conversely, if the script depends on a regular user's project directory, Python virtualenv, or Node version manager, don't casually put it in root's crontab.
First add a task that only writes a log, don't call a complex script directly:
* * * * * date >> /tmp/cron-test.log 2>&1
Wait 1-2 minutes and check:
cat /tmp/cron-test.log
If even this produces no output, the problem is with the cron service, the crontab file, or the system time.
If this produces output but your script can't run, the problem is most likely with the script path, permissions, environment variables, or the running user.
Remember to delete this task after testing to avoid writing to /tmp long term.
A user crontab is 5 time fields plus the command:
分 时 日 月 周 命令
For example, to run at 3:10 AM every day:
10 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
But files in /etc/crontab and /etc/cron.d/ have an extra "user" field:
10 3 * * * root /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
Many people copy the user crontab format into /etc/cron.d/, or copy it the other way around, which causes tasks not to run as expected.
A few more things to note:
- It's best to keep a trailing newline at the end of the crontab file.
- Filenames in
/etc/cron.d/shouldn't have strange suffixes, and permissions are usually644. - The
%in commands has special meaning in cron; date formats likedate +%Fneed to be written asdate +\%F.
You can execute this over SSH:
node app.js
python script.py
rclone sync ...
That doesn't mean cron can find these commands too. cron's environment is very clean, and PATH is often only a very short segment.
First find the full path of the command:
which node
which python3
which rclone
which mysqldump
Write the absolute path in the crontab:
10 3 * * * /usr/bin/python3 /opt/jobs/cleanup.py >> /var/log/cleanup.log 2>&1
Or explicitly set PATH at the top of the crontab:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
If you use user-level environments like nvm, pyenv, conda, cron won't load them by default. A more reliable approach is to write a wrapper script that loads the environment inside the script and then executes the real command.
When executing manually, you might be standing in the project directory:
cd /var/www/app
npm run task
When cron executes, the working directory isn't necessarily here. If the script uses relative paths, such as ./backup, .env, logs/app.log, it's very easy to fail.
It's recommended to write the task as:
15 2 * * * cd /var/www/app && /usr/bin/npm run task >> /var/log/app-task.log 2>&1
If the script is important, it's best to fix the directory at the start of the script:
#!/usr/bin/env bash
set -euo pipefail
cd /var/www/app
Then have the crontab call this script:
15 2 * * * /usr/local/bin/app-task.sh >> /var/log/app-task.log 2>&1
When the script doesn't have execute permission, the cron log might only leave a line like Permission denied.
Check:
ls -l /usr/local/bin/app-task.sh
Fix:
sudo chmod +x /usr/local/bin/app-task.sh
If the task needs to write to a backup directory, log directory, or upload temporary files, also confirm the running user has permission:
sudo -u www-data /usr/local/bin/app-task.sh
sudo -u backup /usr/local/bin/backup.sh
Running it manually as the target user is more informative than running it once as root.
A scheduled task without logs doubles the troubleshooting cost.
At minimum write it like this:
30 1 * * * /usr/local/bin/backup.sh >> /var/log/backup-cron.log 2>&1
If the log will grow over time, pair it with logrotate, or have the script write logs by date itself. Don't let scheduled tasks fail silently.
You can also add this at the start of the script:
set -euo pipefail
This exposes undefined variables, failed pipes, and failed commands earlier. Just test before adding this line to production scripts, to avoid turning steps that originally tolerated failure into hard failures.
VPS images often default to the UTC time zone. You think it runs at 2 AM every day, but it actually runs at 10 AM Beijing time.
Check the time zone:
timedatectl
If you need to change it to the Shanghai time zone:
sudo timedatectl set-timezone Asia/Shanghai
But I'd recommend: plan production tasks uniformly in UTC and write the conversion clearly in the documentation. This way it's less likely to get confusing when migrating to VPSs in different regions.
If your tasks are related to certificate renewal, backup windows, or database low-peak periods, the time zone is especially important. For certificate renewal, see: What to do when Let's Encrypt certificate renewal fails on a VPS.
If you're using a systemd timer, don't only look at .timer.
View the timer:
systemctl list-timers --all
systemctl status my-job.timer
View the service that actually executes:
systemctl status my-job.service
journalctl -u my-job.service --since "24 hours ago"
A timer is only responsible for triggering; what actually executes is the service with the same name or the specified service. Common problems include:
- Only
.servicewas enabled, not.timer. .timertriggered, but.servicefailed to execute.OnCalendaris written wrong, so the next trigger time isn't what you think it is.WorkingDirectoryisn't set in the service, causing relative paths to fail.- The service's running user differs from the user you tested manually with.
A more reliable service example:
[Unit]
Description=Daily app cleanup job
[Service]
Type=oneshot
User=www-data
WorkingDirectory=/var/www/app
ExecStart=/usr/bin/php artisan app:cleanup
Corresponding timer:
[Unit]
Description=Run app cleanup daily
[Timer]
OnCalendar=*-*-* 03:20:00
Persistent=true
[Install]
WantedBy=timers.target
Enable:
sudo systemctl daemon-reload
sudo systemctl enable --now app-cleanup.timer
Persistent=true works like this: after the machine is shut down and misses the execution time, it runs once on the next boot. This is very useful for backup, cleanup, and sync tasks, but be cautious with tasks like "send notifications", "charge fees", or "batch restart" to avoid concentrated triggering after recovery.
Scheduled tasks fear two things most: not running once, or running twice at the same time.
For example, a backup script originally finishes in 20 minutes, but one day the database grows larger and it runs for 90 minutes, while cron triggers every hour, so two backup processes compete for disk and network.
You can use flock for locking:
0 * * * * /usr/bin/flock -n /tmp/backup.lock /usr/local/bin/backup.sh >> /var/log/backup-cron.log 2>&1
If the previous run hasn't finished, the next one is skipped directly.
For database backups, object storage sync, and batch compression, it's best to add locking. For backup strategies themselves, see: VPS automatic backup solutions.
If your tasks run inside a Docker container, the host cron and the container cron are two separate things.
Common misconceptions:
- You can't see container tasks in the host's
crontab -l. - After the container restarts, cron inside the container isn't started.
- The container image doesn't have the cron package.
- Tasks write to the container's temporary filesystem, and files are lost after the container is rebuilt.
- The time zone inside the container differs from the host.
More recommended approaches:
- Simple tasks: host cron calls
docker exec. - Application framework tasks: use the framework's built-in scheduler, but ensure the process stays resident.
- Tasks with high observability requirements: use a systemd timer or a standalone worker instead of stuffing them into a temporary container.
When you encounter "scheduled task didn't run", check in this order:
systemctl status cronorsystemctl status crond.crontab -landsudo crontab -l, confirm the user isn't wrong.- Add
* * * * * date >> /tmp/cron-test.log 2>&1for a minimal trigger test. - Check the cron log or
journalctl -u cron. - Change the command to an absolute path.
- Specify
cd /path/to/projector set the working directory inside the script. - Check the script's execute permission and the target user's permissions.
- Write output to the log:
>> /var/log/job.log 2>&1. - Check
timedatectland the task's scheduled time. - If it's a systemd timer, check both
.timerand.service. - Add
flockto long tasks to avoid overlapping execution.
When VPS scheduled tasks don't run, the real problem usually isn't the "time expression" but the execution environment: different user, different PATH, different directory, different permissions, different time zone.
First use a minimal task to prove the scheduler can trigger, then break the real command into "path, user, directory, permissions, logs" and confirm each point one by one. As long as logs are visible, paths are absolute, and the user is clear, most cron and systemd timer problems can be located quickly.