</>CodeWithKarani

Cron vs systemd Timers vs the Frappe Scheduler: Pick by Failure Mode, Not by Habit

Karani GeoffreyKarani Geoffrey8 min read

Almost every server I audit has scheduled work in at least two places, and the owner can only remember one of them. There is a crontab from 2022, a systemd timer somebody added during a migration, and an ERPNext scheduler quietly enqueuing jobs into Redis. Then a customer gets three copies of the same invoice reminder and nobody can explain it.

My thesis: choose your scheduler by what you want to happen when the job fails, not by which one you learned first. The three options have genuinely different answers to "what if it is already running", "what if the box was off", and "what if it errors" - and that is the whole decision.

The decision table

Questioncronsystemd timerFrappe scheduler
Overlapping runsStarts a second copyRefuses; unit is already activeDeduplicates by job name
Missed while box was offSkipped silentlyPersistent=true catches upCatches up on next tick
Where the output goesEmail, or nowhereThe journal, with the unit nameRQ Job records and worker logs
On failureNothingOnFailure= unit firesJob marked failed, retained in Redis
EnvironmentMinimal, hostileExplicit and declarativeFull app and DB context
Resource limitsNoneMemoryMax, CPUQuota, NiceQueue timeouts

Read that table once and the rule writes itself:

If the job needs your application's database session, use the application's scheduler. If it needs the machine, use a systemd timer. Use cron only when there is no systemd (containers, shared hosting) or the job is one trivially idempotent line.

Cron: what is actually wrong with it

Cron is not bad, it is bare. Four things bite in production.

The environment is not your environment. Cron gives you PATH=/usr/bin:/bin and no shell profile. Your nvm-installed node, your pyenv python, your ~/.local/bin - none of it exists. Prove it to yourself:

* * * * * /usr/bin/env > /tmp/cronenv.txt 2>&1

Percent signs are special. In a crontab, % becomes a newline and everything after the first one becomes stdin. So date +%F inside a crontab line silently produces garbage. Escape it as date +\%F or, better, put the command in a script.

Nothing stops overlap. A five-minute sync that occasionally takes seven minutes will eventually have four copies running, all fighting over the same rows.

Output goes nowhere. Unless MAILTO is set and a mail transport exists, stdout and stderr are discarded. Your job has been failing for three months.

Cron done properly

SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""
CRON_TZ=Africa/Nairobi

*/5 * * * * /usr/bin/flock -n /run/lock/mpesa-sync.lock /usr/local/bin/mpesa-sync.sh 2>&1 | /usr/bin/logger -t mpesa-sync

flock -n is the single most valuable thing you can add to a crontab: it takes the lock or exits immediately, so overlap is impossible. Piping to logger -t puts the output in the journal where you can find it with journalctl -t mpesa-sync. Check that your cron implementation supports CRON_TZ with man 5 crontab before relying on it.

And every script cron runs should start the same way:

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
cd /srv/app

Without set -e, a failed step in the middle carries on to the next one and your job "succeeds" while doing half the work.

systemd timers: more typing, far more control

A timer is two files. Yes, that is more work than one crontab line. What you buy is a named unit with logs, dependencies, resource limits, catch-up and failure handling.

# /etc/systemd/system/mpesa-sync.service
[Unit]
Description=Reconcile M-Pesa transactions
After=network-online.target mariadb.service
Wants=network-online.target
OnFailure=notify-failure@%n.service

[Service]
Type=oneshot
User=app
WorkingDirectory=/srv/app
EnvironmentFile=/etc/app/env
ExecStart=/srv/app/.venv/bin/python -m app.jobs.mpesa_sync
TimeoutStartSec=10m
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
MemoryMax=512M
# /etc/systemd/system/mpesa-sync.timer
[Unit]
Description=Run M-Pesa reconciliation every 5 minutes

[Timer]
OnBootSec=3min
OnUnitActiveSec=5min
AccuracySec=30s
RandomizedDelaySec=20
Persistent=true
Unit=mpesa-sync.service

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now mpesa-sync.timer
systemctl list-timers --all | head
journalctl -u mpesa-sync.service --since today

OnUnitActiveSec=5min means "5 minutes after the last run finished", which is subtly different from cron's "every 5 minutes on the clock" - and it is usually what you actually wanted, because it never overlaps. For clock-aligned schedules use OnCalendar instead:

[Timer]
OnCalendar=Mon..Fri *-*-* 08:00:00 Africa/Nairobi
OnCalendar=*-*-01 02:00:00 Africa/Nairobi
Persistent=true
RandomizedDelaySec=300

Timezone suffixes in calendar expressions need systemd 252 or newer, which Ubuntu 24.04 has. Always test the expression before you trust it:

systemd-analyze calendar "Mon..Fri *-*-* 08:00:00 Africa/Nairobi"
systemd-analyze calendar --iterations=5 "*-*-01 02:00:00"
  Original form: Mon..Fri *-*-* 08:00:00 Africa/Nairobi
Normalized form: Mon..Fri *-*-* 08:00:00 Africa/Nairobi
    Next elapse: Wed 2026-07-22 08:00:00 EAT
       (in UTC): Wed 2026-07-22 05:00:00 UTC
       From now: 4h 47min left

RandomizedDelaySec matters more than people think. If ten servers all run a daily backup at exactly 02:00, they all hit the same object storage endpoint at the same second. Spread them.

Need a one-off without writing files? systemd-run creates a transient unit:

sudo systemd-run --on-active=30min --unit=cache-warm /usr/local/bin/warm-cache.sh
systemctl list-timers cache-warm.timer

The failure notification unit

# /etc/systemd/system/notify-failure@.service
[Unit]
Description=Alert on failure of %i

[Service]
Type=oneshot
ExecStart=/usr/local/bin/notify.sh "%i failed on %H"

Add OnFailure=notify-failure@%n.service to any unit and every failure now reaches Slack, email or a webhook. That single template is the strongest argument for timers over cron.

The Frappe scheduler: not a cron at all

This is where ERPNext teams get confused, so be precise about the architecture. The Frappe scheduler is a long-running process that wakes on a tick, decides which scheduled events are due, and enqueues them into Redis queues. Separate RQ worker processes consume those queues. The scheduler does not execute your job - it only schedules it.

That means there are five independent things that can be broken, and "the scheduler is not working" could be any of them: the scheduler process, the site-level enable flag, the workers, Redis, or the queue being backed up.

# apps/myapp/myapp/hooks.py
scheduler_events = {
    "all": [
        "myapp.tasks.poll_payment_gateway"      # every tick, roughly 4 minutes
    ],
    "hourly": [
        "myapp.tasks.sync_stock_levels"
    ],
    "daily": [
        "myapp.tasks.email_daily_summary"
    ],
    "daily_long": [
        "myapp.tasks.rebuild_analytics"          # long queue, 1500s timeout
    ],
    "weekly": [
        "myapp.tasks.archive_old_logs"
    ],
    "cron": {
        "0 2 * * *":    ["myapp.tasks.nightly_reconciliation"],
        "*/15 * * * *": ["myapp.tasks.retry_failed_webhooks"]
    }
}

Changes to hooks.py only take effect after a migrate, because Frappe materialises them into Scheduled Job Type records:

cd /home/frappe/frappe-bench
bench --site erp.example.co.ke migrate
bench restart

For work triggered by user actions rather than a clock, enqueue directly:

import frappe

def on_submit(doc, method):
    frappe.enqueue(
        "myapp.tasks.push_invoice_to_kra",
        queue="long",
        timeout=1500,
        enqueue_after_commit=True,
        job_name=f"kra-{doc.name}",
        invoice=doc.name,
    )

enqueue_after_commit=True is not optional in real systems. Without it the job can start in a worker before the transaction commits, and the worker looks up a document that does not exist yet. That race produces the classic "DoesNotExistError on a document I am literally looking at" bug.

Diagnosing it when it stops

cd /home/frappe/frappe-bench
bench doctor
bench --site erp.example.co.ke show-pending-jobs
bench --site erp.example.co.ke enable-scheduler
bench --site erp.example.co.ke doctor
-----Checking scheduler status-----
Scheduler disabled for erp.example.co.ke
Scheduler inactive for erp.example.co.ke
Workers online: 3
-----erp.example.co.ke Jobs-----

Two different words, two different causes. Disabled means "pause_scheduler": 1 or a disabled flag in sites/<site>/site_config.json - very commonly left behind after a restore, because Frappe deliberately pauses the scheduler on a restored site so a copy does not start emailing your customers. Inactive means the scheduler process itself is not ticking.

cat sites/erp.example.co.ke/site_config.json | python3 -m json.tool
sudo supervisorctl status | grep -E "schedule|worker"
redis-cli -p 11000 ping        # queue redis
tail -50 logs/scheduler.log logs/worker.error.log

Under a standard bench setup production install, supervisor runs the pieces:

frappe-bench-frappe-schedule           RUNNING   pid 1412, uptime 5 days
frappe-bench-frappe-workers:...-0      RUNNING   pid 1415, uptime 5 days
frappe-bench-frappe-web:...-0          RUNNING   pid 1418, uptime 5 days

If the scheduler is RUNNING but nothing executes, look at the workers and the queue depth. A single 20-minute report in the long queue will not block short, but a flood of failing jobs in default absolutely will starve everything else. Clear a wedged queue with bench purge-jobs, but understand you are discarding real work.

The anti-patterns to go and delete tonight

  • A cron entry that calls a bench command with the same schedule as a scheduler event. Now the job runs twice and your customers get duplicate emails. Pick one.
  • A cron entry running bench as root. It will fail on file permissions or, worse, succeed and leave root-owned files in the bench directory that break the next deploy. Always sudo -u frappe.
  • A systemd timer doing application work by shelling into the app. If it needs the ORM, put it in the app's scheduler where it gets transactions, retries and job records.
  • An app scheduler doing machine work. Log rotation, certificate renewal and backups should keep working when the application is broken - which is exactly when you need them most.
  • Timezone drift. Frappe's System Settings timezone is independent of the server clock. A cron string of 0 2 * * * fires at 02:00 server time. Align them: sudo timedatectl set-timezone Africa/Nairobi.

Find everything that is scheduled on your box

systemctl list-timers --all
sudo crontab -l; for u in $(cut -f1 -d: /etc/passwd); do sudo crontab -u "$u" -l 2>/dev/null | sed "s/^/[$u] /"; done
ls -la /etc/cron.d/ /etc/cron.daily/ /etc/cron.hourly/
cd /home/frappe/frappe-bench && bench --site all doctor

Run those four commands right now on your production server. I promise you will find at least one scheduled job you had completely forgotten about, and there is a reasonable chance it has been failing since the last upgrade.

#cron#systemd#frappe#erpnext#scheduling
Keep reading

Related articles