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

  1. Run as the app’s user, not root (same file permissions as the web app).
  2. Use absolute paths for PHP, Python, Node and your project. Cron has a minimal environment. See cron environment variables.
  3. Load the app’s configuration (.env) the same way the app does.
  4. Prevent overlaps for long tasks (flock, or the framework’s locking).
  5. 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 scheduler service 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.

  • Cron
  • Web Development
  • Automation