All Posts Development

How to Build a Custom Monitoring Dashboard with Grafana and n8n

Are you tired of finding out your server went down because a customer called to complain? A custom monitoring dashboard built with Grafana and n8n gives you real-time visibility.

Are you tired of finding out your server went down because a customer called to complain? Or discovering that your backup failed three days ago because nobody was watching? If your current monitoring strategy is “hope nothing breaks,” you are not alone — but you are also one bad weekend away from a serious problem.

A custom monitoring dashboard built with Grafana and n8n gives you real-time visibility into your entire infrastructure — servers, services, disk space, memory, network — with automated alerts that notify you the moment something goes wrong, not three days later. The stack I am walking you through in this article uses Grafana for visualization, Prometheus for metric collection, and n8n for intelligent alerting workflows. All three are free. All three run in Docker. And you can have the whole thing operational in under an hour.

This is not a toy setup. This is the same monitoring architecture that companies ten times your size are running. The difference is that they paid someone six figures to build it. You are going to build it yourself with Docker Compose and about 200 lines of configuration.

Why Small Businesses Need Real Monitoring

Let me tell you what I see when I walk into a small business that does not have monitoring. I see a server closet with a blinking light that has been blinking for weeks. Nobody knows what it means. I see a NAS drive at 94 percent capacity that nobody has checked since it was installed. I see an internet connection that drops for thirty seconds every afternoon at 2:15 PM, and the staff has just gotten used to refreshing their browsers.

These are not catastrophic failures. They are slow leaks. They cost you ten minutes here, twenty minutes there, a frustrated customer who gave up on your website. Individually, they seem minor. Over a year, they add up to thousands of dollars in lost productivity and missed opportunities.

Monitoring does not prevent problems. Monitoring tells you about problems early, before they compound. A disk at 94 percent is a warning. A disk at 100 percent is an outage. The difference between those two scenarios is whether someone was watching.

Businesses across Daytona Beach and Volusia County are running on infrastructure that nobody is actively watching. That is not a criticism. It is just reality when you are a 10-person company and nobody’s job title includes “systems administrator.” But the infrastructure does not care about your org chart. It fails on its own schedule.

The monitoring stack I am about to show you changes this. It watches everything, all the time, and tells you when something needs attention. It is the IT equivalent of putting smoke detectors in every room instead of just sniffing the air occasionally.

The Architecture: What Each Piece Does

Before I show you the code, let me explain what each component does and why it matters. This is the hidden layer that most tutorials skip, and it is the part that will save you when something unexpected happens.

Prometheus: The Data Collector

Prometheus is a time-series database that collects metrics from your infrastructure. Every 15 seconds (configurable), it reaches out to your servers and services, asks “how are you doing,” and stores the answer. CPU usage, memory consumption, disk space, network traffic, application response times — Prometheus collects all of it and stores it with timestamps.

The key insight about Prometheus is its pull model. Prometheus reaches out to your services rather than your services pushing data to Prometheus. This matters because if a service goes down, Prometheus notices immediately — the scrape fails, and that failure itself becomes a data point. With push-based systems, you only know a service is down when it stops pushing, which means you are waiting for a timeout.

Grafana: The Visualization Layer

Grafana takes the raw numbers that Prometheus collects and turns them into dashboards you can actually understand. Instead of staring at a terminal full of numbers, you get line charts showing CPU usage over time, gauges showing current disk space, tables showing the status of every service, and color-coded panels that go red when something needs attention.

Grafana also has a built-in alerting engine, but I am going to show you something better. We are going to use n8n for alerting because n8n gives you far more flexibility in what happens when an alert fires.

n8n: The Intelligent Alerting Engine

This is where the setup gets interesting. Most monitoring tutorials stop at “Grafana sends you an email when something breaks.” That is fine for a single alert. But what happens when you want:

  • Different alerts to go to different people
  • Escalation if nobody acknowledges an alert within 30 minutes
  • A Slack message AND an email AND a text message for critical issues
  • Automatic remediation (restart a service, clear a temp folder, rotate logs)
  • A summary report every morning of everything that happened overnight

n8n handles all of this. It is a workflow automation platform that can receive alerts from Prometheus or Grafana and then do whatever you need with them. Send a Slack message. Create a ticket in your project management tool. Run a remediation script. Send a text to your on-call person. All of it, all automated, all configurable without writing code.

If you have been following our production-grade n8n workflows guide, you already know how to build robust n8n workflows with error handling. The monitoring alerts we build here follow those same patterns.

Node Exporter: The System Reporter

One more piece. Prometheus needs something to talk to on each server. That something is called an exporter. Node Exporter is the standard exporter for Linux systems, and Windows Exporter handles Windows machines. They expose system metrics (CPU, memory, disk, network) in a format that Prometheus can scrape.

Think of Node Exporter as the thermometer, Prometheus as the chart recorder, Grafana as the display, and n8n as the person who calls the doctor when the temperature gets too high.

