Back to Article List

Monitoring n8n on a VPS with Prometheus and Grafana

Monitoring n8n on a VPS with Prometheus and Grafana

Run this against a fresh n8n container and you get a 404:

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5678/metrics

The endpoint is off by default. One variable turns it on and a dozen more decide what it exposes. After that the job is the usual Prometheus and Grafana work: scrape it, graph the four or five series that predict trouble, alert on two of them. This guide is the version of that for an n8n instance on a single VPS running Docker Compose, in regular or queue mode, on n8n 2.x. If you already monitor other Node.js services the shape will be familiar, since n8n uses prom-client and exposes the same process metrics as the app in the Node.js monitoring with Prometheus and Grafana tutorial. The n8n-specific part is the queue gauges and the flags below.

Enable the n8n metrics endpoint with N8N_METRICS

Add to the environment of the n8n service and restart:

N8N_METRICS=true
N8N_METRICS_PREFIX=n8n_

The prefix is already the default; I put it in the compose file anyway so the next person reading it knows where the n8n_ in the metric names comes from. Run the curl again and you get a few hundred lines of prom-client output: process_cpu_seconds_total, process_resident_memory_bytes, nodejs_eventloop_lag_seconds, nodejs_heap_size_used_bytes, the garbage collection histograms and a version gauge. Those are the "default metrics", controlled by N8N_METRICS_INCLUDE_DEFAULT_METRICS (true by default), and on a regular-mode instance they are most of what you'll graph.

The n8n docs page on enabling Prometheus metrics carries one warning I'll quote as written: "Don't make it accessible on the public internet, as it can reveal sensitive operational data about your n8n instance." There is no authentication on /metrics. Section three below is about that.

N8N_METRICS_INCLUDE flags and what each one costs

Every extra flag adds series, and some add a label whose value set grows with your instance, which is what makes Prometheus memory climb. This is the list from the endpoints reference as of 2.38, with my note on cardinality:

VariableAddsCardinality
N8N_METRICS_INCLUDE_DEFAULT_METRICSNode.js process and runtime metricsFixed, small. Leave on.
N8N_METRICS_INCLUDE_QUEUE_METRICSQueue mode job gauges and counters, refreshed every N8N_METRICS_QUEUE_METRICS_INTERVAL seconds (20)Four series. Turn on in queue mode.
N8N_METRICS_INCLUDE_WORKFLOW_ID_LABELA workflow_id label on workflow metricsOne series per workflow. Fine under a few hundred workflows.
N8N_METRICS_INCLUDE_NODE_TYPE_LABELA node_type label on node metricsOne series per node type used. Moderate.
N8N_METRICS_INCLUDE_CREDENTIAL_TYPE_LABELA credential_type labelSmall.
N8N_METRICS_INCLUDE_API_ENDPOINTSRequest metrics for n8n's own REST endpointsGrows with the three label flags below.
N8N_METRICS_INCLUDE_API_PATH_LABEL, _API_METHOD_LABEL, _API_STATUS_CODE_LABELPath, method and status labels on API metricsPath is the dangerous one: every distinct URL is a series.
N8N_METRICS_INCLUDE_CACHE_METRICSCache hits and missesSmall.
N8N_METRICS_INCLUDE_MESSAGE_EVENT_BUS_METRICSInternal event bus countersSmall.
N8N_METRICS_INCLUDE_SCHEDULER_METRICSDurable scheduler gauges (main only), interval N8N_METRICS_SCHEDULER_INTERVALSmall. Only useful with the durable scheduler on.
N8N_METRICS_INCLUDE_POLL_TRIGGER_METRICSPolling trigger metrics (main only)Per polling workflow.
N8N_METRICS_INCLUDE_SSRF_METRICSCounters for SSRF protection checksSmall.
N8N_METRICS_INCLUDE_DNS_CACHE_METRICSDNS cache hit and miss countersSmall.

All of the include flags default to false except the first. The Grafana guide in the n8n docs also names N8N_METRICS_INCLUDE_WEBHOOK_METRICS for an n8n_webhook_request_duration_seconds histogram, N8N_METRICS_INCLUDE_FORM_METRICS for form submissions and N8N_METRICS_INCLUDE_WORKFLOW_INFO for an n8n_workflow_info gauge that carries workflow names so your dashboards can show "Invoice sync" instead of an id. Those three are newer than the endpoints table and I have only run the webhook one; it worked on 2.38.5 and gave a sensible histogram.

My own compose has queue metrics, the workflow id label and the webhook histogram on, and everything else off. I tried the API path label once and had 1,400 new series inside a day, most of them /rest/executions/<id>, which told me nothing I needed. I haven't turned on the message event bus metrics for long enough to say what they look like on a busy instance, so no opinion there.

