Most web applications need scheduled work: sending digest emails, cleaning old records, generating reports, renewing subscriptions, syncing data. On a VPS, that usually means cron, either running tasks directly, or triggering the framework’s own scheduler every minute. Here’s how to set it up reliably for common stacks.
Cron basics: crontab guide.
Rules for app cron jobs
- Run as the app’s user, not root (same file permissions as the web app).
- Use absolute paths for PHP, Python, Node and your project. Cron has a minimal environment. See cron environment variables.
- Load the app’s configuration (
.env) the same way the app does. - Prevent overlaps for long tasks (
flock, or the framework’s locking). - Log output somewhere you’ll see it, and monitor that jobs actually run.
Create the crontab for the app user:
sudo crontab -u www-data -e # or the dedicated app user
Laravel
Laravel’s scheduler runs from one cron entry, and you define tasks in code:
* * * * * cd /var/www/app && /usr/bin/php artisan schedule:run >> /dev/null 2>&1
// routes/console.php (Laravel 11+)
Schedule::command('reports:daily')->dailyAt('02:00')->withoutOverlapping()->onOneServer();
onOneServer() (with a shared cache like Redis) prevents duplicate runs when several app servers run cron. See Laravel scheduler cron and Laravel queues with Supervisor.
WordPress
WP-Cron runs on page loads by default. That’s unreliable on quiet sites, and wasteful on busy ones. Disable it, and trigger it from system cron:
// wp-config.php
define( 'DISABLE_WP_CRON', true );
*/5 * * * * cd /var/www/example.com && /usr/local/bin/wp cron event run --due-now --quiet
See WordPress real cron.
Django
Run management commands directly:
0 3 * * * cd /opt/app && /opt/app/venv/bin/python manage.py clearsessions >> /var/log/app/cron.log 2>&1
*/10 * * * * cd /opt/app && /usr/bin/flock -n /tmp/sync.lock /opt/app/venv/bin/python manage.py sync_orders >> /var/log/app/cron.log 2>&1
Load environment variables via a wrapper script, or set -a; . /opt/app/.env; set +a in the command. For many scheduled tasks, or tasks that should run on workers, use Celery beat. See Celery workers.
Node.js
Two common approaches:
- System cron calls a script:
0 * * * * cd /opt/api && /usr/bin/node scripts/cleanup.js >> /var/log/api/cron.log 2>&1. - In-process schedulers (node-cron, BullMQ repeatable jobs): convenient, but beware of cluster mode, where each instance runs the job. Run schedulers in one dedicated process, or use a queue with repeatable jobs. See Node.js cluster mode.
Docker
Options:
- host cron runs
docker compose exec -T app php artisan schedule:run(simple); - a dedicated
schedulerservice in Compose, running the framework’s scheduler loop (php artisan schedule:work, Celery beat, or Supercronic for crontab files).
Avoid running cron inside the main app container. See Docker Compose in production.
systemd timers: an alternative
systemd timers give better logging (journalctl -u), dependencies, and catch-up after downtime (Persistent=true). They’re a good fit for important jobs. See systemd timers.
Monitoring scheduled jobs
Silent failures are the danger with cron. Use heartbeat monitoring: the job pings a URL when it succeeds, and you get alerted if the ping doesn’t arrive on time:
0 2 * * * /opt/app/bin/backup.sh && curl -fsS -m 10 https://status.example.com/api/push/abc123 > /dev/null
Uptime Kuma (push monitors) or Healthchecks.io work well. See Uptime Kuma and cron job not running.
Quick reference
| Stack | Cron entry |
|---|---|
| Laravel | * * * * * php artisan schedule:run |
| WordPress | wp cron event run --due-now + DISABLE_WP_CRON |
| Django | manage.py <command> (or Celery beat) |
| Node.js | node scripts/job.js (one scheduler only) |
| Overlap protection | flock -n /tmp/job.lock … |
| Monitoring | Heartbeat ping on success |
Related: background jobs and queues and cron expression examples.