The Docker Compose Stack

Here is the complete Docker Compose file that brings up the entire monitoring stack. Save this as docker-compose.yml in a directory called monitoring.

version: "3.8"

services:
  prometheus:
    image: prom/prometheus:v2.53.0
    container_name: prometheus
    restart: unless-stopped
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
      - ./prometheus/alerts.yml:/etc/prometheus/alerts.yml
      - prometheus_data:/prometheus
    command:
      - "--config.file=/etc/prometheus/prometheus.yml"
      - "--storage.tsdb.retention.time=90d"
      - "--web.enable-lifecycle"
    ports:
      - "9090:9090"
    networks:
      - monitoring

  grafana:
    image: grafana/grafana:11.3.0
    container_name: grafana
    restart: unless-stopped
    environment:
      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-changeme}
      - GF_USERS_ALLOW_SIGN_UP=false
      - GF_SERVER_ROOT_URL=https://automateanddeploy.com:3000
    volumes:
      - grafana_data:/var/lib/grafana
      - ./grafana/provisioning:/etc/grafana/provisioning
    ports:
      - "3000:3000"
    depends_on:
      - prometheus
    networks:
      - monitoring

  node-exporter:
    image: prom/node-exporter:v1.8.2
    container_name: node-exporter
    restart: unless-stopped
    volumes:
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
      - /:/rootfs:ro
    command:
      - "--path.procfs=/host/proc"
      - "--path.rootfs=/rootfs"
      - "--path.sysfs=/host/sys"
      - "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)"
    ports:
      - "9100:9100"
    networks:
      - monitoring

  n8n:
    image: docker.n8n.io/n8nio/n8n:1.76.1
    container_name: n8n
    restart: unless-stopped
    environment:
      - N8N_BASIC_AUTH_ACTIVE=true
      - N8N_BASIC_AUTH_USER=admin
      - N8N_BASIC_AUTH_PASSWORD=${N8N_PASSWORD:-changeme}
      - N8N_HOST=localhost
      - N8N_PORT=5678
      - N8N_PROTOCOL=http
      - WEBHOOK_URL=http://n8n:5678/
      - N8N_METRICS=true
    volumes:
      - n8n_data:/home/node/.n8n
    ports:
      - "5678:5678"
    networks:
      - monitoring

  alertmanager:
    image: prom/alertmanager:v0.27.0
    container_name: alertmanager
    restart: unless-stopped
    volumes:
      - ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml
    command:
      - "--config.file=/etc/alertmanager/alertmanager.yml"
    ports:
      - "9093:9093"
    networks:
      - monitoring

volumes:
  prometheus_data:
  grafana_data:
  n8n_data:

networks:
  monitoring:
    driver: bridge

Let me walk through the important decisions in this file.

The restart: unless-stopped directive on every service means that if your server reboots, the monitoring stack comes back automatically. You do not have to remember to start it. The only time a service stays stopped is if you explicitly stop it with docker compose stop.

The prometheus_data and grafana_data volumes persist your data outside the containers. If you rebuild the containers (for an upgrade, for example), your metrics history and dashboard configurations survive. Without named volumes, a container rebuild would destroy everything.

The 90-day retention on Prometheus (--storage.tsdb.retention.time=90d) gives you three months of historical data. That is enough to spot trends and investigate incidents. You can increase this, but remember that more retention means more disk space. Plan for roughly 1-2 GB per month for a small infrastructure.

The N8N_METRICS=true environment variable tells n8n to expose its own metrics endpoint. This means Prometheus can monitor n8n itself — how many workflows are running, how many have failed, execution times. You are monitoring the monitoring.

Prometheus Configuration

Create a directory called prometheus and save this as prometheus/prometheus.yml:

global:
  scrape_interval: 15s
  evaluation_interval: 15s

rule_files:
  - "alerts.yml"

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

scrape_configs:
  - job_name: "prometheus"
    static_configs:
      - targets: ["localhost:9090"]

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

  - job_name: "n8n"
    metrics_path: /metrics
    static_configs:
      - targets: ["n8n:5678"]

  # Add your application targets here
  # - job_name: "my-web-app"
  #   static_configs:
  #     - targets: ["your-app:8080"]

The scrape_interval: 15s means Prometheus checks every target every 15 seconds. For a small infrastructure, this is the sweet spot between responsiveness and resource usage. You could go as low as 5 seconds for critical services, but 15 seconds catches most problems before they become outages.

Now create the alert rules file at prometheus/alerts.yml:

