SerhiiLabs

n8n Monitoring with Prometheus and Alertmanager: Alerts That Actually Fire

3,832 words 18 min read
Categories n8n-automation

If you have ever copied an n8n alerting rule from a tutorial, deployed it, and then waited weeks for an alert that never came, you are not doing anything wrong. The rule is. A large share of published n8n alert rules reference series that stock n8n does not emit under those names, so they sit in your config looking correct and evaluating to nothing forever.

An alert only fires when it is wired to a series that exists in your n8n version and moves when the thing you care about breaks. Almost every dead n8n alert violates one of those two conditions: it uses an invented series name, or it targets a metric that is absent in the version you run. The fix is not a different monitoring stack. It is matching each rule to a series your instance emits, then proving it transitions to firing.

This guide works through that, section by section, removing one reason an alert fails in the order those reasons bite: wrong mental model, wrong series name, missing scrape target, missing dependency, an expression that silently never evaluates, an undelivered notification, and finally an untested rule.

Prerequisites and scope

You need a self-hosted n8n instance, single-node or queue mode, with Prometheus and Alertmanager reachable, and basic PromQL familiarity. Two things to state up front:

The /metrics endpoint is self-hosted only. It is not available on n8n Cloud. The official monitoring documentation covers this.

Metric availability is version-dependent. This guide targets n8n 2.x, with the current release being 2.23.2 (n8n 2.0 shipped in December 2025). Because n8n adds and renames metrics between minor versions, the single most important habit this guide will push is to check your own /metrics output rather than trust any list, including this one.

The two questions your monitoring has to answer

Before writing a single rule, separate these two questions. They use different series and different reasoning, even though both are answerable from /metrics in current 2.x.

Is n8n healthy? This is about the runtime: is the process up, is the event loop responsive, is memory climbing toward the container limit, is the queue draining. The data lives in the default Node.js process metrics, the queue gauges, and the standard exporters.

Did my workflow succeed? This is about correctness: did executions error, what is the failure rate. In current n8n 2.x this is answerable natively, because the execution-duration histogram carries a success or failure status label and is on by default. The reason copied failure rules still fail is not that the data is missing; it is that the rules use series names n8n never emitted. We confront the exact names later.

Keep the two questions in different mental buckets and the rest follows. The runtime question gets process and queue rules. The correctness question gets the execution histogram, with the Error Trigger and a Postgres query as complements for immediacy and per-workflow breakdown.

What the /metrics endpoint exposes, and what it does not

n8n collects metrics through the prom-client library, and the honest inventory is thinner than most guides imply. The bulk of /metrics is prom-client default Node.js process metrics. The n8n-specific series are few and mostly opt-in.

Metrics are grouped into categories, each behind an environment variable. The defaults matter, because four of the five interesting categories are off by default:

CategoryEnvironment variableDefault
Node.js process metricsN8N_METRICS_INCLUDE_DEFAULT_METRICStrue
Cache hit/missN8N_METRICS_INCLUDE_CACHE_METRICSfalse
Event busN8N_METRICS_INCLUDE_MESSAGE_EVENT_BUS_METRICSfalse
API endpointsN8N_METRICS_INCLUDE_API_ENDPOINTSfalse
Queue (queue mode)N8N_METRICS_INCLUDE_QUEUE_METRICSfalse

There are also label toggles, all off by default because they increase cardinality: N8N_METRICS_INCLUDE_WORKFLOW_ID_LABEL, N8N_METRICS_INCLUDE_WORKFLOW_NAME_LABEL, N8N_METRICS_INCLUDE_NODE_TYPE_LABEL, and N8N_METRICS_INCLUDE_CREDENTIAL_TYPE_LABEL. The n8n-specific metric prefix defaults to n8n_ and is configurable via N8N_METRICS_PREFIX. These are documented on the endpoints environment variables page, and the list grows between releases, so confirm it against your version.

Two metric toggles sit outside that category table and matter for the failure question later. N8N_METRICS_INCLUDE_WORKFLOW_EXECUTION_DURATION defaults to true, so the execution-duration histogram is emitted as soon as metrics are on. N8N_METRICS_INCLUDE_WORKFLOW_STATISTICS defaults to false, and it gates a separate set of cached total-count gauges (n8n_production_executions, n8n_manual_executions, and similar) that report execution totals but are not split by success or failure.

