Advanced task scheduling with cron and systemd timer

Every Linux VPS admin has written a cron job: */5 * * * * /usr/bin/php /var/www/check-queue.php. It runs, it works, you forget about it, until it silently fails, or overlaps with a previous run, or runs at a second that no clock on earth produces. Basic cron is good for simple time-based triggers. But real production scheduling needs logging, retries, dependency ordering, and the ability to see what actually happened without grepping /var/log/syslog by timestamp. This guide covers cron nâng cao techniques, logging patterns, MAILTO gotchas, flock for mutual exclusion, and then moves into systemd timers, the modern alternative that gives you real-time control, structured journal logs, and dependency chains that don't exist in cron. You will walk away with runnable configs for both tools, tested on a standard Linux VPS running Debian 12 or Ubuntu 24.04.
Prerequisites
- A Linux VPS running Debian 12, Ubuntu 24.04, or AlmaLinux 9, full root or sudo access.
- Basic familiarity with the command line and service management (
systemctl,journalctl). - An existing user account with a working cron setup (even if it's just one test job).
Why basic cron is not enough once you scale
Cron's design dates to the 1970s V7 Unix. It reads a crontab, forks a shell at the scheduled time, and sends STDOUT/STDERR to MAILTO if set, or to the system log. It does not log job start/finish, it does not prevent overlapping runs, and it has no concept of "this job depends on that job finishing first." If your VPS is running five database backups, three cache clears, and two API syncs per hour, basic cron will let them step on each other. That is where cron nâng cao begins, wrapping each job in a shell construct that handles logging, locking, and exit codes.
Step 1, Build a cron log wrapper that never loses output
A good cron job logs to a file with a timestamp, a job name, and a non-zero exit signal:
#!/bin/bash
# /usr/local/bin/cron-log.sh
JOB_NAME="$1"
shift
LOGFILE="/var/log/cron/${JOB_NAME}.log"
TS=$(date '+%F %T')
echo "[${TS}] START ${JOB_NAME}" >> "${LOGFILE}"
"$@" >> "${LOGFILE}" 2>&1
RC=$?
TS=$(date '+%F %T')
echo "[${TS}] EXIT ${JOB_NAME} code=${RC}" >> "${LOGFILE}"
exit ${RC}
Make it executable and create the log directory:
mkdir -p /var/log/cron
chmod 755 /usr/local/bin/cron-log.sh
Now a cron entry that calls it:
*/10 * * * * /usr/local/bin/cron-log.sh sync-orders /usr/bin/php /var/www/sync-orders.php
Verify: After the job runs, tail -2 /var/log/cron/sync-orders.log should show START and EXIT lines with the return code.
This pattern gives you per-job logs, timestamps, and exit visibility, something no single cron flag provides. For jobs on a Linux VPS where disk space is finite, set a logrotate rule on /var/log/cron/*.log with size-based rotation rather than weekly, because cron logs grow faster than you think.
Step 2, Prevent overlapping runs with flock
If a job takes longer than the interval between two cron invocations, you get a second instance running over the same data. flock from util-linux holds a file lock and exits immediately if the lock is held:
*/5 * * * * /usr/bin/flock -n /tmp/check-queue.lock /usr/bin/php /var/www/check-queue.php
The -n flag means "fail immediately if the lock is already taken", use -E 1 to get a non-zero exit code you can catch in the wrapper. Without it, the second instance waits for the lock to release, which is useful for serializing jobs that must run sequentially but are not time-critical.
Test it: Run the command twice from two terminals. The second one should print "Resource temporarily unavailable" and exit. If your job absolutely must run every cycle, drop the -n and let it wait, but then your cron interval becomes a minimum, not a guarantee.
Step 3, Add RANDOMDELAY to spread load
Standard cron does not support random delay. If ten jobs are scheduled at */5 * * * *, all ten fire at the same second. Some cron implementations (Vixie, cronie) support RANDOMDELAY in /etc/anacrontab, but the portable approach is to add a shell sleep inline:
*/5 * * * * /bin/sleep $((RANDOM \% 30)) ; /usr/bin/flock -n /tmp/cache-warm.lock /usr/local/bin/cron-log.sh cache-warm /usr/bin/php /var/www/cache-warm.php
This adds a random 0 to 29 second delay before the job starts. The percentage sign needs escaping (\%) in the crontab. Adjust the maximum (30, 60, 120) depending on the job's expected duration and how many jobs share the same window.
Why this matters: On a VPS with limited vCPUs, ten simultaneous PHP processes can spike CPU and cause transients in response time. Randomizing the start window smooths the load without adding a central coordinator.
Step 4, Switch to systemd timer for fine-grained control
Systemd timers replace the entire cron model. Instead of a single crontab per user, you write a service unit (what to run) and a timer unit (when to run). The two files live in /etc/systemd/system/.
Service unit, /etc/systemd/system/cleanup-log.service:
[Unit]
Description=Rotate old log entries
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/cleanup-log.sh
StandardOutput=journal
StandardError=journal
Timer unit, /etc/systemd/system/cleanup-log.timer:
[Unit]
Description=Run cleanup-log daily at 03:00
[Timer]
OnCalendar=daily
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now cleanup-log.timer
Verify: systemctl list-timers shows the next trigger time. journalctl -u cleanup-log.service -n 20 shows the full output with structured fields.
Key advantages over cron: RandomizedDelaySec is built-in, no shell hack. Persistent=true catches a missed trigger if the system was offline. Standard output goes to the journal, indexed by unit name, not mixed with kernel messages in syslog. And because each timer is a systemd unit, you can start, stop, or mask it at runtime without editing a file. If your Windows VPS brothers need similar reliability, Task Scheduler with triggers can approximate this, but systemd timers do it natively on Linux.
Step 5, Build a service chain with dependencies
Cron has no run-order. Systemd does. Create two services and one timer:
/etc/systemd/system/db-backup.service:
[Unit]
Description=Backup all databases
Before=sync-to-s3.service
/etc/systemd/system/sync-to-s3.service:
[Unit]
Description=Upload backups to S3
After=db-backup.service
Requires=db-backup.service
/etc/systemd/system/backup-chain.timer:
[Timer]
OnCalendar=*-*-* 04:00:00
RandomizedDelaySec=5m
If db-backup.service fails (non-zero exit), sync-to-s3.service never starts. That is a dependency chain, something cron cannot do without a wrapper script that checks exit codes and calls exit 1 explicitly. For a production WordPress VPS running nightly database dumps, this pattern prevents uploads of partial or corrupt files.
Troubleshooting
Job runs at the wrong time
Systemd timers respect the system timezone. Check timedatectl show --property=Timezone. For cron, grep '^CRON_TZ' /var/spool/cron/crontabs/root (or the user file). A mismatched TZ is the most common reason a job fires an hour early or late.
Cron job runs but produces no visible output
Check MAILTO in the crontab, if unset, cron discards STDOUT. The log-wrapper from Step 1 solves this. Also confirm the user's crontab is active: crontab -l -u deploy shows the file, and systemctl status cron (or crond on AlmaLinux) shows the daemon is running.
Timer shows "failed" in list-timers
journalctl -u myjob.service -b --no-pager shows the exact failure reason, a missing executable, a permission denied, a script that exited with code 1. Fix the script, then systemctl reset-failed myjob.service before the next trigger.
FAQ
Can I run systemd timers as a non-root user?
Yes. Create ~/.config/systemd/user/ and run systemctl --user daemon-reload. The timer runs under the user's session and does not need root. To enable it at boot, run sudo loginctl enable-linger username.
Should I migrate all cron jobs to systemd timers?
Not necessarily. Cron is simpler for quick, one-off, time-only jobs. Switch to timers when you need logging, dependency ordering, randomized delay, or the ability to start/stop the job as a unit. A healthy setup uses both.
How do I test a timer without waiting for the scheduled time?
systemctl start myjob.service runs the service immediately. To test the timer logic, you can set OnCalendar=*-*-* *:*:00 temporarily, but the cleanest method is systemd-analyze calendar 'your-expression' to verify the calendar event computes as expected.
Does anacron replace cron for daily jobs?
Anacron catches missed runs after a system shutdown. On a VPS that rarely goes down, anacron adds complexity without benefit. Systemd's Persistent=true is functionally equivalent and simpler to audit.
What is the biggest mistake people make with cron nâng cao?
Not wrapping the command. A bare php /path/to/job.php in a crontab gives you zero visibility, no timestamp, no log, no exit code. The five extra characters to wrap it in flock and a log function save hours of debugging later.
Related articles
- Configure logrotate and manage logs on a VPS
- Set up a Prometheus Alertmanager pipeline on a VPS
- Configure swap and optimize memory on a low RAM VPS
- Using tmux for persistent terminal sessions on a VPS
Linux VPS 高级任务调度:cron 与 systemd 定时器
基础 cron 对简单时间触发够用,但生产环境需要日志、避免重叠运行、依赖排序和随机延迟。使用带 flock 和日志包装器的 crontab,或者完全改用 systemd timer(内置 RandomDelaySec、持久化触发和 service 依赖链)。建议混合使用:简单任务用 cron,需要依赖或实时控制的用 systemd timer。在 Linux VPS 上,journalctl 可替代 syslog grep,大幅降低排错时间。