groups:
  - name: infrastructure
    rules:
      # Fires when a target is unreachable for 2 minutes
      - alert: TargetDown
        expr: up == 0
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Target {{ $labels.instance }} is down"
          description: "{{ $labels.job }} target {{ $labels.instance }} has been unreachable for more than 2 minutes."

      # Fires when disk usage exceeds 85%
      - alert: DiskSpaceWarning
        expr: (1 - (node_filesystem_avail_bytes / node_filesystem_size_bytes)) * 100 > 85
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Disk space above 85% on {{ $labels.instance }}"
          description: 'Filesystem {{ $labels.mountpoint }} on {{ $labels.instance }} is {{ $value | printf "%.1f" }}% full.'

      # Fires when disk usage exceeds 95%
      - alert: DiskSpaceCritical
        expr: (1 - (node_filesystem_avail_bytes / node_filesystem_size_bytes)) * 100 > 95
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Disk space CRITICAL on {{ $labels.instance }}"
          description: 'Filesystem {{ $labels.mountpoint }} on {{ $labels.instance }} is {{ $value | printf "%.1f" }}% full. Immediate action required.'

      # Fires when CPU usage exceeds 90% for 10 minutes
      - alert: HighCPUUsage
        expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "High CPU usage on {{ $labels.instance }}"
          description: "CPU usage has been above 90% for more than 10 minutes on {{ $labels.instance }}."

      # Fires when available memory drops below 10%
      - alert: LowMemory
        expr: (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100 < 10
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Low memory on {{ $labels.instance }}"
          description: 'Available memory is below 10% on {{ $labels.instance }}. Current available: {{ $value | printf "%.1f" }}%.'

  - name: n8n_workflows
    rules:
      # Fires when n8n workflow error rate spikes
      - alert: N8nHighErrorRate
        expr: rate(n8n_workflow_failed_total[5m]) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High n8n workflow failure rate"
          description: 'n8n workflows are failing at a rate of {{ $value | printf "%.2f" }} per second over the last 5 minutes.'

Let me explain the thinking behind these thresholds because this is where most monitoring setups go wrong.

The for duration on each alert is critical. The TargetDown alert fires after 2 minutes of unreachability, not immediately. Why? Because network blips happen. A momentary packet loss or a brief DNS hiccup should not wake you up at 3 AM. Two minutes of sustained unreachability, though, that is a real problem.

The disk space alerts have two tiers: warning at 85 percent and critical at 95 percent. The warning gives you time to clean up or expand storage. The critical means you are about to run out and something is probably going to break soon. I have seen businesses in Port Orange and Deltona lose entire days of productivity because a server ran out of disk space overnight and every application on it stopped working.

The CPU alert requires 10 minutes above 90 percent. Brief CPU spikes are normal — a backup running, a report generating, a batch process completing. Ten minutes of sustained high CPU usually means something is stuck or something unexpected is consuming resources. For a deeper look at this topic, see our guide on Automate QuickBooks Data Entry with n8n: Step-by-Step for Small Business.

Alertmanager Configuration

Create a directory called alertmanager and save this as alertmanager/alertmanager.yml:

global:
  resolve_timeout: 5m

route:
  group_by: ["alertname", "severity"]
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  receiver: "n8n-webhook"

  routes:
    - match:
        severity: critical
      receiver: "n8n-webhook-critical"
      repeat_interval: 1h

receivers:
  - name: "n8n-webhook"
    webhook_configs:
      - url: "http://n8n:5678/webhook/monitoring-alert"
        send_resolved: true

  - name: "n8n-webhook-critical"
    webhook_configs:
      - url: "http://n8n:5678/webhook/critical-alert"
        send_resolved: true

The group_wait: 30s setting collects all alerts that fire within a 30-second window and sends them as a single notification. Without this, a cascading failure (server goes down, taking five services with it) would generate six separate alerts. Grouping turns that into one notification that says “these six things all went wrong at the same time,” which is much more useful for diagnosis.

The repeat_interval: 4h for standard alerts and 1 hour for critical alerts controls how often you get reminded about unresolved problems. You do not want an email every 5 minutes about a disk that is still at 87 percent. You do want a reminder every hour about a server that is still unreachable.

Grafana Dashboard Provisioning

Create the provisioning directory structure:

mkdir -p grafana/provisioning/datasources
mkdir -p grafana/provisioning/dashboards

Save this as grafana/provisioning/datasources/prometheus.yml:

apiVersion: 1

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true
    editable: false

Save this as grafana/provisioning/dashboards/dashboard.yml:

apiVersion: 1

providers:
  - name: "Default"
    orgId: 1
    folder: ""
    type: file
    disableDeletion: false
    editable: true
    options:
      path: /etc/grafana/provisioning/dashboards
      foldersFromFilesStructure: false

Now save this as grafana/provisioning/dashboards/infrastructure.json. This is your starter dashboard:

{
  "dashboard": {
    "title": "Infrastructure Overview",
    "uid": "infra-overview",
    "timezone": "browser",
    "refresh": "30s",
    "panels": [
      {
        "title": "CPU Usage",
        "type": "gauge",
        "gridPos": { "h": 8, "w": 6, "x": 0, "y": 0 },
        "targets": [
          {
            "expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
            "legendFormat": "CPU %"
          }
        ],
        "fieldConfig": {
          "defaults": {
            "thresholds": {
              "steps": [
                { "color": "green", "value": null },
                { "color": "yellow", "value": 70 },
                { "color": "red", "value": 90 }
              ]
            },
            "max": 100,
            "unit": "percent"
          }
        }
      },
      {
        "title": "Memory Usage",
        "type": "gauge",
        "gridPos": { "h": 8, "w": 6, "x": 6, "y": 0 },
        "targets": [
          {
            "expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
            "legendFormat": "Memory %"
          }
        ],
        "fieldConfig": {
          "defaults": {
            "thresholds": {
              "steps": [
                { "color": "green", "value": null },
                { "color": "yellow", "value": 70 },
                { "color": "red", "value": 90 }
              ]
            },
            "max": 100,
            "unit": "percent"
          }
        }
      },
      {
        "title": "Disk Usage",
        "type": "gauge",
        "gridPos": { "h": 8, "w": 6, "x": 12, "y": 0 },
        "targets": [
          {
            "expr": "(1 - (node_filesystem_avail_bytes{mountpoint=\"/\"} / node_filesystem_size_bytes{mountpoint=\"/\"})) * 100",
            "legendFormat": "Disk %"
          }
        ],
        "fieldConfig": {
          "defaults": {
            "thresholds": {
              "steps": [
                { "color": "green", "value": null },
                { "color": "yellow", "value": 70 },
                { "color": "red", "value": 90 }
              ]
            },
            "max": 100,
            "unit": "percent"
          }
        }
      },
      {
        "title": "Service Status",
        "type": "stat",
        "gridPos": { "h": 8, "w": 6, "x": 18, "y": 0 },
        "targets": [
          {
            "expr": "count(up == 1)",
            "legendFormat": "Services Up"
          }
        ],
        "fieldConfig": {
          "defaults": {
            "thresholds": {
              "steps": [
                { "color": "red", "value": null },
                { "color": "green", "value": 1 }
              ]
            }
          }
        }
      },
      {
        "title": "CPU Usage Over Time",
        "type": "timeseries",
        "gridPos": { "h": 10, "w": 12, "x": 0, "y": 8 },
        "targets": [
          {
            "expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
            "legendFormat": "CPU %"
          }
        ]
      },
      {
        "title": "Memory Usage Over Time",
        "type": "timeseries",
        "gridPos": { "h": 10, "w": 12, "x": 12, "y": 8 },
        "targets": [
          {
            "expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
            "legendFormat": "Memory %"
          }
        ]
      },
      {
        "title": "Network Traffic",
        "type": "timeseries",
        "gridPos": { "h": 10, "w": 12, "x": 0, "y": 18 },
        "targets": [
          {
            "expr": "rate(node_network_receive_bytes_total{device!=\"lo\"}[5m]) * 8",
            "legendFormat": "Inbound (bps)"
          },
          {
            "expr": "rate(node_network_transmit_bytes_total{device!=\"lo\"}[5m]) * 8",
            "legendFormat": "Outbound (bps)"
          }
        ]
      },
      {
        "title": "Disk I/O",
        "type": "timeseries",
        "gridPos": { "h": 10, "w": 12, "x": 12, "y": 18 },
        "targets": [
          {
            "expr": "rate(node_disk_read_bytes_total[5m])",
            "legendFormat": "Read bytes/s"
          },
          {
            "expr": "rate(node_disk_written_bytes_total[5m])",
            "legendFormat": "Write bytes/s"
          }
        ]
      }
    ]
  }
}

This dashboard gives you eight panels covering the four critical infrastructure metrics: CPU, memory, disk, and network. The gauge panels at the top show current status with color coding (green, yellow, red). The time-series panels below show trends over time. You can customize this in Grafana’s UI once it is running — add panels, change thresholds, rearrange the layout.

The n8n Alert Workflow

This is where everything comes together. When Prometheus detects a problem, it sends an alert to Alertmanager. Alertmanager routes the alert to n8n via webhook. n8n receives the alert and executes your notification workflow.

Here is a Python script that generates the n8n workflow JSON for you. Save it as create_n8n_alert_workflow.py:

#!/usr/bin/env python3
"""
Generate n8n workflow JSON for monitoring alert handling.
Supports email, Slack, and escalation routing.
"""


from datetime import datetime


def create_alert_workflow():
    """Create the n8n workflow for processing monitoring alerts."""
    workflow = {
        "name": "Monitoring Alert Handler",
        "nodes": [
            {
                "parameters": {
                    "httpMethod": "POST",
                    "path": "monitoring-alert",
                    "responseMode": "onReceived",
                    "responseData": "allEntries"
                },
                "name": "Webhook - Standard Alert",
                "type": "n8n-nodes-base.webhook",
                "position": [250, 300]
            },
            {
                "parameters": {
                    "httpMethod": "POST",
                    "path": "critical-alert",
                    "responseMode": "onReceived",
                    "responseData": "allEntries"
                },
                "name": "Webhook - Critical Alert",
                "type": "n8n-nodes-base.webhook",
                "position": [250, 500]
            },
            {
                "parameters": {
                    "conditions": {
                        "string": [
                            {
                                "value1": "={{ $json.status }}",
                                "operation": "equals",
                                "value2": "firing"
                            }
                        ]
                    }
                },
                "name": "Is Firing?",
                "type": "n8n-nodes-base.if",
                "position": [500, 300]
            },
            {
                "parameters": {
                    "functionCode": (
                        "const alerts = items[0].json.alerts || [];\n"
                        "const formatted = alerts.map(alert => ({\n"
                        "  name: alert.labels.alertname,\n"
                        "  severity: alert.labels.severity,\n"
                        "  instance: alert.labels.instance,\n"
                        "  summary: alert.annotations.summary,\n"
                        "  description: alert.annotations.description,\n"
                        "  started: alert.startsAt,\n"
                        "  status: alert.status\n"
                        "}));\n"
                        "return formatted.map(f => ({ json: f }));"
                    )
                },
                "name": "Format Alert Data",
                "type": "n8n-nodes-base.function",
                "position": [700, 250]
            },
            {
                "parameters": {
                    "fromEmail": "[email protected]",
                    "toEmail": "[email protected]",
                    "subject": (
                        "=[{{ $json.severity | uppercase }}] "
                        "{{ $json.name }}: {{ $json.summary }}"
                    ),
                    "text": (
                        "=MONITORING ALERT\n\n"
                        "Alert: {{ $json.name }}\n"
                        "Severity: {{ $json.severity }}\n"
                        "Instance: {{ $json.instance }}\n\n"
                        "{{ $json.description }}\n\n"
                        "Started: {{ $json.started }}\n"
                        "Dashboard: http://your-grafana:3000"
                    )
                },
                "name": "Send Email Alert",
                "type": "n8n-nodes-base.emailSend",
                "position": [950, 200]
            },
            {
                "parameters": {
                    "channel": "#alerts",
                    "text": (
                        "=:rotating_light: *{{ $json.severity | uppercase }}* "
                        "- {{ $json.name }}\n"
                        "{{ $json.summary }}\n"
                        "_Instance: {{ $json.instance }}_\n"
                        "{{ $json.description }}"
                    )
                },
                "name": "Send Slack Alert",
                "type": "n8n-nodes-base.slack",
                "position": [950, 350]
            },
            {
                "parameters": {
                    "functionCode": (
                        "const alert = items[0].json;\n"
                        "const now = new Date();\n"
                        "const logEntry = {\n"
                        "  timestamp: now.toISOString(),\n"
                        "  alert_name: alert.name,\n"
                        "  severity: alert.severity,\n"
                        "  instance: alert.instance,\n"
                        "  summary: alert.summary,\n"
                        "  status: 'notified'\n"
                        "};\n"
                        "return [{ json: logEntry }];"
                    )
                },
                "name": "Log Alert",
                "type": "n8n-nodes-base.function",
                "position": [950, 500]
            },
            {
                "parameters": {
                    "functionCode": (
                        "const alert = items[0].json;\n"
                        "return [{ json: {\n"
                        "  message: `RESOLVED: ${alert.name} on "
                        "${alert.instance}`,\n"
                        "  resolved_at: new Date().toISOString()\n"
                        "}}];"
                    )
                },
                "name": "Format Resolution",
                "type": "n8n-nodes-base.function",
                "position": [700, 400]
            }
        ],
        "connections": {
            "Webhook - Standard Alert": {
                "main": [
                    [{"node": "Is Firing?", "type": "main", "index": 0}]
                ]
            },
            "Is Firing?": {
                "main": [
                    [{"node": "Format Alert Data", "type": "main", "index": 0}],
                    [{"node": "Format Resolution", "type": "main", "index": 0}]
                ]
            },
            "Format Alert Data": {
                "main": [
                    [
                        {"node": "Send Email Alert", "type": "main", "index": 0},
                        {"node": "Send Slack Alert", "type": "main", "index": 0},
                        {"node": "Log Alert", "type": "main", "index": 0}
                    ]
                ]
            }
        },
        "settings": {
            "errorWorkflow": "",
            "timezone": "America/New_York"
        }
    }
    return workflow


def create_daily_summary_workflow():
    """Create a daily summary workflow that reports overnight activity."""
    workflow = {
        "name": "Daily Monitoring Summary",
        "nodes": [
            {
                "parameters": {
                    "rule": {
                        "interval": [
                            {"triggerAtHour": 7, "triggerAtMinute": 0}
                        ]
                    }
                },
                "name": "Every Morning at 7 AM",
                "type": "n8n-nodes-base.scheduleTrigger",
                "position": [250, 300]
            },
            {
                "parameters": {
                    "url": "http://prometheus:9090/api/v1/query",
                    "qs": {
                        "query": "ALERTS{alertstate='firing'}"
                    }
                },
                "name": "Check Active Alerts",
                "type": "n8n-nodes-base.httpRequest",
                "position": [500, 300]
            },
            {
                "parameters": {
                    "url": "http://prometheus:9090/api/v1/query",
                    "qs": {
                        "query": (
                            "(1 - (node_filesystem_avail_bytes "
                            "/ node_filesystem_size_bytes)) * 100"
                        )
                    }
                },
                "name": "Check Disk Usage",
                "type": "n8n-nodes-base.httpRequest",
                "position": [500, 500]
            },
            {
                "parameters": {
                    "functionCode": (
                        "const alerts = items[0].json;\n"
                        "const disk = items[1].json;\n"
                        "const summary = {\n"
                        "  date: new Date().toLocaleDateString(),\n"
                        "  active_alerts: alerts.data?.result?.length || 0,\n"
                        "  disk_usage: disk.data?.result || [],\n"
                        "  status: alerts.data?.result?.length > 0 "
                        "? 'ATTENTION NEEDED' : 'ALL CLEAR'\n"
                        "};\n"
                        "return [{ json: summary }];"
                    )
                },
                "name": "Build Summary",
                "type": "n8n-nodes-base.function",
                "position": [750, 400]
            }
        ],
        "connections": {
            "Every Morning at 7 AM": {
                "main": [
                    [
                        {"node": "Check Active Alerts", "type": "main", "index": 0},
                        {"node": "Check Disk Usage", "type": "main", "index": 0}
                    ]
                ]
            },
            "Check Active Alerts": {
                "main": [
                    [{"node": "Build Summary", "type": "main", "index": 0}]
                ]
            }
        },
        "settings": {
            "timezone": "America/New_York"
        }
    }
    return workflow


if __name__ == "__main__":
    alert_wf = create_alert_workflow()
    summary_wf = create_daily_summary_workflow()

    with open("alert_workflow.json", "w") as f:
        json.dump(alert_wf, f, indent=2)
    print(f"Created alert_workflow.json")

    with open("daily_summary_workflow.json", "w") as f:
        json.dump(summary_wf, f, indent=2)
    print(f"Created daily_summary_workflow.json")

    print("\nImport these into n8n via Settings > Import Workflow")

Run this script with python3 create_n8n_alert_workflow.py and it will generate two workflow JSON files. Import them into n8n through the web interface (Settings, then Import from File). You will need to configure the email and Slack credentials in n8n before the workflows will send notifications.

The alert handler workflow does three things in parallel when an alert fires: sends an email, sends a Slack message, and logs the alert. This parallel execution means you get notified through multiple channels simultaneously. If your email is down (ironic, given this is a monitoring system), you still get the Slack message.

The daily summary workflow runs every morning at 7 AM and queries Prometheus for active alerts and current disk usage. It builds a summary and can send it via email or Slack. This gives you a daily health check without having to open the dashboard — you just read the summary over your morning coffee.

Deploying the Stack

With all the configuration files in place, your directory structure should look like this:

monitoring/
  docker-compose.yml
  prometheus/
    prometheus.yml
    alerts.yml
  alertmanager/
    alertmanager.yml
  grafana/
    provisioning/
      datasources/
        prometheus.yml
      dashboards/
        dashboard.yml
        infrastructure.json
  create_n8n_alert_workflow.py

Here is the deployment script. Save it as deploy.sh:

#!/bin/bash
# Deploy the monitoring stack
set -e

echo "=== Monitoring Stack Deployment ==="
echo "Starting at $(date)"

# Create .env file if it doesn't exist
if [ ! -f .env ]; then
    echo "Creating .env file with default passwords..."
    echo "GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 16)" > .env
    echo "N8N_PASSWORD=$(openssl rand -base64 16)" >> .env
    echo "Generated passwords saved to .env"
    echo "IMPORTANT: Save these credentials somewhere secure."
    cat .env
fi

# Validate Docker is available
if ! command -v docker &> /dev/null; then
    echo "ERROR: Docker is not installed or not in PATH."
    echo "Install Docker: https://docs.docker.com/get-docker/"
    exit 1
fi

# Pull images first (shows progress)
echo ""
echo "Pulling container images..."
docker compose pull

# Start the stack
echo ""
echo "Starting services..."
docker compose up -d

# Wait for services to be healthy
echo ""
echo "Waiting for services to start..."
sleep 10

# Check service status
echo ""
echo "=== Service Status ==="
docker compose ps

# Print access URLs
echo ""
echo "=== Access URLs ==="
echo "Grafana:      https://automateanddeploy.com:3000"
echo "Prometheus:   https://automateanddeploy.com:9090"
echo "n8n:          https://automateanddeploy.com:5678"
echo "Alertmanager: https://automateanddeploy.com:9093"
echo ""
echo "Credentials are in .env file"
echo ""
echo "Next steps:"
echo "1. Log into Grafana and change the admin password"
echo "2. Log into n8n and import the alert workflows"
echo "3. Configure email/Slack credentials in n8n"
echo "4. Test an alert by stopping node-exporter: docker compose stop node-exporter"
echo ""
echo "Deployment complete at $(date)"

Make it executable with chmod +x deploy.sh and run it. The script generates random passwords, pulls the container images, starts the stack, and prints the access URLs. The entire process takes about 2-3 minutes depending on your internet connection for the image downloads.

Testing Your Monitoring Setup

Here is the part that most tutorials skip, and it is the most important part. A monitoring system you have never tested is a monitoring system you do not know works.

Test 1: Verify Prometheus Is Scraping

Open Prometheus at https://automateanddeploy.com:9090/targets. You should see three targets — prometheus, node-exporter, and n8n — all showing “UP” in green. If any target shows “DOWN,” check that the corresponding container is running with docker compose ps.

Test 2: Verify Grafana Dashboards

Open Grafana at https://automateanddeploy.com:3000 and log in with the credentials from your .env file. The Infrastructure Overview dashboard should appear in the default folder. You should see the CPU, memory, disk, and network panels with live data. If panels show “No data,” check that Prometheus is configured as a data source (Settings, then Data Sources).

Test 3: Trigger a Test Alert

This is the critical test. Stop the node-exporter service to simulate a server going down:

docker compose stop node-exporter

Within 2 minutes, the TargetDown alert should fire in Prometheus. Check https://automateanddeploy.com:9090/alerts — you should see the alert in “firing” state. Alertmanager should receive it and forward it to n8n’s webhook. If you configured email or Slack in n8n, you should receive a notification.

Start node-exporter again:

docker compose start node-exporter

Within a few minutes, Prometheus should resolve the alert and send a resolution notification through n8n.

Test 4: Fill a Disk (Carefully)

If you want to test the disk space alert without actually filling your disk, you can temporarily lower the threshold. Edit prometheus/alerts.yml, change the DiskSpaceWarning threshold from 85 to something below your current usage (say 20 percent), and reload Prometheus:

curl -X POST https://automateanddeploy.com:9090/-/reload

The alert should fire within a minute. Change the threshold back and reload again.

Understanding PromQL: The Query Language Behind Your Dashboards

You do not need to become a PromQL expert to use this monitoring stack, but understanding the basics will help you build custom panels and write better alert rules.

Every metric in Prometheus has a name and a set of labels. For example, node_cpu_seconds_total{mode="idle", cpu="0"} is the total seconds that CPU 0 has spent idle. That single metric, combined with PromQL functions, tells you everything about CPU usage.

The rate() function calculates the per-second rate of change over a time window. rate(node_cpu_seconds_total{mode="idle"}[5m]) gives you the average idle rate over the last 5 minutes. Subtract that from 1 (or from 100 after multiplying) and you have CPU usage as a percentage. That is what every CPU panel in the dashboard is doing.

The avg by(instance) aggregation groups results by the instance label. If you have multiple CPUs (and you almost certainly do), this gives you the average across all cores instead of a separate line for each core.

Here is a practical example. Say you want to know how much disk space you will run out of at the current rate of consumption. PromQL can tell you:

predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[7d], 30*24*3600)

