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
| Component | Purpose | Port | Resource Usage |
| Prometheus | Metrics collection and storage | 9090 | 200-500 MB RAM |
| Grafana | Dashboard visualization | 3001 | 100-200 MB RAM |
| Alertmanager | Alert routing and deduplication | 9093 | 50 MB RAM |
| Node Exporter | System metrics (CPU, RAM, disk) | 9100 | 20 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
proc dev host etc)($$
/)"
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.
| Metric | PromQL 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.