The series you can build runtime alerts on, from the default Node.js set, are stable and standard:

  • process_resident_memory_bytes for resident memory
  • process_cpu_user_seconds_total and process_cpu_system_seconds_total for CPU
  • nodejs_eventloop_lag_seconds for event loop responsiveness
  • process_open_fds and process_max_fds for file descriptor exhaustion
  • nodejs_gc_duration_seconds for garbage collection pressure

The n8n-specific series are fewer, but two of them carry most of the value. In queue mode, the queue depth gauge is n8n_scaling_mode_queue_jobs_waiting. And the workflow execution-duration histogram, n8n_workflow_execution_duration_seconds, is emitted by default and labelled by status (success or failed) and mode (manual, trigger, webhook, and so on); its _count child series is the native source of execution and failure counts. Both names matter, because both are names that widely copied rules get wrong.

A note on version drift, since it is the reason “check your own endpoint” is not optional. The execution-duration histogram was introduced during the 2.x line, so a very old 2.x build or a 1.x instance may not have it. Separately, an active-workflow-count gauge appears in the source but was reported missing from the metrics output in a 1.x build. Confirm what your specific version emits rather than assuming.

Verify before you alert. Curl your own endpoint and read it: curl -s http://127.0.0.1:5678/metrics | grep -E "n8n_|nodejs_eventloop|process_resident". The exact set of n8n-specific series, including the execution-duration histogram and its labels, varies by version. Treat the lists in any guide as a starting point to confirm, not as ground truth for your instance.

Enabling and scraping it correctly

Turning metrics on is one variable. Scraping them correctly in queue mode is where data quietly goes missing.

Enable the endpoint and the categories you want. In a Compose setup for queue mode, the n8n service environment looks like this. This targets n8n 2.23.2, Postgres 18, and Redis 8; pin to whatever versions you run.

services:
n8n-main:
image: docker.n8n.io/n8nio/n8n:2.23.2
environment:
- N8N_METRICS=true
- N8N_METRICS_INCLUDE_DEFAULT_METRICS=true
- N8N_METRICS_INCLUDE_QUEUE_METRICS=true
- QUEUE_HEALTH_CHECK_ACTIVE=true
- EXECUTIONS_MODE=queue
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- QUEUE_BULL_REDIS_HOST=redis
depends_on:
- postgres
- redis
n8n-worker:
image: docker.n8n.io/n8nio/n8n:2.23.2
command: worker
environment:
- N8N_METRICS=true
- N8N_METRICS_INCLUDE_DEFAULT_METRICS=true
- QUEUE_HEALTH_CHECK_ACTIVE=true
- EXECUTIONS_MODE=queue
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- QUEUE_BULL_REDIS_HOST=redis
depends_on:
- postgres
- redis
postgres:
image: postgres:18
environment:
- POSTGRES_USER=n8n
- POSTGRES_PASSWORD=REPLACE_WITH_STRONG_PASSWORD
- POSTGRES_DB=n8n
volumes:
- postgres-data:/var/lib/postgresql/data
redis:
image: redis:8-alpine
volumes:
- redis-data:/data
volumes:
postgres-data:
redis-data:

Two queue-mode details cause most of the lost data.

First, every process exposes its own endpoint. The main, worker, and webhook processes each serve /metrics separately, and you must scrape every one of them. The common failure is setting N8N_METRICS=true on main only, or pointing Prometheus at the main instance alone, and then wondering why worker memory or CPU never shows up. Queue metrics specifically (n8n_scaling_mode_queue_jobs_waiting and the related _active, _completed, and _failed series) are emitted by the main process, but the Node.js process metrics you care about for capacity live on each worker. One multi-main caveat: every main emits its own copy of these queue gauges for the same shared queue, so summing them across mains multiplies the real figure. The Prometheus configuration docs point you at the instance_role_leader gauge to single out the leader’s view when you aggregate.

A scrape config for queue mode targets each role and labels it so you can aggregate later:

scrape_configs:
- job_name: "n8n-main"
metrics_path: /metrics
static_configs:
- targets: ["n8n-main:5678"]
labels:
role: "main"
- job_name: "n8n-workers"
metrics_path: /metrics
static_configs:
- targets: ["n8n-worker-1:5678", "n8n-worker-2:5678"]
labels:
role: "worker"

When you graph or alert on worker series, aggregate across instances rather than reading one worker, for example sum without (instance) (process_resident_memory_bytes{role="worker"}).