This takes the last 7 days of disk usage data and projects 30 days into the future. If the result is negative, your disk will be full within a month. You could create an alert on this — “DiskFullWithin30Days” — that gives you a month of warning instead of waiting until you hit 85 percent.

That kind of predictive alerting is the difference between monitoring that tells you about problems and monitoring that tells you about future problems. And it is built right into the query language.

Adding More Targets

The beauty of this setup is that adding a new service to monitor is trivial. You just add a new scrape_config entry to prometheus/prometheus.yml.

For example, to monitor a web application that exposes metrics on port 8080:

- job_name: "my-web-app"
  metrics_path: /metrics
  static_configs:
    - targets: ["192.168.1.50:8080"]

To monitor another server’s system metrics, install Node Exporter on that server and add it:

- job_name: "office-server"
  static_configs:
    - targets: ["192.168.1.100:9100"]

To monitor a Windows server, install Windows Exporter (the equivalent of Node Exporter for Windows) and add it the same way.

After editing the Prometheus config, reload it:

curl -X POST https://automateanddeploy.com:9090/-/reload

No restart needed. Prometheus picks up the new configuration within seconds.

What This Setup Does Not Cover

I want to be honest about the limitations, because understanding them is important for making good decisions about your infrastructure.

Log aggregation: This stack monitors metrics (numbers over time) but does not collect logs (text from application output). If you need log analysis, add Loki to the stack. It integrates natively with Grafana and adds maybe 500 MB of RAM to the resource requirements.