Keep /metrics off the internet

The endpoint is served on the same port as the editor and the webhooks, so anything that proxies port 5678 to the world proxies /metrics too. Three options, in the order I'd pick them.

Don't publish the port. If Prometheus runs in the same compose project, it reaches n8n:5678 over the compose network and the host never needs 5678 bound at all; your reverse proxy is the only thing with a public port. This is how the n8n behind Nginx setup is laid out, with n8n bound to 127.0.0.1:5678 and nginx in front. It also keeps the editor off the public port, which is a separate reason to prefer it.

Block the path at the proxy. For nginx, before the main location /:

location = /metrics {
    return 404;
}

Caddy's equivalent is a handle /metrics block with respond 404 above the reverse_proxy line. Returning 404 instead of 403 means a scanner learns nothing.

Or, if Prometheus lives on another server, allow-list its IP on that location and deny everything else. Better still is reaching it over a private network, and the private networking layout for n8n shows the two-server version where the scrape never touches a public interface. The allow-list is the fallback when neither is available.

Worker metrics in queue mode with QUEUE_HEALTH_CHECK_ACTIVE

Workers don't serve HTTP unless you ask. Each worker needs:

N8N_METRICS=true
QUEUE_HEALTH_CHECK_ACTIVE=true
QUEUE_HEALTH_CHECK_PORT=5678

That opens /healthz, /healthz/readiness and /metrics on the worker, and Prometheus scrapes each worker container by service name. The four queue gauges and counters (n8n_scaling_mode_queue_jobs_waiting among them) are published by the main process only, which is where N8N_METRICS_INCLUDE_QUEUE_METRICS=true has to be set; the n8n queue mode with Redis workers guide has the full split of what runs where. Putting it on a worker does nothing. What you get from a worker is the process metrics: memory, CPU, event loop lag, heap.

On the health endpoints: a 200 from /healthz means the process answers, nothing more, while /healthz/readiness only goes 200 once the database is connected and migrated. The monitor n8n page is where both are defined. Readiness is the one to point external checks at.

Prometheus scrape config for n8n main and workers

Add Prometheus to the same compose file as n8n so service names resolve. Pin the image; v3.14.0 is the current release as I write this:

  prometheus:
    image: prom/prometheus:v3.14.0
    restart: unless-stopped
    command:
      - --config.file=/etc/prometheus/prometheus.yml
      - --storage.tsdb.retention.time=30d
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
      - prom_data:/prometheus

  node-exporter:
    image: prom/node-exporter:v1.12.1
    restart: unless-stopped
    pid: host
    volumes:
      - /:/host:ro,rslave
    command:
      - --path.rootfs=/host

No ports: on Prometheus. You reach its UI through Grafana or an SSH tunnel. Then prometheus/prometheus.yml:

global:
  scrape_interval: 15s
  evaluation_interval: 15s

rule_files:
  - /etc/prometheus/alerts.yml

scrape_configs:
  - job_name: n8n-main
    metrics_path: /metrics
    static_configs:
      - targets: ["n8n:5678"]
        labels:
          role: main

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

  - job_name: node
    static_configs:
      - targets: ["node-exporter:9100"]

Drop the worker job in regular mode. Fifteen seconds is fine for n8n; the queue gauges only refresh every 20 seconds on the n8n side anyway, so scraping faster buys nothing. After docker compose up -d prometheus, confirm the targets are up from inside the network rather than opening a port:

docker compose exec prometheus wget -qO- http://localhost:9090/api/v1/targets | grep -o '"health":"[a-z]*"'

Redis and Postgres exporters are the usual next step and both have well-maintained images (oliver006/redis_exporter and prometheuscommunity/postgres-exporter). I run the Postgres one and not the Redis one; Redis in a single-VPS queue setup rarely does anything interesting before Postgres does.

Metrics to graph in Grafana

Grafana goes in the same compose file, pinned (grafana/grafana:13.2.0 at the time of writing), with a volume on /var/lib/grafana and its port published only on 127.0.0.1:3000 or behind the reverse proxy with a login. Add Prometheus as a data source under Connections, Data sources, with the URL http://prometheus:9090. Then build one dashboard with these panels. No JSON to import, they're all one-line queries:

1. Queue depth: n8n_scaling_mode_queue_jobs_waiting, as a time series. The one graph that matters in queue mode. On our TV in the office this is the only n8n panel anyone looks at.

2. Active jobs: n8n_scaling_mode_queue_jobs_active next to the sum of worker concurrency, so you can see when workers are saturated.