Second, worker health endpoints need an explicit toggle. The liveness path /healthz and the readiness path /healthz/readiness are only active on workers when QUEUE_HEALTH_CHECK_ACTIVE=true is set, as documented in the queue mode docs. Without it, any readiness-based probe on a worker has nothing to talk to and fails silently in the unhelpful direction.

One security note: the /metrics endpoint is unauthenticated and exposes operational detail. Keep Prometheus on a private network and restrict the endpoint at the network or reverse-proxy layer. Do not expose it to the internet.

The dependencies n8n will not report on

A large share of real n8n incidents start in Postgres or Redis, and n8n’s own /metrics says nothing about either. If the database connection pool saturates or Redis becomes unreachable, n8n’s process metrics may look fine right up until executions stall. That is a hole exactly where outages begin.

Close it with the standard exporters, scraped alongside n8n:

  • postgres_exporter (current release 0.19.1) for pg_up, connection counts via pg_stat_activity_count, and replication or transaction-age signals
  • redis_exporter for Redis reachability and memory
  • node_exporter (current release 1.11.1) for host CPU, memory, and disk, plus cAdvisor for per-container memory against the container limit

Keep this layer focused. You are not building a generic exporter tutorial; you want the few dependency signals that change n8n’s behavior when they go bad: database reachable, connection pool not saturated, Redis reachable, host and container memory not exhausted. Install each exporter per its own documentation and move on.

Alerts that actually fire on runtime health

Here are copy-ready rules for the signals /metrics and the exporters support. Each has a for: duration so a transient blip does not page you, and each is wired to a series that exists and moves when the thing you care about breaks.

Before the rules, the trap that defeats more alerts than any other. In Prometheus, a counter that has never incremented produces no time series at all. An expression like rate(some_total[5m]) > 0 against a series that does not exist does not evaluate to false; it evaluates to nothing, and an alert on nothing never fires. This is the literal mechanism behind “alerts that do not fire”. Defend against it with up == 0 and absent(), which fire precisely when data is missing.

