How to Monitor Your Self-Hosted Frappe / ERPNext Instance (Workers, Redis, Database, and More)
Frappe and ERPNext are among the most operationally complex open source applications you can self-host. A production Frappe deployment involves a Gunicorn web server, multiple background job workers (default, long, short, and scheduler queues), Redis for caching and queueing, MariaDB, file storage, and a transactional email queue. When any of these fails, business operations — invoicing, payroll, inventory, purchasing — break in ways that aren't always obvious.
This guide covers comprehensive monitoring for every layer of your Frappe/ERPNext stack.
Where Frappe deployments break
Web server failures — Gunicorn crashes or the Nginx proxy fails. Users see connection refused or a 502 Bad Gateway. No self-healing; requires manual restart.
Background worker failures — Frappe uses RQ (Redis Queue) workers for background jobs. Worker crashes leave jobs stuck in the queue. Email queues stop draining. Scheduled reports don't run. The UI shows no indication — document status just stops changing.
Redis queue failures — Redis is both the session cache and the job queue backend. A Redis failure stops all background processing and degrades session performance. This is one of the highest-impact single-point failures in a Frappe deployment.
MariaDB connection pool exhaustion — under heavy load, MariaDB connections can be exhausted. Requests start failing with connection errors. The pool rarely recovers without intervention.
File storage failures — Frappe stores attachments, print formats, and backup files on the local filesystem (or S3/NFS in enterprise setups). A full disk or broken mount causes upload failures that surface as cryptic errors to users.
Email delivery queue stalls — Frappe's email queue runs through background workers. If workers stop, emails pile up invisibly. Users expect notifications that never arrive.
Webhook delivery failures — outbound webhooks (to Zapier, Slack, or internal services) go through the same worker queues. Silent worker failures kill webhook delivery.
Step 1: Enable and test Frappe's health endpoint
Recent versions of Frappe include a built-in health endpoint at /api/method/frappe.monitor.health:
curl https://erp.yourdomain.com/api/method/frappe.monitor.health
# {"message":{"status":"healthy","worker":"alive","scheduler":"alive"}}
If you're on an older Frappe version, create a custom whitelisted method:
# apps/your_app/your_app/api/health.py
import frappe
import redis
@frappe.whitelist(allow_guest=True)
def health():
checks = {}
# Database check
try:
frappe.db.sql("SELECT 1")
checks['database'] = 'ok'
except Exception:
checks['database'] = 'error'
# Redis cache check
try:
cache = frappe.cache()
cache.set('health_check', 'ok', ex=30)
val = cache.get('health_check')
checks['redis_cache'] = 'ok' if val == b'ok' else 'error'
except Exception:
checks['redis_cache'] = 'error'
# Redis queue check
try:
import redis as redis_lib
conf = frappe.conf
r = redis_lib.Redis(
host=conf.get('redis_queue', 'redis://localhost:11000').split('/')[-2].split(':')[0],
port=int(conf.get('redis_queue', 'redis://localhost:11000').split(':')[-1]),
)
r.ping()
checks['redis_queue'] = 'ok'
except Exception:
checks['redis_queue'] = 'error'
# File storage check
site_path = frappe.get_site_path()
checks['file_storage'] = 'ok' if frappe.os.access(site_path, frappe.os.W_OK) else 'error'
status_ok = all(v == 'ok' for v in checks.values())
frappe.response['http_status_code'] = 200 if status_ok else 500
return {
'status': 'healthy' if status_ok else 'degraded',
'checks': checks
}
Register the endpoint in your hooks.py:
# apps/your_app/your_app/hooks.py
override_whitelisted_methods = {
"your_app.api.health.health": "your_app.api.health.health"
}
Step 2: Set up HTTP monitoring in Vigilmon
With health endpoints available, configure monitoring in Vigilmon:
- Sign up at vigilmon.online
- Click New Monitor → HTTP
Create these monitors:
| Monitor name | URL | Expected |
|---|---|---|
| Frappe Web Server | https://erp.yourdomain.com/api/method/frappe.monitor.health | 200 + "status":"healthy" |
| ERPNext Login Page | https://erp.yourdomain.com/login | 200 |
| Frappe REST API | https://erp.yourdomain.com/api/resource/DocType | 200 or 403 (alive, needs auth) |
| Frappe Desk | https://erp.yourdomain.com/app | 200 |
The REST API check accepting a 403 (Forbidden) works the same way as the OAuth check pattern — a 403 means the API is alive and enforcing auth. A 500 means the Python layer is broken.
Step 3: Monitor background job workers
Frappe background workers are the most common source of silent failures. When they stop, the application appears functional but stops processing jobs.
Worker heartbeat via a recurring scheduled task
Create a Frappe scheduled task that pings a heartbeat URL:
# apps/your_app/your_app/tasks/heartbeat.py
import frappe
import requests
def ping_worker_heartbeat():
"""Called by the Frappe scheduler. Pings Vigilmon to confirm workers are alive."""
heartbeat_url = frappe.conf.get('vigilmon_worker_heartbeat_url')
if heartbeat_url:
try:
requests.get(heartbeat_url, timeout=5)
except Exception:
frappe.log_error("Failed to ping worker heartbeat", "Vigilmon")
Register it in hooks.py:
# apps/your_app/your_app/hooks.py
scheduler_events = {
"all": [
"your_app.tasks.heartbeat.ping_worker_heartbeat"
]
}
The all event fires every minute. In Vigilmon, set the heartbeat monitor interval to 5 minutes (with tolerance for scheduler jitter). If the worker queue stops processing, this task stops running and Vigilmon alerts you within 5 minutes.
Set the heartbeat URL in your site config:
# bench set-config -g vigilmon_worker_heartbeat_url "https://vigilmon.online/heartbeats/your-token"
Or edit sites/common_site_config.json:
{
"vigilmon_worker_heartbeat_url": "https://vigilmon.online/heartbeats/your-worker-token"
}
Step 4: Separate heartbeats for each worker queue
Frappe runs workers on multiple named queues: default, long, short, and scheduler. A failure in the long queue (which handles large report generation) won't affect the default queue — so you want separate heartbeats:
# apps/your_app/your_app/tasks/heartbeat.py
import frappe
import requests
def ping_default_heartbeat():
_ping(frappe.conf.get('vigilmon_default_heartbeat_url'))
def ping_long_heartbeat():
_ping(frappe.conf.get('vigilmon_long_heartbeat_url'))
def ping_short_heartbeat():
_ping(frappe.conf.get('vigilmon_short_heartbeat_url'))
def _ping(url):
if url:
try:
requests.get(url, timeout=5)
except Exception:
pass
Register each in hooks.py with appropriate frequencies:
scheduler_events = {
"all": [
"your_app.tasks.heartbeat.ping_default_heartbeat",
"your_app.tasks.heartbeat.ping_short_heartbeat",
],
"hourly": [
"your_app.tasks.heartbeat.ping_long_heartbeat",
],
}
Configure all three in common_site_config.json:
{
"vigilmon_default_heartbeat_url": "https://vigilmon.online/heartbeats/token-default",
"vigilmon_long_heartbeat_url": "https://vigilmon.online/heartbeats/token-long",
"vigilmon_short_heartbeat_url": "https://vigilmon.online/heartbeats/token-short"
}
In Vigilmon, create a heartbeat monitor for each with appropriate intervals (2 minutes for all-queue heartbeats, 90 minutes for long-queue).
Step 5: Monitor Redis connectivity
Redis failures in Frappe are catastrophic. Monitor Redis connectivity directly with a cron-based heartbeat:
#!/bin/bash
# /opt/frappe-jobs/check-redis.sh
REDIS_CACHE_PORT=${REDIS_CACHE_PORT:-13000}
REDIS_QUEUE_PORT=${REDIS_QUEUE_PORT:-11000}
REDIS_SOCKETIO_PORT=${REDIS_SOCKETIO_PORT:-12000}
ALL_OK=true
# Check Redis cache
if ! redis-cli -p $REDIS_CACHE_PORT ping | grep -q PONG; then
echo "[$(date)] Redis cache (port $REDIS_CACHE_PORT) NOT responding" >&2
ALL_OK=false
fi
# Check Redis queue
if ! redis-cli -p $REDIS_QUEUE_PORT ping | grep -q PONG; then
echo "[$(date)] Redis queue (port $REDIS_QUEUE_PORT) NOT responding" >&2
ALL_OK=false
fi
if [ "$ALL_OK" = true ]; then
curl -fsS --retry 3 "$VIGILMON_REDIS_HEARTBEAT_URL" > /dev/null 2>&1
echo "[$(date)] Redis health check passed"
fi
*/5 * * * * frappe /opt/frappe-jobs/check-redis.sh >> /var/log/frappe/redis-health.log 2>&1
Step 6: Database connection pool monitoring
MariaDB connection pool exhaustion causes request failures across the entire Frappe application. Monitor it with a health check that probes connection availability:
# apps/your_app/your_app/tasks/db_health.py
import frappe
import requests
def check_database_pool():
"""Checks DB connectivity and connection count. Runs every 5 minutes."""
try:
# Test basic connectivity
frappe.db.sql("SELECT 1")
# Check active connection count (requires PROCESS privilege)
result = frappe.db.sql(
"SELECT COUNT(*) as conn_count FROM information_schema.PROCESSLIST WHERE db = %s",
(frappe.conf.db_name,),
as_dict=True
)
conn_count = result[0]['conn_count'] if result else 0
# Warn if connections are high (tune this threshold for your setup)
max_connections = int(frappe.conf.get('max_db_connections', 100))
if conn_count < max_connections * 0.9: # < 90% of max
heartbeat_url = frappe.conf.get('vigilmon_db_heartbeat_url')
if heartbeat_url:
requests.get(heartbeat_url, timeout=5)
else:
frappe.log_error(
f"DB connection pool near limit: {conn_count}/{max_connections}",
"DB Health Warning"
)
except Exception as e:
frappe.log_error(str(e), "DB Health Check Failed")
Register in hooks.py:
scheduler_events = {
"cron": {
"*/5 * * * *": ["your_app.tasks.db_health.check_database_pool"]
}
}
Step 7: Email delivery queue heartbeat
Frappe's email queue runs through background workers. Monitor whether emails are actually being sent:
# apps/your_app/your_app/tasks/email_health.py
import frappe
import requests
from datetime import datetime, timedelta
def check_email_queue():
"""Alerts if emails are stuck in the queue for > 30 minutes."""
cutoff = datetime.now() - timedelta(minutes=30)
stuck_emails = frappe.db.count('Email Queue', {
'status': 'Not Sent',
'creation': ['<', cutoff]
})
if stuck_emails == 0:
# Queue is draining — send heartbeat
heartbeat_url = frappe.conf.get('vigilmon_email_heartbeat_url')
if heartbeat_url:
try:
requests.get(heartbeat_url, timeout=5)
except Exception:
pass
else:
frappe.log_error(
f"{stuck_emails} email(s) stuck in queue for > 30 minutes",
"Email Queue Alert"
)
Register in hooks.py:
scheduler_events = {
"cron": {
"*/15 * * * *": ["your_app.tasks.email_health.check_email_queue"]
}
}
Step 8: File storage and report rendering heartbeats
File storage
#!/bin/bash
# /opt/frappe-jobs/check-file-storage.sh
SITE_PATH="/home/frappe/frappe-bench/sites/your-site.com"
PRIVATE_DIR="$SITE_PATH/private/files"
PUBLIC_DIR="$SITE_PATH/public/files"
MIN_FREE_GB=5
FREE_BYTES=$(df --output=avail "$SITE_PATH" | tail -1)
FREE_GB=$(( FREE_BYTES / 1024 / 1024 ))
if [ "$FREE_GB" -gt "$MIN_FREE_GB" ] && [ -w "$PRIVATE_DIR" ] && [ -w "$PUBLIC_DIR" ]; then
curl -fsS "$VIGILMON_STORAGE_HEARTBEAT_URL" > /dev/null 2>&1
else
echo "[$(date)] File storage check failed. Free: ${FREE_GB}GB, writable: $([ -w $PRIVATE_DIR ] && echo yes || echo NO)" >&2
fi
*/15 * * * * frappe /opt/frappe-jobs/check-file-storage.sh >> /var/log/frappe/storage.log 2>&1
Report rendering heartbeat
# apps/your_app/your_app/tasks/report_health.py
import frappe
import requests
def check_report_rendering():
"""Attempts to render a known-good report. Pings heartbeat on success."""
try:
# Render a lightweight built-in report
result = frappe.get_doc('Report', 'ToDo').get_data()
if result is not None:
heartbeat_url = frappe.conf.get('vigilmon_reports_heartbeat_url')
if heartbeat_url:
requests.get(heartbeat_url, timeout=5)
except Exception as e:
frappe.log_error(str(e), "Report Rendering Health Check Failed")
Webhook delivery heartbeat
# apps/your_app/your_app/tasks/webhook_health.py
import frappe
import requests
from datetime import datetime, timedelta
def check_webhook_queue():
"""Alerts if webhooks are stuck in the queue."""
cutoff = datetime.now() - timedelta(minutes=15)
stuck = frappe.db.count('Webhook Log', {
'status': 'Error',
'creation': ['>', cutoff]
})
if stuck < 3: # Tolerate occasional individual failures
heartbeat_url = frappe.conf.get('vigilmon_webhook_heartbeat_url')
if heartbeat_url:
try:
requests.get(heartbeat_url, timeout=5)
except Exception:
pass
else:
frappe.log_error(f"{stuck} webhook failures in last 15 minutes", "Webhook Health Alert")
Step 9: Alerts configuration
Configure notification channels in Vigilmon:
Slack:
- Create an incoming webhook in your Slack ops channel
- Go to Notifications → New Channel → Slack in Vigilmon
- Paste the webhook URL
- Enable on all Frappe monitors
Email:
- Go to Notifications → New Channel → Email
- Add your ERP admin or IT ops distribution list
- Enable on critical monitors (web server, Redis, database, default worker)
A worker heartbeat miss alert:
🔴 MISSED: Frappe Default Worker
Expected every: 2 minutes
Last received: 12 minutes ago
Action: Check bench logs — run "bench doctor" to inspect worker status
Supervision and auto-restart (complementary to monitoring)
Pair your monitoring with proper process supervision so failures self-heal faster:
# Check Frappe process status via supervisor
supervisorctl status | grep frappe
# Or via systemd (bench managed)
systemctl status frappe-default-worker@0.service
systemctl status frappe-schedule.service
Monitoring catches the failure; supervision auto-restarts the process. Both are necessary — supervision without monitoring means you don't know how often workers are crashing.
Complete monitoring coverage
| Component | Monitoring method |
|---|---|
| Web server (Gunicorn + Nginx) | HTTP monitor on /api/method/frappe.monitor.health |
| Default queue workers | Heartbeat via scheduler task (every 1 min) |
| Long queue workers | Heartbeat via scheduler task (hourly) |
| Short queue workers | Heartbeat via scheduler task (every 1 min) |
| Redis cache | Heartbeat via cron CLI check |
| Redis queue | Heartbeat via cron CLI check |
| MariaDB connection pool | Heartbeat via scheduler task (every 5 min) |
| File storage | Heartbeat via cron disk check |
| Email delivery queue | Heartbeat: alert if queue stalls > 30 min |
| Report rendering | Heartbeat via scheduled report test |
| Webhook delivery | Heartbeat: alert on repeated failures |
Next steps
- Add a Frappe bench backup job heartbeat — backups are your last line of defense, and backup failures are almost never noticed until you need the backup
- Monitor your MariaDB slow query log volume as a leading indicator of database stress before it reaches connection pool limits
- Set up a synthetic login flow as a canary: if a user can log in and reach the desk, your full Frappe stack is functional
Get started free at vigilmon.online.