ERP

ERPNext Scheduler Shows Process Not Found in Docker — It's a False Negative

Frappe's System Health Report reports the scheduler as Process Not Found whenever the scheduler runs in its own container. It is a file-lock bug, not an outage. Here is why it happens, how to prove your scheduler is actually alive, and what the unmerged upstream fix does.

MManojAugust 5, 202614 min read
#erpnext#frappe#docker#kubernetes
Share

You open the System Health Report in ERPNext, and Scheduler Status says Process Not Found. Your scheduler container is up. Your background jobs are running. Nothing is actually wrong. In any deployment where bench schedule runs in its own container — which is every standard frappe_docker Compose and Kubernetes helm setup — that field is a false negative and always will be. The health check probes a file lock on config/scheduler_process, and config/ is not a shared volume, so the web container locks its own private copy of the file and concludes nobody is running the scheduler. It is tracked as frappe/frappe#40828, the fix is PR #40866, and as of 2026-08-05 that PR is open and unmerged — so there is no version you can upgrade to today.

Other things frappe_docker breaks that are not your fault

Container-layer surprises are a theme in split Frappe deployments. If your CSS and JS are 404ing after a build, see the frappe_docker asset breakage guide. If the scheduler genuinely is slow rather than falsely reported dead, start with MariaDB tuning for ERPNext.

The best bug reports are the ones where the software is wrong about itself. This is one of those: a diagnostic screen, added to help you find problems, reporting a problem that does not exist, on the single most common production topology. I am Manoj, ERP implementation lead at Mith Tech in Bengaluru, and this is the kind of thing that eats an afternoon before somebody thinks to check whether the jobs are actually running.

Does this bug apply to you?

The whole thing turns on one question: does the process that renders the System Health Report share a filesystem with the process running bench schedule? In local development under bench start they are sibling processes on one disk, and the check works perfectly — which is exactly why this never shows up until production.

InteractiveDeployment Topology Checkerlink

Pick how you actually run Frappe or ERPNext — single container, frappe_docker Compose split, Kubernetes helm, bare-metal single host, or bare-metal multi-host — and get a verdict on whether the Process Not Found false negative applies, why, and how to confirm it yourself.

Pick the way you actually run Frappe/ERPNext. The verdict comes from one question: do the process that renders the health report and the process that runs bench schedule share the filesystem holding config/?

Affected — expect a permanent false negative

The stock compose.yaml puts gunicorn in the backend service and `bench schedule` in a separate scheduler service. x-backend-defaults mounts exactly one volume, sites. config/ is not shared, so each container writes and locks its own private copy of config/scheduler_process.

config/ shared?
No
Scheduler Status field
Lies
Topologies affected
4 of 6
How to confirm it yourself

Run `ls -li /home/frappe/frappe-bench/config/scheduler_process` in both containers. Different inode numbers confirm it.

What to do next

Ignore the field. Check Scheduled Job Log recency instead (tab 1 of the recipe below).

Detail: No — config/ lives in each container's writable layer. The affected/not-affected split is read from frappe_docker’s compose.yaml, the production Containerfile VOLUME declaration, and frappe/helm’s deployment-scheduler.yaml.

The affected list is read straight from the deployment definitions. The production Containerfile in frappe_docker declares VOLUME for sites and logs only. The x-backend-defaults anchor in compose.yaml mounts exactly one volume, sites. The helm chart's deployment-scheduler.yaml mounts sites-dir and logs. In every one of those, config/ is created by bench init inside the image layer and stays in each container's own copy-on-write layer.

Why acquiring a lock is read as "dead"

The function at the centre of this is is_schduler_process_running() in frappe/utils/scheduler.py, at line 63 on the current develop HEAD. The spelling is not a typo in this article: the real function name is missing the second "e" — schduler. That is the string to grep for.

InteractiveFileLock Probe Explainerlink

Walk the five steps of the health check side by side in two worlds: a shared inode where the probe works as designed, and split containers where each process locks its own private copy of config/scheduler_process. See exactly where the inverted logic turns a healthy scheduler into Process Not Found.

The liveness check is inverted: it treats a successful lock acquisition as proof the scheduler is dead. That only works when both processes contend on the same inode. Step through both worlds side by side.