3. Failure rate: rate(n8n_scaling_mode_queue_jobs_failed[5m]) and rate(n8n_scaling_mode_queue_jobs_completed[5m]) on the same axis. Both are counters; a raw counter graph is a line that goes up forever and tells you nothing.

4. Memory per process: process_resident_memory_bytes{job=~"n8n-.*"}, one line per instance label. A worker that climbs between restarts has a workflow holding onto data, and the n8n performance tuning notes cover the usual suspects (large Code node payloads, missing pagination, binary data in memory). Restarting the worker every night is the wrong fix, and I did it for a month anyway.

5. Event loop lag: nodejs_eventloop_lag_seconds{job="n8n-main"}. This is the earliest signal that main is struggling, and its usually left off dashboards. Ours sits at 8 to 12 ms idle. It went to 200 ms the day someone put a Code node that sorted 60,000 items on the main instance instead of offloading to a worker, and the editor felt sluggish before anything failed.

6. Webhook latency, if you enabled the webhook histogram: histogram_quantile(0.95, sum(rate(n8n_webhook_request_duration_seconds_bucket[5m])) by (le)).

A stat panel with up{job=~"n8n-.*"} at the top rounds it off. If you want the host side (disk, load, network) on the same board, the node exporter series are there under the node job. And if you'd like Grafana on its own server, the Grafana VPS template deploys it with Docker so the dashboard survives the n8n box having a bad day. The visualize metrics with Grafana page in the n8n docs points to a GitHub project with pre-built dashboards for webhooks, forms and the scheduler, which is a reasonable starting point if you'd rather import than build. Import one, then delete the panels you don't understand.

Alert rules for n8n in Prometheus

prometheus/alerts.yml, referenced from the rule_files block above:

groups:
  - name: n8n
    rules:
      - alert: N8nMainDown
        expr: up{job="n8n-main"} == 0
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "n8n main is not answering /metrics"

      - alert: N8nQueueBacklog
        expr: n8n_scaling_mode_queue_jobs_waiting > 50
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "{{ $value }} jobs waiting for 10 minutes"

      - alert: N8nFailedJobs
        expr: rate(n8n_scaling_mode_queue_jobs_failed[10m]) > 0.05
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "n8n failing more than 3 jobs a minute"

      - alert: N8nEventLoopLag
        expr: nodejs_eventloop_lag_seconds{job="n8n-main"} > 0.5
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "n8n main event loop lag above 500 ms"

The thresholds are mine and the backlog one especially depends on your traffic: 50 waiting jobs is a problem on an instance that normally has zero and nothing on one that batches 5,000 webhooks every hour. Watch the graph for a week before you pick a number. The alert on up covers the "main is dead" case; for "main is up but can't reach Postgres" you'd need to probe /healthz/readiness, which is a job for the blackbox exporter or for Uptime Kuma below, since Prometheus itself only knows what /metrics tells it. Rule syntax and the for: semantics are in the Prometheus alerting rules documentation. Delivery goes through Alertmanager, which is its own container and its own config; I won't go into it here beyond saying that a Slack webhook receiver takes ten lines.

OpenTelemetry tracing in n8n 2.19 and later

Metrics tell you main is slow. Traces tell you which node in which workflow made it slow. Since 2.19 the community edition can export OTLP traces:

N8N_OTEL_ENABLED=true
N8N_OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo:4318
N8N_OTEL_TRACES_SAMPLE_RATE=0.1
N8N_OTEL_TRACES_INCLUDE_NODE_SPANS=true

The endpoint is anything that speaks OTLP over HTTP: Grafana Tempo, Jaeger, an OpenTelemetry Collector. Node spans give you a span per node execution, which is the useful part and also the expensive part, so keep the sample rate low. There's also N8N_OTEL_TRACES_PRODUCTION_ONLY to skip manual runs from the editor. The variable list is on the OpenTelemetry environment variables page. I have it running against Tempo with a 10% sample and it has paid for itself exactly once, finding a sub-workflow that was being called 40 times per parent run. Most weeks nobody opens it. That's fine; tracing is the thing you're glad exists on the bad day.

Outside-in checks with Uptime Kuma

Prometheus scraping from inside the compose network tells you nothing about the path your customers use: DNS, the TLS certificate, the reverse proxy, the public webhook URL. An Uptime Kuma instance on a different server, hitting https://n8n.example.com/healthz/readiness every minute and a test webhook every five, covers that side with almost no setup. Keep it on another machine, or it goes down with the thing it's watching.

When an alert fires and the graphs don't explain it, the n8n troubleshooting cheat sheet is the next stop: it's organised by the error strings you'll find in docker compose logs. Most alerts here map to two or three entries there.

Automate faster, for less

Bring your winning ideas to life with AMD power, NVMe speed and unmetered bandwidth.