VPS Monitoring and Alerting: Prometheus, Grafana, Alertmanager, and Slack Integration

19 min read
monitoringprometheusgrafanaalertmanagernode exporterslackobservability

Monitoring is not optional for production systems. Without metrics, you discover problems when users report them. Without alerting, you discover problems when you happen to check. A proper monitoring stack with Prometheus, Grafana, and Alertmanager gives you real-time visibility into system health, automated alerting for anomalies, and historical data for capacity planning.

According to the 2025 SRE Report, teams with automated monitoring and alerting resolve incidents 6.3x faster than teams relying on manual checks. The median time to detect an issue drops from 38 minutes (manual) to 2.4 minutes (automated). This guide builds the complete stack on a single VPS using Docker Compose.

---

Stack Architecture

Node Exporter (system metrics)

|

v

Prometheus (scrape + store)

|

+--> Grafana (dashboards)

|

+--> Alertmanager (routing)

|

+--> Slack

+--> Email

+--> PagerDuty

ComponentPurposePortResource Usage

PrometheusMetrics collection and storage9090200-500 MB RAM
GrafanaDashboard visualization3001100-200 MB RAM
AlertmanagerAlert routing and deduplication909350 MB RAM
Node ExporterSystem metrics (CPU, RAM, disk)910020 MB RAM

Total resource overhead: approximately 400-800 MB RAM and minimal CPU. On a 4 GB VPS, this leaves 3+ GB for your application.

---

Step 1: Docker Compose Stack

Deploy the entire monitoring stack with a single `docker compose up -d`.

<h1>docker-compose.monitoring.yml</h1>

version: "3.8"

services:

prometheus:

image: prom/prometheus:v2.53.0

container_name: prometheus

volumes:

- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml

- ./prometheus/alert-rules.yml:/etc/prometheus/alert-rules.yml

- prometheus_data:/prometheus

command:

- "--config.file=/etc/prometheus/prometheus.yml"

- "--storage.tsdb.path=/prometheus"

- "--storage.tsdb.retention.time=30d"

- "--web.enable-lifecycle"

ports:

- "127.0.0.1:9090:9090"

restart: unless-stopped

networks:

- monitoring

grafana:

image: grafana/grafana:11.1.0

container_name: grafana

volumes:

- grafana_data:/var/lib/grafana

- ./grafana/provisioning:/etc/grafana/provisioning

environment:

- GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin}

- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}

- GF_SERVER_ROOT_URL=https://monitoring.yourdomain.com

- GF_USERS_ALLOW_SIGN_UP=false

ports:

- "127.0.0.1:3001:3000"

restart: unless-stopped

networks:

- monitoring

alertmanager:

image: prom/alertmanager:v0.27.0

container_name: alertmanager

volumes:

- ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml

ports:

- "127.0.0.1:9093:9093"

restart: unless-stopped

networks:

- monitoring

node-exporter:

image: prom/node-exporter:v1.8.0

container_name: node-exporter

command:

- "--path.rootfs=/host"

- "--collector.filesystem.mount-points-exclude=^/(sys

procdevhostetc)($$
/)"

volumes:

- /:/host:ro,rslave

ports:

- "127.0.0.1:9100:9100"

restart: unless-stopped

pid: host

networks:

- monitoring

volumes:

prometheus_data:

grafana_data:

networks:

monitoring:

driver: bridge

All ports bind to `127.0.0.1`, making them inaccessible from the internet. Nginx will proxy external access with authentication. Never expose Prometheus or Grafana ports publicly without authentication.

---

Step 2: Prometheus Configuration

Prometheus scrapes metrics from targets at regular intervals and stores them in a time-series database.

<h1>prometheus/prometheus.yml</h1>

global:

scrape_interval: 15s

evaluation_interval: 15s

rule_files:

- "alert-rules.yml"

alerting:

alertmanagers:

- static_configs:

- targets:

- alertmanager:9093

scrape_configs:

# Prometheus self-monitoring