1. The scheduler starts and grabs the lock
Shared inode — one filesystem

start_scheduler() opens <bench>/config/scheduler_process and calls lock.acquire(blocking=False). It succeeds, and the lock is never released — the kernel holds it for the lifetime of the process.

Private inode — split containers

Identical. Inside the scheduler container, the lock is acquired on that container's own copy of config/scheduler_process — an inode in its private writable layer.

The function is really spelled is_schduler_process_running — missing the second “e”. That is the string to grep for in frappe/utils/scheduler.py. Note the file never stores a PID; its contents are irrelevant, only the lock matters.

The mechanism is worth stating plainly. start_scheduler() acquires a non-blocking FileLock on <bench>/config/scheduler_process and never releases it — the kernel drops it when the process exits. That held lock is the entire liveness signal. is_schduler_process_running() then tries to acquire the same lock: a Timeout means somebody else holds it, so the scheduler is alive; a successful acquire means nobody holds it, so the scheduler is dead. A successful lock acquisition is the failure signal.

flock locks are held against an open file description on a specific inode on a specific filesystem. They are not visible across containers with separate writable layers, and they are not visible across hosts. So in a split deployment the backend container's probe finds its own freshly-created, unlocked scheduler_process file, acquires it instantly, and returns False. Every time. Forever.

Two details make this worse than it needs to be. First, the file is not a PID file — its contents are never read, so there is nothing in it to cross-check. Second, in fetch_scheduler() the "Process Not Found" branch is evaluated first and short-circuits the Dormant, Active and Inactive branches, so a perfectly healthy enabled scheduler never gets a chance to report itself as anything else.

How to actually tell whether your scheduler is alive

Code recipeScheduler Liveness Recipelink

Three tabs of runnable checks and configuration: trustworthy liveness checks that read the shared database and Redis rather than the file lock, honest workarounds clearly labelled as hacks, and the upstream Redis-heartbeat fix with the verbatim buggy code it replaces. Copy button on each tab.

Trustworthy

Everything here reads the shared database or Redis, or looks at the scheduler container directly — none of it depends on the file lock. Note that `bench doctor` and `bench --site <site> scheduler status` are config-only checks: they report whether the scheduler is enabled, paused, or in maintenance mode, never whether the process is alive. In split containers they are actively misleading, which is why they are at the bottom of this tab.

bash
# ── 1. THE RELIABLE ONE: is the Scheduled Job Log still advancing?
# The scheduler's only job is to enqueue events. Every executed
# scheduled job writes a row to the SHARED database, so this
# cannot lie about which container did the work.

# MariaDB
SELECT
  MAX(creation)                               AS last_job,
  TIMESTAMPDIFF(SECOND, MAX(creation), NOW()) AS seconds_ago,
  COUNT(*)                                    AS jobs_last_hour
FROM `tabScheduled Job Log`
WHERE creation > NOW() - INTERVAL 1 HOUR;

# PostgreSQL
SELECT
  MAX(creation)                                    AS last_job,
  EXTRACT(EPOCH FROM (NOW() - MAX(creation)))::int AS seconds_ago,
  COUNT(*)                                         AS jobs_last_hour
FROM "tabScheduled Job Log"
WHERE creation > NOW() - INTERVAL '1 hour';

# Default scheduler tick is 240 seconds (DEFAULT_SCHEDULER_TICK),
# overridable via scheduler_tick_interval in common_site_config.json.
# Newer than a few ticks => your scheduler is ALIVE, whatever the
# System Health Report says.

# Same thing from bench console:
#   frappe.get_all("Scheduled Job Type",
#       filters={"stopped": 0},
#       fields=["name", "method", "frequency", "last_execution"],
#       order_by="last_execution asc", limit=10)
# Recent, advancing last_execution values = working scheduler.


# ── 2. LOOK AT THE SCHEDULER PROCESS DIRECTLY
docker compose ps scheduler
docker compose exec scheduler ps aux | grep "bench schedule"
docker compose logs --tail=100 scheduler

# Kubernetes (verify the real labels with kubectl get deploy first)
kubectl logs deploy/<release>-erpnext-scheduler --tail=100
kubectl exec deploy/<release>-erpnext-scheduler -- ps aux | grep schedule