Application Performance Monitoring (APM): This monitors infrastructure metrics — CPU, memory, disk, network. It does not instrument your application code to track individual request performance, database query times, or user session behavior. For that, you need Jaeger or Tempo.

Synthetic monitoring: This tells you that your server is up and responding. It does not tell you that your website looks correct, that the checkout flow works, or that your API returns valid data. Synthetic monitoring requires tools like Uptime Robot or custom health check scripts.

Redundancy: If the server running this monitoring stack goes down, your monitoring goes down with it. For critical environments, run a second monitoring stack in a different location that watches the first one. For most small businesses, a single monitoring server with good uptime is sufficient.

The Custom-Built Advantage

This stack gets you 80 percent of what you need. For a small business running a handful of servers and a few applications, it covers the fundamentals beautifully. You will know when things break, you will see trends before they become problems, and you will have automated notifications that actually reach you.

The remaining 20 percent is where professional implementation makes the difference. When we build monitoring solutions for businesses across Daytona Beach, Ormond Beach, and Volusia County, we handle the parts that DIY setups typically miss:

  • Custom dashboards tailored to your specific business metrics, not just generic infrastructure panels
  • Compliance-ready alerting with documented escalation procedures and audit trails
  • Cross-site monitoring for businesses with multiple locations
  • Integrated incident management that creates tickets, assigns owners, and tracks resolution
  • Performance baselines that detect anomalies specific to your environment, not just generic thresholds
  • 24/7 monitoring by human eyes when critical alerts fire, not just email notifications that sit unread