groups:
- name: n8n-runtime
rules:
- alert: N8nInstanceDown
expr: up{job=~"n8n-.*"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "n8n process {{ $labels.instance }} is not being scraped"
- alert: N8nTargetMissing
expr: absent(up{job="n8n-main"})
for: 5m
labels:
severity: critical
annotations:
summary: "n8n main target has disappeared from Prometheus entirely"
- alert: N8nEventLoopLagHigh
expr: nodejs_eventloop_lag_seconds{job=~"n8n-.*"} > 0.2
for: 10m
labels:
severity: warning
annotations:
summary: "n8n event loop lag is sustained above 200ms on {{ $labels.instance }}"
- alert: N8nContainerMemoryHigh
expr: container_memory_working_set_bytes{name=~"n8n.*"} / container_spec_memory_limit_bytes{name=~"n8n.*"} > 0.9
for: 10m
labels:
severity: warning
annotations:
summary: "n8n container {{ $labels.name }} is above 90% of its memory limit"
- alert: N8nQueueBacklogGrowing
expr: n8n_scaling_mode_queue_jobs_waiting > 50
for: 10m
labels:
severity: warning
annotations:
summary: "n8n queue backlog has stayed above 50 waiting jobs for 10 minutes"
- alert: PostgresDown
expr: pg_up == 0
for: 1m
labels:
severity: critical
annotations:
summary: "Postgres backing n8n is unreachable"

A few notes on why these are written this way.

N8nInstanceDown uses up == 0, which is true whenever Prometheus has a target it cannot scrape. N8nTargetMissing uses absent() to catch the different failure where the target vanishes from service discovery and up produces no series at all. The two cover complementary gaps.

The memory rule uses cAdvisor’s container working set against the container’s own memory limit, rather than joining n8n’s process_resident_memory_bytes to a limit series. That join is fiddly because the process metric and the cAdvisor metric carry different labels and do not line up on a common key without rewriting. Letting cAdvisor compute the ratio against the limit it already knows is cleaner and correct. One caveat: this assumes the container has a memory limit set, since without one container_spec_memory_limit_bytes reports the host’s total memory and the ratio becomes meaningless. Set a limit on the n8n containers, and keep process_resident_memory_bytes for trend and leak detection on a dashboard.

The queue backlog threshold of 50 is a placeholder. The useful signal is backlog rising while worker CPU stays flat, which means you are under-provisioned on workers or blocked downstream on Postgres or an external API. Tune the number to your throughput.

Readiness deserves a clarification the candidate list often glosses over. /healthz/readiness returns 200 only when the database is connected and migrations are done, which is useful, but it is an HTTP endpoint, not a Prometheus series. To alert on it you scrape it with blackbox_exporter and alert on probe_success == 0 for that target. If you do not run blackbox_exporter, up == 0 on the metrics scrape still covers the case where the process is gone; it just will not distinguish “running but database disconnected” from “down”.

The alert everyone actually wants: my workflow failed

This is the section that justifies the whole article, and it is where the precision has to be sharpest, because the common advice here is both confidently stated and wrong for current n8n.

The wrong version of the story is that n8n emits no workflow-failure metric, so you have to leave Prometheus and query the database. That was once a reasonable read, but it is not true for 2.x. The reason widely copied failure rules do not fire is simpler and more specific: they use series names n8n never emitted. A rule like rate(n8n_execution_failed_total[5m]) > 0.1 never fires because n8n_execution_failed_total is not a real series. The same rule sets often pair it with n8n_queue_bull_queue_waiting for queue depth, where the series n8n emits is n8n_scaling_mode_queue_jobs_waiting. Wrong name, no series, no evaluation, forever. Check any inherited rule against your own /metrics dump before trusting it.

What does exist, by default, is the execution-duration histogram n8n_workflow_execution_duration_seconds. It carries a status label of success or failed and a mode label, and because N8N_METRICS_INCLUDE_WORKFLOW_EXECUTION_DURATION defaults to true, you already have it the moment N8N_METRICS=true is set. Its _count child series is a native, status-aware execution counter, which is exactly the thing the failure alert needs. The failure rate is a direct Prometheus query:

rate(n8n_workflow_execution_duration_seconds_count{status="failed"}[5m])

For a proportion rather than an absolute rate, divide failed by total:

sum(rate(n8n_workflow_execution_duration_seconds_count{status="failed"}[5m]))
/
sum(rate(n8n_workflow_execution_duration_seconds_count[5m]))

That turns into a rule like this. The mode label lets you scope to production runs and ignore manual editor executions:

- alert: N8nWorkflowFailureRateHigh
expr: |
sum(rate(n8n_workflow_execution_duration_seconds_count{status="failed", mode!="manual"}[5m]))
/
sum(rate(n8n_workflow_execution_duration_seconds_count{mode!="manual"}[5m]))
> 0.05
for: 10m
labels:
severity: warning
annotations:
summary: "More than 5% of production workflow executions are failing"

One caveat that keeps this honest: by default the histogram is not labelled per workflow, so this rule tells you the instance-wide failure rate, not which workflow is failing. You can add the workflow_id label with N8N_METRICS_INCLUDE_WORKFLOW_ID_LABEL, but that multiplies cardinality by the number of active workflows, which is exactly the kind of explosion you do not want in Prometheus on a busy instance. So the native metric answers “are failures spiking” well, and “which workflow” poorly. That is where the two complements come in.

The n8n Error Trigger, for immediacy and identity. n8n’s built-in Error Trigger node does not fire on every error globally. It runs only when its workflow is designated as the error workflow for another workflow (or for itself), and only for automatic executions, not manual editor runs. Set one error-handler workflow as the error workflow for the workflows you care about, and it fires the instant one of them fails, with the failed workflow’s name and the error in the payload. Route that to Slack or email, or post it into Alertmanager’s /api/v2/alerts endpoint so it flows through the same routing, grouping, and silencing as your infrastructure alerts. This gives you the per-incident detail the histogram omits.

A Postgres query, for per-workflow breakdown without cardinality cost. n8n stores execution records, including status and workflow, in its database. A read-only role and a scheduled query give you per-workflow failure rates over arbitrary windows without adding a single high-cardinality label to Prometheus. This is the right tool for “what is the failure rate of this specific workflow over the last day”, and it is the basis of the analytics-style dashboards people build for n8n. Keep it on a tightly scoped read-only role so monitoring never contends with production writes.

Insights rounds this out in-app: a summary view is available across plans, while the deeper per-workflow failure-rate breakdowns are a paid feature. It is convenient for eyeballing trends, but it sits outside your alerting pipeline rather than inside it.

The practical recommendation: alert on the native status="failed" rate in Prometheus, use the Error Trigger for instant per-workflow notifications, and reach for the Postgres query when you want per-workflow rates without paying the cardinality tax. The native metric is the foundation here, not an afterthought.

Wiring Alertmanager so notifications arrive

A firing rule is not a delivered notification. Alertmanager decides what reaches a human and what gets suppressed.

route:
receiver: "default"
group_by: ["alertname", "role"]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
routes:
- matchers:
- severity = "critical"
receiver: "oncall"
repeat_interval: 1h
receivers:
- name: "default"
slack_configs:
- api_url: "REPLACE_WITH_SLACK_WEBHOOK_URL"
channel: "#n8n-alerts"
send_resolved: true
- name: "oncall"
slack_configs:
- api_url: "REPLACE_WITH_SLACK_WEBHOOK_URL"
channel: "#oncall"
send_resolved: true
inhibit_rules:
- source_matchers:
- alertname = "N8nInstanceDown"
target_matchers:
- severity = "warning"
equal: ["instance"]

The pieces that matter:

group_by collapses related alerts into one notification so a single outage does not produce a wall of messages. repeat_interval controls how often an unresolved alert re-notifies; tighter for critical, looser for warnings. The routes block sends critical alerts to a separate, faster receiver.

The inhibit_rules block is the one people skip and then regret. When n8n is down, every downstream warning, high memory, queue backlog, readiness failure, becomes noise about a symptom you already know. Inhibiting warnings while the instance-down alert is active keeps the page readable. Extend the same idea to suppress workflow-failure alerts when the whole instance is down, since “the workflow failed” is not actionable when the cause is “n8n is not running”.

Point Prometheus at Alertmanager in prometheus.yml:

alerting:
alertmanagers:
- static_configs:
- targets: ["alertmanager:9093"]

This guide assumes Prometheus 3.x and Alertmanager 0.32.x, the current stable lines.

Proving the alert fires

This is the step nearly every tutorial omits, and it is the one that separates an alerting setup you trust from a folder of YAML you hope works.

For each rule, induce the failure on purpose and watch the alert move through its states. In the Prometheus UI under Alerts, a rule with a for: duration goes from inactive to pending the moment its expression is true, then to firing once the for: window elapses. Then confirm the notification lands in Slack or your inbox.

Concrete tests:

  • Stop a worker container and confirm N8nInstanceDown reaches firing and pages.
  • Stop Postgres, or block its port, and confirm PostgresDown fires. Simulating a database disconnect is the highest-value single test, because the database is where many real n8n incidents originate.
  • Force a workflow error, for example with a deliberately failing HTTP Request node, and confirm your Error Trigger handler sends its notification.
  • Load the workers until n8n_scaling_mode_queue_jobs_waiting climbs past your threshold and confirm the backlog alert fires.

An alert you have never seen fire is an assumption, not a safeguard. Test each one once, on purpose, before you rely on it.

Decision matrix

When you want a signal, the question is always the same: which source carries it, and can Prometheus alert on it directly.

Signal you wantRight sourceExample metric or mechanismAlertable directly in Prometheus?
n8n process up/metrics scrape targetup == 0, absent()Yes
Event loop or runtime stress/metrics default setnodejs_eventloop_lag_seconds, process_resident_memory_bytesYes
Container memory vs limitcAdvisorcontainer_memory_working_set_bytes / container_spec_memory_limit_bytesYes
Queue backlog (queue mode)/metrics queue metricsn8n_scaling_mode_queue_jobs_waitingYes
Database or Redis downexporterspg_up, redis_exporter seriesYes
Readiness (database connected)blackbox_exporter probing /healthz/readinessprobe_success == 0Yes, with blackbox_exporter
Workflow failure rate/metrics execution histogramrate(n8n_workflow_execution_duration_seconds_count{status="failed"}[5m])Yes, native and on by default in 2.x
Which workflow failedError Trigger; Postgres executions dataper-incident notification; scheduled per-workflow queryComplement to the native rate (avoids label cardinality)

The pattern across the table: nearly everything you want is alertable directly in Prometheus, including workflow failure rate, as long as the rule names a series your version emits. The instance-wide failure rate comes from the native histogram; the Error Trigger and a Postgres query fill in which workflow failed without forcing a high-cardinality label into your metrics. Wire each rule to a series that exists and moves when something breaks, prove it fires, and the alerts hold up.