# ── 3. PROVE THE FALSE NEGATIVE IN TWO COMMANDS
# Different inode numbers = the two containers are locking
# different files, so the probe can never work.
docker compose exec scheduler ls -li /home/frappe/frappe-bench/config/scheduler_process
docker compose exec backend   ls -li /home/frappe/frappe-bench/config/scheduler_process

# Or run the exact probe on both sides. Expected in a split
# deployment: True inside scheduler, False inside backend.
docker compose exec scheduler python -c \
  "import frappe; from frappe.utils.scheduler import is_schduler_process_running as r; print(r())"
docker compose exec backend python -c \
  "import frappe; from frappe.utils.scheduler import is_schduler_process_running as r; print(r())"


# ── 4. RQ / QUEUE CHECKS — useful, but not about the scheduler
docker compose exec backend bench --site <site> show-pending-jobs
# Redis-backed, so cross-container accurate. A GROWING backlog with
# no Scheduled Job Log progress = your WORKERS are down. A FLAT empty
# backlog with no progress = the SCHEDULER really is down.


# ── 5. CONFIG-ONLY CHECKS — read these as "is it switched on",
#     never as "is it alive". Both are misleading in split containers.
docker compose exec backend bench doctor
#   "Workers online: N" comes from rq.Worker.all() over shared Redis,
#   so it does see other containers — but `bench schedule` is NOT an
#   RQ worker and never appears in that count. bench doctor never
#   calls is_schduler_process_running(). Its scheduler lines are
#   is_scheduler_inactive() output: config only.

docker compose exec backend bench --site <site> scheduler status
#   -> "Scheduler is enabled for site <site>"
#   This checks NO process. is_scheduler_inactive() only reads
#   maintenance_mode, pause_scheduler, disable_scheduler and
#   System Settings.enable_scheduler. It will happily say "enabled"
#   with the scheduler container stopped. Good for ruling out
#   "someone disabled it", useless for "is it running".

Ask the database, not the dashboard

Query Scheduled Job Log for the newest row. The scheduler's only job is to enqueue events, and every executed scheduled job writes a row to the shared database — which no container-layer quirk can distort. The default scheduler tick is 240 seconds, overridable with scheduler_tick_interval in common_site_config.json. A newest row that is a few minutes old means your scheduler is doing its job.

Look at the container

docker compose ps scheduler, then docker compose exec scheduler ps aux | grep "bench schedule", then the last hundred lines of its logs. On Kubernetes, kubectl logs on the scheduler Deployment — check the rendered labels with kubectl get deploy first, since they depend on your release name and chart values.

Prove the inode mismatch

Run ls -li /home/frappe/frappe-bench/config/scheduler_process in both the backend and scheduler containers. Different inode numbers is the proof. You can go further and run the probe itself on both sides — in a split deployment it returns True inside the scheduler container and False inside backend. That asymmetry is the bug, reproduced in two commands.

Rule out the config-only causes

This is where bench --site <site> scheduler status earns its keep, and only here. It reads maintenance_mode, pause_scheduler, disable_scheduler and System Settings.enable_scheduler — configuration intent, no process check at all. It will cheerfully report "enabled" with the scheduler container stopped. Use it to rule out "someone switched it off", then stop trusting it.

Replace the signal with monitoring that works

Add a Compose healthcheck or a Kubernetes liveness probe on the scheduler service — those run inside the scheduler container, where a process check is meaningful. Then alert externally when the newest Scheduled Job Log row exceeds roughly three scheduler ticks. Do this from your monitoring stack, not from a Frappe Notification: a scheduler-driven alert cannot tell you the scheduler is down.

What about bench doctor?

bench doctor is genuinely useful and genuinely does not answer this question. Its Workers online: count comes from rq.Worker.all() over the shared queue Redis, so it does see workers in other containers — but bench schedule is not an RQ worker and never appears in that count. A healthy Workers online: 2 tells you nothing about the scheduler. The scheduler lines it prints all come from is_scheduler_disabled() and is_scheduler_inactive(), which are config-only. bench doctor never calls is_schduler_process_running() at all. Its queue depths are Redis-backed and accurate cross-container, which makes it a good tool for the adjacent question of whether your workers are keeping up.