If you have followed this tutorial and want to take your monitoring to the next level, our IT support services include professional monitoring implementation. We will audit your infrastructure, build custom dashboards, configure intelligent alerting, and make sure the whole thing actually works when you need it. Businesses in Deltona and across the county are already running on monitoring stacks we built and maintain.

Frequently Asked Questions

How do I build a custom monitoring dashboard with Grafana and n8n?

Deploy Grafana, Prometheus, and n8n using Docker Compose. Configure Prometheus to scrape metrics from your infrastructure using Node Exporter. Build visualization dashboards in Grafana connected to Prometheus as a data source. Create n8n workflows that receive alerts via webhook from Alertmanager and route them to email, Slack, or SMS. The Docker Compose file in this article deploys the entire stack in about 3 minutes.

What hardware do I need to run Grafana and Prometheus?

A small to medium-sized monitoring deployment runs comfortably on 2-4 CPU cores and 4-8 GB of RAM. The Docker Compose stack in this article uses approximately 1.5 GB of RAM at idle. Storage requirements depend on retention — plan for 1-2 GB per month of metric data. A virtual machine or a dedicated mini-PC is sufficient for monitoring up to 20-30 servers.

Can Grafana monitor Windows servers?

Yes. Install Windows Exporter on your Windows servers to expose system metrics in Prometheus format. Add the Windows server as a scrape target in Prometheus, and the metrics will appear in Grafana just like Linux metrics. Windows Exporter provides CPU, memory, disk, network, and Windows-specific metrics like Active Directory and IIS performance.