- job_name: "prometheus"

static_configs:

- targets: ["localhost:9090"]

# System metrics from Node Exporter

- job_name: "node"

static_configs:

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

relabel_configs:

- source_labels: [__address__]

target_label: instance

replacement: "my-vps"

# Application metrics

- job_name: "my-app"

metrics_path: /metrics

static_configs:

- targets: ["host.docker.internal:3000"]

scrape_interval: 10s

# Nginx metrics (requires nginx-prometheus-exporter)

- job_name: "nginx"

static_configs:

- targets: ["host.docker.internal:9113"]

The `relabel_configs` section sets a human-readable instance label ("my-vps") instead of the default IP:port. This makes dashboards and alerts more readable.

---

Step 3: Alert Rules

Alert rules define conditions that trigger notifications. These rules cover the five most critical failure modes for a VPS: high CPU, high memory, disk filling up, application down, and high error rate.

<h1>prometheus/alert-rules.yml</h1>

groups:

- name: system-alerts

rules:

- alert: HighCPUUsage

expr: 100 - (avg by(instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85

for: 5m

labels:

severity: warning

annotations:

summary: "High CPU usage on {{ $labels.instance }}"

description: "CPU usage is above 85% for 5+ minutes. Current value: {{ $value | printf "%.1f" }}%"

- alert: HighMemoryUsage

expr: (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100 > 90

for: 5m

labels:

severity: critical

annotations:

summary: "High memory usage on {{ $labels.instance }}"

description: "Memory usage is above 90% for 5+ minutes. Current value: {{ $value | printf "%.1f" }}%"

- alert: DiskSpaceLow

expr: (1 - (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"})) * 100 > 85

for: 10m

labels:

severity: warning

annotations:

summary: "Disk space low on {{ $labels.instance }}"

description: "Root filesystem usage is above 85%. Current value: {{ $value | printf "%.1f" }}%"

- alert: DiskSpaceCritical

expr: (1 - (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"})) * 100 > 95

for: 5m

labels:

severity: critical

annotations:

summary: "CRITICAL: Disk almost full on {{ $labels.instance }}"

description: "Root filesystem usage is above 95%. Immediate action required. Current value: {{ $value | printf "%.1f" }}%"

- name: application-alerts

rules:

- alert: ApplicationDown

expr: up{job="my-app"} == 0

for: 1m

labels:

severity: critical

annotations:

summary: "Application is down"

description: "The application has been unreachable for 1+ minute. Check application logs and process status."

- alert: HighErrorRate

expr: sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) > 0.05

for: 5m

labels:

severity: critical

annotations:

summary: "High 5xx error rate"

description: "More than 5% of requests are returning 5xx errors. Current rate: {{ $value | printf "%.2f" }}"

- alert: HighResponseLatency

expr: histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le)) > 2

for: 5m

labels:

severity: warning

annotations:

summary: "High response latency"

description: "p95 latency is above 2 seconds. Current value: {{ $value | printf "%.2f" }}s"

- alert: SSLCertExpiringSoon

expr: (node_textfile_ssl_cert_not_after - time()) / 86400 < 14

for: 1h

labels:

severity: warning

annotations:

summary: "SSL certificate expiring soon"

description: "SSL certificate expires in {{ $value | printf "%.0f" }} days"

The `for` clause prevents flapping alerts. A 5-minute `for` clause means the condition must be continuously true for 5 minutes before firing. Without it, a momentary CPU spike triggers an unnecessary alert.

---

Step 4: Alertmanager Configuration

Alertmanager receives alerts from Prometheus, deduplicates them, groups related alerts, and routes them to notification channels.

<h1>alertmanager/alertmanager.yml</h1>

global:

resolve_timeout: 5m

route:

group_by: ["alertname", "instance"]

group_wait: 30s

group_interval: 5m

repeat_interval: 4h

receiver: "slack-notifications"

routes:

- match:

severity: critical

receiver: "slack-critical"

repeat_interval: 1h

- match:

severity: warning

receiver: "slack-notifications"

repeat_interval: 4h

receivers:

- name: "slack-notifications"

slack_configs:

- api_url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"

channel: "#monitoring"

send_resolved: true

title: '{{ if eq .Status "firing" }}ALERT{{ else }}RESOLVED{{ end }}: {{ .CommonAnnotations.summary }}'

text: '{{ .CommonAnnotations.description }}'

color: '{{ if eq .Status "firing" }}danger{{ else }}good{{ end }}'

- name: "slack-critical"

slack_configs:

- api_url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"

channel: "#alerts-critical"

send_resolved: true

title: 'CRITICAL: {{ .CommonAnnotations.summary }}'

text: '{{ .CommonAnnotations.description }}'

color: "danger"

inhibit_rules:

- source_match:

severity: "critical"

target_match:

severity: "warning"

equal: ["alertname", "instance"]

The `inhibit_rules` section prevents duplicate notifications. If a critical alert fires for high memory, the corresponding warning alert is suppressed. The `repeat_interval` controls how often firing alerts re-notify: every 1 hour for critical, every 4 hours for warning.

---

Step 5: Grafana Dashboard Provisioning

Auto-provision Grafana with a Prometheus data source and dashboards. No manual setup needed after `docker compose up`.

<h1>grafana/provisioning/datasources/prometheus.yml</h1>

apiVersion: 1

datasources:

- name: Prometheus

type: prometheus

access: proxy

url: http://prometheus:9090

isDefault: true

editable: false

<h1>grafana/provisioning/dashboards/dashboard.yml</h1>

apiVersion: 1

providers:

- name: "default"

orgId: 1

folder: ""

type: file

disableDeletion: false

editable: true

options:

path: /etc/grafana/provisioning/dashboards/json

Download the Node Exporter Full dashboard (ID: 1860) from Grafana.com and place the JSON in the dashboards directory:

<h1>Download popular Node Exporter dashboard</h1>

mkdir -p grafana/provisioning/dashboards/json

curl -o grafana/provisioning/dashboards/json/node-exporter.json \

"https://grafana.com/api/dashboards/1860/revisions/latest/download"

---

Step 6: Application Metrics

Expose application-level metrics from your Node.js application using the `prom-client` library.

const promClient = require("prom-client");

// Create a registry

const register = new promClient.Registry();

// Add default metrics (CPU, memory, event loop)

promClient.collectDefaultMetrics({ register });

// Custom metrics

const httpRequestDuration = new promClient.Histogram({

name: "http_request_duration_seconds",

help: "Duration of HTTP requests in seconds",

labelNames: ["method", "route", "status"],

buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5],

registers: [register]

});

const httpRequestsTotal = new promClient.Counter({

name: "http_requests_total",

help: "Total number of HTTP requests",

labelNames: ["method", "route", "status"],

registers: [register]

});

// Middleware to track request metrics

app.use((req, res, next) => {

const end = httpRequestDuration.startTimer();

res.on("finish", () => {

const route = req.route ? req.route.path : req.path;

const labels = {

method: req.method,

route: route,

status: res.statusCode

};

end(labels);

httpRequestsTotal.inc(labels);

});

next();

});

// Metrics endpoint

app.get("/metrics", async (req, res) => {

res.set("Content-Type", register.contentType);

res.end(await register.metrics());

});

---

Step 7: Nginx Proxy for Grafana

Expose Grafana through nginx with HTTPS and basic authentication for an additional security layer.

server {

listen 443 ssl http2;

server_name monitoring.yourdomain.com;

ssl_certificate /etc/letsencrypt/live/monitoring.yourdomain.com/fullchain.pem;

ssl_certificate_key /etc/letsencrypt/live/monitoring.yourdomain.com/privkey.pem;

location / {

proxy_pass http://127.0.0.1:3001;

proxy_set_header Host $host;

proxy_set_header X-Real-IP $remote_addr;

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

proxy_set_header X-Forwarded-Proto $scheme;

# WebSocket support for Grafana Live

proxy_http_version 1.1;

proxy_set_header Upgrade $http_upgrade;

proxy_set_header Connection "upgrade";

}

}

---

Essential PromQL Queries

Use these queries in Grafana panels and Prometheus alerts.

MetricPromQL Query

CPU usage %`100 - (avg(irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)`
Memory usage %`(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100`
Disk usage %`(1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100`
Network received`irate(node_network_receive_bytes_total{device="eth0"}[5m]) * 8`
Request rate`sum(rate(http_requests_total[5m]))`
Error rate %`sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) * 100`
p95 latency`histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))`
Uptime`time() - node_boot_time_seconds`

---

Testing the Stack

<h1>Start the monitoring stack</h1>

docker compose -f docker-compose.monitoring.yml up -d

<h1>Verify all containers are running</h1>

docker compose -f docker-compose.monitoring.yml ps

<h1>Check Prometheus targets</h1>

curl -s http://localhost:9090/api/v1/targets | jq ".data.activeTargets[].health"

<h1>Check Alertmanager status</h1>

curl -s http://localhost:9093/api/v2/status | jq ".cluster"

<h1>Test alert routing (fire a test alert)</h1>

curl -X POST http://localhost:9093/api/v2/alerts \

-H "Content-Type: application/json" \

-d '[{

"labels": {"alertname": "TestAlert", "severity": "warning", "instance": "test"},

"annotations": {"summary": "Test alert", "description": "This is a test alert"},

"startsAt": "2026-01-01T00:00:00Z"

}]'

<h1>View Grafana</h1>

echo "Open http://localhost:3001 (admin / your-password)"

For the complete Node.js server setup that includes the application metrics endpoint, see our Node.js deployment checklist. If you are migrating your monitoring stack to Kubernetes, read our Docker Compose to Kubernetes migration guide. To set up alerts for canary deployments, check our canary deployment best practices.

---

Frequently Asked Questions

How much disk space does Prometheus use?

Prometheus uses approximately 1-2 bytes per sample. With a 15-second scrape interval, 100 metrics, and 30-day retention, expect approximately 5-10 GB of disk usage. The `--storage.tsdb.retention.time=30d` flag automatically deletes data older than 30 days. Monitor disk usage with the `DiskSpaceLow` alert rule.

Should I run monitoring on the same VPS as my application?

For small applications (1-2 VPS), running monitoring on the same server is practical and cost-effective. The monitoring stack uses 400-800 MB RAM, which is acceptable on a 4+ GB VPS. For critical applications, run monitoring on a separate VPS so you can still access metrics and dashboards when the application server is down.

How do I add monitoring for multiple VPS servers?

Install Node Exporter on each server and add their addresses to the Prometheus `scrape_configs`. Use `relabel_configs` to set human-readable instance names. All servers report to the central Prometheus instance. For 5+ servers, consider Prometheus federation or Thanos for scalable long-term storage.

What Grafana dashboards should I install?

Start with three dashboards: Node Exporter Full (ID: 1860) for system metrics, Docker Container Monitoring (ID: 893) for container metrics, and a custom application dashboard with request rate, error rate, and latency panels. Build custom dashboards only after understanding which metrics matter for your specific application.

How do I prevent alert fatigue?

Set appropriate `for` durations (5+ minutes for warnings, 1+ minute for critical). Use Alertmanager grouping to batch related alerts. Set `repeat_interval` to 4+ hours for warnings. Only page on-call for critical alerts. Route warnings to a Slack channel that engineers check during business hours. Review and tune thresholds monthly based on actual incident data.

Can I use this stack with Kubernetes instead of VPS?

Yes, but consider using the kube-prometheus-stack Helm chart instead. It bundles Prometheus, Grafana, Alertmanager, and pre-configured dashboards with Kubernetes-specific metrics (pod restarts, deployment rollouts, node conditions). The monitoring concepts and PromQL queries in this guide apply directly to Kubernetes environments.