The official Diagnosing The Scheduler page recommends bench doctor, show-pending-jobs and purge-jobs. That page predates containerised splits and does not mention the file-lock probe. There is also no documentation page for the System Health Report itself — the source file is the only reference, which is a large part of why this is confusing.

Walk this before you conclude anything is broken

InteractiveLiveness Checklistlink

Eight checks to work through before treating Process Not Found as an incident — from Scheduled Job Log freshness and the honest neighbouring fields, through the inode comparison, to the external alerting that should replace this status field entirely.

Walk all eight before you conclude anything is broken. If the first five pass, “Process Not Found” is the false negative and there is nothing to fix.

0 / 8

Nothing here is saved — this is a checklist for your current setup, not your ERPNext instance.

What to know before you commit to a workaround

Sharing config/ works, on exactly one topology. A named Docker volume mounted at /home/frappe/frappe-bench/config in both backend and scheduler gives them the same inode, and the probe starts working. Use a named volume, not a host bind mount — Docker seeds a named volume from the image contents on first use, while a bind mount would blank the directory and take bench-generated config with it. This is single-host Compose only. It does not work across Kubernetes nodes or multi-host Swarm.

It is a symptom fix. You still have a filesystem lock doing distributed coordination, which is the actual design problem. And it changes behaviour you may not have been thinking about: with a genuinely shared config/, a second scheduler on the same volume will now correctly refuse to start. That is arguably an improvement, but it is a change.

Do not reach for a shared RWX PVC on Kubernetes. Advisory flock over NFS and many CSI RWX drivers is unreliable or a silent no-op. It can appear to work, then regress — or worse, let two schedulers both acquire the lock and both run. Never use file locks for cross-node coordination.

Running bench schedule inside the backend container is a dev-box move. It collapses the split so the probe's assumption holds, but you lose independent restart and scaling, and any backend replica count above 1 gives you N schedulers racing, because the flock only prevents duplicates within one filesystem.

Patching the framework is rarely worth it. frappe_docker fully supports custom image builds, so you can apply the PR #40866 diff yourself. If you already maintain a custom image, fine. If you do not, forking framework internals to fix a cosmetic status field buys you a permanent upgrade tax.

Where the upstream fix stands

The design in PR #40866 is the obvious one: publish a scheduler heartbeat key in queue Redis with a TTL, refresh it while the scheduler sleeps and again before each site, and read that key in the health report. Redis is already shared across every container, so it is the natural cross-container liveness channel, and TTL expiry gives you automatic dead-detection. The file lock stays, for the job it is actually good at — preventing duplicate schedulers on one filesystem. A new Redis Unavailable status separates "I cannot tell" from "it is dead".

Review found and closed one real gap: the heartbeat was only refreshed while sleeping, so a long multi-site enqueue pass could let the 120-second key expire mid-run. The accepted answer was a per-site refresh inside the loop. A background heartbeat thread was proposed and deliberately rejected — if a single site takes longer than the TTL to enqueue, something genuinely is stuck, and a thread would mask exactly the signal you want.

The PR has been approved by automated review, has full coverage on the modified lines, and has been pinged by the author and by a third affected user. It is stalled on maintainer bandwidth, not on technical objections. Reacting or commenting on it is, honestly, the highest-leverage thing most readers of this post can do. Re-check the PR before acting on any of this — a merge is the most perishable fact here.

+Why does ERPNext System Health Report say Process Not Found for the scheduler when my scheduler container is running?

Because the check is a file-lock probe on <bench>/config/scheduler_process, and config/ is not a shared volume in frappe_docker or the helm chart. The backend container locks its own private copy of that file, succeeds, and concludes nobody is holding it — which the code reads as "the scheduler is not running". Your scheduler is almost certainly fine. Confirm with Scheduled Job Log recency. Tracked as frappe/frappe#40828.

+Is Process Not Found in the Frappe System Health Report a real outage or a false alarm?

In a split Docker Compose or Kubernetes deployment it is a false alarm if Scheduled Job Log rows are still being created within a few scheduler ticks — the default tick is 240 seconds. It is a real outage if the log is stale and your scheduler container or pod is down or restart-looping. The two neighbouring fields in the same report, "Failing Scheduled Jobs" and "Oldest Unscheduled Job", are database-backed and remain trustworthy.