How is n8n better than Grafana’s built-in alerting?

Grafana’s built-in alerting handles basic notifications well. n8n adds workflow logic — conditional routing (send critical alerts to SMS, warnings to email only), escalation (if nobody acknowledges in 30 minutes, notify the manager), automatic remediation (restart a failed service), and daily summary reports. n8n turns simple notifications into intelligent incident response.

How much does this monitoring stack cost?

Every component is open source and free to use. Grafana, Prometheus, n8n, Node Exporter, and Alertmanager are all free software. Your only costs are the server to run them on (as low as $5-10 per month for a cloud VPS, or free if you use existing hardware) and your time to set it up and maintain it.

What to Do Right Now

  1. Install Docker on the server where you want to run monitoring.
  2. Create the directory structure with the configuration files from this article.
  3. Run the deploy script and verify all services start successfully.
  4. Import the n8n workflows and configure your notification credentials.
  5. Trigger a test alert by stopping node-exporter. Verify you receive notifications.
  6. Add your production targets to Prometheus and customize the Grafana dashboard.
  7. Set a calendar reminder to review your monitoring setup monthly.

You can have real-time infrastructure monitoring running in under an hour. The tools are free. The knowledge is in this article. The only thing standing between you and knowing when something breaks before your customers do is actually doing it.

For a deeper dive into building robust automation workflows, check out our guide on production-grade n8n workflows. And when you are ready for monitoring that somebody else maintains, reach out to our team.

Free Discovery Call

Start With a Conversation, Not a Commitment

Every engagement begins with a free 30-minute discovery call. We'll map what's slowing your business down and tell you exactly what we'd fix first – no pitch deck, no obligation.