+How do I check if the Frappe scheduler is actually running in Docker?

Do not use the System Health Report. Run docker compose ps scheduler and docker compose logs --tail=100 scheduler, then verify jobs are advancing by querying the newest Scheduled Job Log row. If it is within a few minutes, the scheduler is alive. bench --site <site> scheduler status reports configuration only, not liveness, so it cannot answer this.

+Hey Google, why is my ERPNext scheduler showing process not found in Docker?

Because Frappe checks whether the scheduler is alive by trying to grab a file lock, and in Docker your web container and your scheduler container each have their own separate copy of that file. The web container grabs its own lock successfully and assumes nothing is running. The scheduler is fine — check whether scheduled jobs are still being logged.

+How can I tell if the ERPNext background scheduler is really working in Kubernetes?

Look at whether scheduled jobs are still finishing, not at the health report. Run kubectl logs on the scheduler pod, and query the Scheduled Job Log table for the most recent entry. If new rows are appearing every few minutes, the scheduler is doing its job even if the dashboard says otherwise. Sharing a volume will not help you here, because the pods can be on different nodes.

+Which Frappe or ERPNext version fixes the scheduler Process Not Found false negative in containers?

None yet, as of 2026-08-05. The code is present unchanged on develop, version-15, version-16, version-16-hotfix, and the newest release tag v16.30.0. The fix — PR frappe/frappe#40866, replacing the file lock with a Redis heartbeat — is open and unmerged, targeting develop, so the earliest it can ship is a future v16.x once merged and backported. Watch that PR, then read the release notes of the next v16 tag.

+Does bench doctor detect that the Frappe scheduler is down?

Not directly. bench doctor reports RQ workers online via Redis — so it does see other containers — plus queue depths, and whether the scheduler is configured as enabled, paused or in maintenance mode. It never calls is_schduler_process_running(), and bench schedule is not an RQ worker, so it never appears in the worker count. It is a config-only check for this purpose. Use Scheduled Job Log freshness plus docker compose ps scheduler instead.

+What happens if we don't fix this and just ignore the Process Not Found status?

Functionally, nothing — the field is cosmetic and your scheduler keeps working. The real cost is that you have no working scheduler health signal at all, so a genuine scheduler outage looks identical to the false negative and nobody notices. That is the actual risk. Replace the signal: put a healthcheck or liveness probe on the scheduler service, and alert externally when the newest Scheduled Job Log row exceeds roughly three scheduler ticks.

None of the forum threads above name the "Process Not Found" string — they are the adjacent conversations about scheduler behaviour in Docker and Kubernetes, and I have not found a single discuss.frappe.io topic that discusses this bug by name. GitHub #40828 appears to be the only place it exists in writing. That is worth knowing when you go searching and find nothing: you are not looking in the wrong place, the discussion simply has not happened yet.

The short version: if your scheduler container is up and Scheduled Job Log is advancing, the report is wrong and you should leave it alone. Put a healthcheck on the scheduler service, alert on job-log freshness from outside Frappe, and watch PR #40866 for the merge. When it lands, the status field becomes worth reading again — and until then, treating a cosmetic red flag as an incident costs more than the bug does. This is the sort of thing we work through on every ERPNext implementation that ships to containers.

Running Frappe or ERPNext across containers and not sure what to trust?

We deploy and operate ERPNext on Docker and Kubernetes for Indian SMBs. If your monitoring is telling you things that contradict reality, we will help you work out which signal is the honest one.

Free · By email

Get practical ERPNext & automation guides

New implementation guides, cost breakdowns and open-source tips for Indian businesses — occasionally, straight to your inbox. No spam.

M

Written by

Manoj

Founder of Mith Tech, an open-source ERP & automation studio. Hands-on ERPNext/Frappe implementation across multi-branch, multi-warehouse Indian operations — GST/TDS/PT compliance, branch-level permissions, and custom Frappe apps that give management real-time visibility.

Keep reading

See what this looks like for your business

Book a free 30-minute audit. We'll map your workflows, find where time and money leak, and design an open-source stack you actually own — no per-user licence fees.

Book a consultation
0
Published on 5 August 2026

Manoj

Comments & ratings

No comments yet. Start a new discussion.