Self-Hosting Prometheus and Grafana: Infrastructure Monitoring That Actually Works

Build a production-grade monitoring stack with Prometheus and Grafana from scratch. Covers architecture, Docker Compose deployment, Node Exporter, alerting with Alertmanager, PromQL from basics to advanced, pre-built dashboards, and everything CoderOasis uses to monitor its own infrastructure.

Monitoring is the thing most self-hosters and small teams do wrong for longer than any other part of their infrastructure. Not because it's hard — it's not — but because it feels non-urgent until it is. Until you're staring at an alert from your hosting provider at 2AM saying your server ran out of disk space three hours ago, and you have no idea why or when it started filling up, because you have no historical data. Until a database query starts performing badly and you can't tell if it's been slow for a day or a week or since a deploy last Tuesday.

I've been in that position. Before CoderOasis ran Prometheus and Grafana, our "monitoring" was a cron job that emailed us if the web server stopped responding. That's not monitoring. That's knowing the server is down after it's already down. Real monitoring tells you that disk usage crossed 70% eleven days ago and has been climbing 3% per day — giving you over a week of warning before the problem becomes an incident.

This guide builds a complete, production-ready monitoring stack. Prometheus for metrics collection and storage, Grafana for visualization and dashboards, Alertmanager for intelligent alert routing, and Node Exporter plus additional exporters to instrument your actual infrastructure. By the end, you'll have the same setup we run at CoderOasis: per-host metrics, per-container metrics, database performance, Traefik request rates, automated alerts to wherever your team communicates, and dashboards that let you answer infrastructure questions at a glance.

This pairs with the self-hosted productivity stack guide — Grafana appears there as one of the services we proxy through Teleport. It also integrates with the Teleport setup — we access Grafana through Teleport's application proxy, so it's never directly exposed on the internet.

What You're Actually Building

Prometheus's Data Model

Prometheus works by pulling (scraping) metrics from targets on a regular interval rather than receiving pushed metrics. Every target you monitor exposes an HTTP endpoint (typically /metrics) that returns the current value of all its metrics in a text format. Prometheus fetches that endpoint every 15 seconds (or whatever you configure), stores the time series, and makes it queryable.

This pull model has important operational properties. Prometheus knows when a target goes down (it can't scrape it). Targets don't need to know where Prometheus is. Adding new targets doesn't require restarting or reconfiguring anything that's already being monitored.

Prometheus stores data as time series: streams of timestamped values identified by a metric name and a set of key-value labels.

node_disk_read_bytes_total{device="sda", instance="web-01:9100", job="node"} 1.234567e+09
node_disk_read_bytes_total{device="sdb", instance="web-01:9100", job="node"} 2.345678e+08

Same metric name (node_disk_read_bytes_total), different label sets (device="sda" vs device="sdb"), different time series. PromQL — the query language — lets you filter, aggregate, and compute over these time series.

The Four Components

Prometheus Server: The core. Scrapes targets, stores time series in a local TSDB (time series database), evaluates alerting rules, and exposes a query API. Everything else talks to or from the Prometheus server.

Exporters: Adapters that translate existing metrics from applications and systems into Prometheus's format. Node Exporter exposes Linux host metrics (CPU, memory, disk, network). cAdvisor exposes Docker container metrics. PostgreSQL Exporter exposes Postgres statistics. The exporter ecosystem covers essentially every technology you'd want to monitor.

Alertmanager: Handles the alerting pipeline. Prometheus evaluates alert rules and fires alerts to Alertmanager. Alertmanager deduplicates, groups, silences, and routes alerts to the right destination (Slack, email, PagerDuty, etc.). Keeping alerting in a separate component means you can maintain alert routing and silencing even when Prometheus itself is being restarted.

Grafana: Visualization layer. Connects to Prometheus as a data source and renders dashboards, graphs, and alerts based on PromQL queries. Grafana is where your team actually spends time — it's the interface for asking infrastructure questions.

Installation using Docker Compose for the Stack

We run the entire monitoring stack in Docker Compose alongside our other services. This gives us easy updates, volume management, and the same operational model as the rest of our infrastructure.

Directory Structure

mkdir -p /opt/monitoring/{prometheus,grafana,alertmanager}
mkdir -p /opt/monitoring/prometheus/{rules,targets}
mkdir -p /opt/monitoring/grafana/{provisioning/{datasources,dashboards},dashboards}
 
# Set permissions -- Grafana runs as UID 472
chown -R 472:472 /opt/monitoring/grafana

The Core Docker Compose File

# /opt/monitoring/docker-compose.yml
version: "3.9"
 
networks:
  monitoring:
    driver: bridge
  proxy:
    external: true   # Shared with Traefik
 
volumes:
  prometheus_data:
  grafana_data:
  alertmanager_data:
 
services:
  prometheus:
    image: prom/prometheus:v2.52.0
    container_name: prometheus
    restart: unless-stopped
    user: "nobody"
    networks:
      - monitoring
    command:
      - "--config.file=/etc/prometheus/prometheus.yml"
      - "--storage.tsdb.path=/prometheus"
      - "--storage.tsdb.retention.time=90d"       # Keep 90 days of metrics
      - "--storage.tsdb.retention.size=50GB"       # Hard cap at 50GB
      - "--web.enable-lifecycle"                   # Allow config reload via HTTP POST
      - "--web.enable-admin-api"                   # Enable admin API for snapshot/delete
      - "--query.max-samples=50000000"             # Prevent runaway queries
    volumes:
      - /opt/monitoring/prometheus:/etc/prometheus:ro
      - prometheus_data:/prometheus
    ports:
      - "127.0.0.1:9090:9090"   # Localhost only -- access via Traefik or Teleport
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.prometheus.rule=Host(`prometheus.teleport.yourdomain.com`)"
      - "traefik.http.routers.prometheus.middlewares=teleport-auth"   # Auth via Teleport
 
  grafana:
    image: grafana/grafana:11.0.0
    container_name: grafana
    restart: unless-stopped
    networks:
      - monitoring
      - proxy
    volumes:
      - grafana_data:/var/lib/grafana
      - /opt/monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
      - /opt/monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
    environment:
      # Security
      GF_SECURITY_ADMIN_USER: admin
      GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD}"   # In .env file
      GF_SECURITY_SECRET_KEY: "${GRAFANA_SECRET_KEY}"
      GF_SECURITY_DISABLE_GRAVATAR: "true"
      GF_SECURITY_COOKIE_SECURE: "true"
      GF_SECURITY_COOKIE_SAMESITE: "strict"
 
      # Authentication -- disable anonymous, require login
      GF_AUTH_ANONYMOUS_ENABLED: "false"
      GF_AUTH_BASIC_ENABLED: "true"
 
      # Disable telemetry
      GF_ANALYTICS_REPORTING_ENABLED: "false"
      GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
 
      # Server
      GF_SERVER_DOMAIN: "grafana.teleport.yourdomain.com"
      GF_SERVER_ROOT_URL: "https://grafana.teleport.yourdomain.com"
 
      # Paths
      GF_PATHS_DATA: /var/lib/grafana
      GF_PATHS_LOGS: /var/log/grafana
      GF_PATHS_PLUGINS: /var/lib/grafana/plugins
      GF_PATHS_PROVISIONING: /etc/grafana/provisioning
 
      # Install useful plugins on first start
      GF_INSTALL_PLUGINS: >
        grafana-clock-panel,
        grafana-piechart-panel,
        grafana-worldmap-panel,
        vonage-status-panel
 
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.grafana.rule=Host(`grafana.teleport.yourdomain.com`)"
      - "traefik.http.routers.grafana.entrypoints=websecure"
      - "traefik.http.routers.grafana.tls.certresolver=letsencrypt"
      - "traefik.http.services.grafana.loadbalancer.server.port=3000"
 
  alertmanager:
    image: prom/alertmanager:v0.27.0
    container_name: alertmanager
    restart: unless-stopped
    networks:
      - monitoring
    command:
      - "--config.file=/etc/alertmanager/alertmanager.yml"
      - "--storage.path=/alertmanager"
      - "--web.external-url=https://alertmanager.teleport.yourdomain.com"
    volumes:
      - /opt/monitoring/alertmanager:/etc/alertmanager:ro
      - alertmanager_data:/alertmanager
    ports:
      - "127.0.0.1:9093:9093"
 
  # Node Exporter -- on every host you want to monitor
  # For the monitoring host itself:
  node-exporter:
    image: prom/node-exporter:v1.8.0
    container_name: node-exporter
    restart: unless-stopped
    network_mode: host    # Must be host mode to see all network interfaces
    pid: host             # Must see host PIDs for process metrics
    command:
      - "--path.rootfs=/host"
      - "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)"
      - "--collector.processes"
      - "--collector.systemd"
    volumes:
      - /:/host:ro,rslave
    # Note: no port mapping needed with network_mode: host
    # Exposed on :9100 on the host automatically
 
  # cAdvisor -- Docker container metrics
  cadvisor:
    image: gcr.io/cadvisor/cadvisor:v0.49.1
    container_name: cadvisor
    restart: unless-stopped
    privileged: true   # Required for container metric collection
    networks:
      - monitoring
    devices:
      - /dev/kmsg:/dev/kmsg
    volumes:
      - /:/rootfs:ro
      - /var/run:/var/run:ro
      - /sys:/sys:ro
      - /var/lib/docker:/var/lib/docker:ro
      - /cgroup:/cgroup:ro
    ports:
      - "127.0.0.1:8080:8080"

Create the .env file (never commit this to Git):

# /opt/monitoring/.env
GRAFANA_ADMIN_PASSWORD=generate-a-strong-password-here
GRAFANA_SECRET_KEY=generate-a-64-char-random-string-here
 
# Generate these:
# GRAFANA_ADMIN_PASSWORD: openssl rand -base64 24
# GRAFANA_SECRET_KEY: openssl rand -hex 32

Prometheus Configuration

Main Configuration File

# /opt/monitoring/prometheus/prometheus.yml
global:
  scrape_interval: 15s          # Default scrape interval
  evaluation_interval: 15s      # Rule evaluation interval
  scrape_timeout: 10s           # Timeout per scrape
  external_labels:
    cluster: 'coderoasis-production'
    environment: 'production'
 
# Reference to Alertmanager
alerting:
  alertmanagers:
    - static_configs:
        - targets:
            - alertmanager:9093
      timeout: 10s
 
# Load alerting rules from separate files
rule_files:
  - "rules/*.yml"
 
# Scrape configurations
scrape_configs:
  # Prometheus monitoring itself
  - job_name: 'prometheus'
    static_configs:
      - targets: ['localhost:9090']
    metrics_path: /metrics
 
  # The monitoring host itself (running node exporter in host network mode)
  - job_name: 'node-monitoring'
    static_configs:
      - targets: ['host.docker.internal:9100']
        labels:
          instance: 'monitoring-host'
          role: 'monitoring'
 
  # Docker containers (cAdvisor on the monitoring host)
  - job_name: 'cadvisor-monitoring'
    static_configs:
      - targets: ['cadvisor:8080']
        labels:
          instance: 'monitoring-host'
 
  # Production servers -- Node Exporter on each host
  - job_name: 'node'
    static_configs:
      - targets:
          - 'web-01.internal:9100'
          - 'web-02.internal:9100'
          - 'db-01.internal:9100'
        labels:
          env: production
    relabel_configs:
      # Use the target address as the instance label
      - source_labels: [__address__]
        regex: '([^:]+):.*'
        target_label: instance
 
  # File-based service discovery -- drop new targets in here
  # Useful for dynamic infrastructure
  - job_name: 'file-sd-nodes'
    file_sd_configs:
      - files:
          - '/etc/prometheus/targets/*.yml'
        refresh_interval: 30s
 
  # cAdvisor on production hosts
  - job_name: 'cadvisor'
    static_configs:
      - targets:
          - 'web-01.internal:8080'
          - 'web-02.internal:8080'
    metrics_path: /metrics
 
  # PostgreSQL exporter
  - job_name: 'postgres'
    static_configs:
      - targets:
          - 'db-01.internal:9187'
        labels:
          env: production
          db: postgresql
 
  # Nginx/Traefik metrics
  - job_name: 'traefik'
    static_configs:
      - targets:
          - 'traefik:8082'   # Traefik metrics port (internal Docker network)
        labels:
          component: traefik
 
  # Blackbox Exporter -- external endpoint monitoring (HTTP, TCP, ICMP)
  - job_name: 'blackbox-http'
    metrics_path: /probe
    params:
      module: [http_2xx]
    static_configs:
      - targets:
          - https://coderoasis.com
          - https://coderoasis.com/ghost/api/admin/
          - https://grafana.teleport.yourdomain.com
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target
      - source_labels: [__param_target]
        target_label: instance
      - target_label: __address__
        replacement: blackbox-exporter:9115

File-Based Service Discovery

Rather than restarting Prometheus every time you add a server, use file SD:

# /opt/monitoring/prometheus/targets/production-nodes.yml
- targets:
    - 'web-01.internal:9100'
    - 'web-02.internal:9100'
  labels:
    env: production
    role: webserver
    team: platform
 
- targets:
    - 'db-01.internal:9100'
  labels:
    env: production
    role: database
    team: platform

Prometheus watches this directory and hot-reloads when files change. Adding a new server: create or edit the YAML file. No Prometheus restart. New targets appear in the next scrape interval.

Alerting Rules

This is where Prometheus earns its keep for operations. Rules evaluate PromQL expressions on the scrape interval and fire alerts when conditions are met.

# /opt/monitoring/prometheus/rules/host-alerts.yml
groups:
  - name: host.rules
    interval: 30s    # Evaluate these every 30 seconds
    rules:
 
      # Disk space -- the one that bites you if you don't have monitoring
      - alert: DiskSpaceWarning
        expr: |
          (
            node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs"}
            / node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs"}
          ) * 100 < 25
        for: 10m     # Must be true for 10 consecutive minutes before alerting
        labels:
          severity: warning
          team: platform
        annotations:
          summary: "Disk space below 25% on {{ $labels.instance }}"
          description: >
            {{ $labels.instance }} device {{ $labels.device }} has
            {{ $value | printf "%.1f" }}% free disk space remaining.
          runbook: "https://wiki.internal/runbooks/disk-space"
 
      - alert: DiskSpaceCritical
        expr: |
          (
            node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs"}
            / node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs"}
          ) * 100 < 10
        for: 5m
        labels:
          severity: critical
          team: platform
        annotations:
          summary: "CRITICAL: Disk space below 10% on {{ $labels.instance }}"
          description: >
            {{ $labels.instance }} device {{ $labels.device }} has only
            {{ $value | printf "%.1f" }}% free. Immediate action required.
 
      # Disk filling rate -- predict when you'll run out
      - alert: DiskFillingFast
        expr: |
          predict_linear(
            node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs"}[6h],
            4 * 3600   # predict 4 hours ahead
          ) < 0
        for: 30m
        labels:
          severity: critical
          team: platform
        annotations:
          summary: "Disk predicted to fill in under 4 hours on {{ $labels.instance }}"
          description: >
            Based on the last 6 hours of data, {{ $labels.instance }}
            device {{ $labels.device }} will run out of disk space within 4 hours.
 
      # Memory
      - alert: MemoryUsageHigh
        expr: |
          (
            1 - (
              node_memory_MemAvailable_bytes
              / node_memory_MemTotal_bytes
            )
          ) * 100 > 90
        for: 15m
        labels:
          severity: warning
          team: platform
        annotations:
          summary: "Memory usage above 90% on {{ $labels.instance }}"
          description: >
            {{ $labels.instance }} is using {{ $value | printf "%.1f" }}%
            of available memory for 15+ minutes.
 
      # CPU
      - alert: CPUUsageHigh
        expr: |
          100 - (avg by (instance) (
            rate(node_cpu_seconds_total{mode="idle"}[5m])
          ) * 100) > 85
        for: 20m
        labels:
          severity: warning
          team: platform
        annotations:
          summary: "CPU usage above 85% on {{ $labels.instance }}"
          description: >
            {{ $labels.instance }} has had {{ $value | printf "%.1f" }}%
            CPU utilization for over 20 minutes.
 
      # Load average
      - alert: HighLoadAverage
        expr: |
          node_load1 / on(instance) count by (instance) (
            node_cpu_seconds_total{mode="idle"}
          ) > 2
        for: 15m
        labels:
          severity: warning
          team: platform
        annotations:
          summary: "Load average above 2x CPU count on {{ $labels.instance }}"
          description: >
            {{ $labels.instance }} 1-minute load average is
            {{ $value | printf "%.2f" }}x the number of CPU cores.
 
      # Target down -- the most important alert
      - alert: TargetDown
        expr: up == 0
        for: 5m
        labels:
          severity: critical
          team: platform
        annotations:
          summary: "Target {{ $labels.instance }} is down"
          description: >
            Prometheus cannot reach {{ $labels.job }}/{{ $labels.instance }}.
            It has been unreachable for 5+ minutes.
 
      # System reboot detection
      - alert: SystemRebooted
        expr: node_time_seconds - node_boot_time_seconds < 600
        for: 0m    # Fire immediately
        labels:
          severity: info
          team: platform
        annotations:
          summary: "{{ $labels.instance }} rebooted recently"
          description: >
            {{ $labels.instance }} has been up for less than 10 minutes,
            indicating a recent reboot.
 
---
# /opt/monitoring/prometheus/rules/database-alerts.yml
groups:
  - name: postgres.rules
    rules:
 
      - alert: PostgresDown
        expr: pg_up == 0
        for: 1m
        labels:
          severity: critical
          team: platform
        annotations:
          summary: "PostgreSQL is down on {{ $labels.instance }}"
 
      - alert: PostgresReplicationLag
        expr: pg_replication_lag > 30
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "PostgreSQL replication lag > 30s on {{ $labels.instance }}"
          description: "Replication lag is {{ $value | printf \"%.1f\" }} seconds."
 
      - alert: PostgresTooManyConnections
        expr: |
          pg_stat_activity_count / pg_settings_max_connections * 100 > 80
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "PostgreSQL connection count above 80% of max"
          description: >
            {{ $value | printf "%.1f" }}% of max_connections are in use.
 
      - alert: PostgresSlowQueries
        expr: |
          rate(pg_stat_statements_total_time_seconds[5m]) /
          rate(pg_stat_statements_calls_total[5m]) > 1
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "PostgreSQL average query time > 1s"
 
---
# /opt/monitoring/prometheus/rules/http-alerts.yml
groups:
  - name: http.rules
    rules:
 
      - alert: EndpointDown
        expr: probe_success == 0
        for: 3m
        labels:
          severity: critical
          team: platform
        annotations:
          summary: "HTTP probe failing for {{ $labels.instance }}"
          description: >
            The endpoint {{ $labels.instance }} has been failing
            HTTP health checks for 3+ minutes.
 
      - alert: SSLCertExpiringSoon
        expr: probe_ssl_earliest_cert_expiry - time() < 30 * 24 * 3600
        for: 1h
        labels:
          severity: warning
        annotations:
          summary: "SSL cert expiring within 30 days for {{ $labels.instance }}"
          description: >
            Certificate expires at {{ $value | humanizeTimestamp }}.
 
      - alert: SSLCertExpiryCritical
        expr: probe_ssl_earliest_cert_expiry - time() < 7 * 24 * 3600
        for: 30m
        labels:
          severity: critical
        annotations:
          summary: "SSL cert expiring within 7 DAYS for {{ $labels.instance }}"
 
      - alert: HighHTTPErrorRate
        expr: |
          sum by (instance) (rate(traefik_service_requests_total{code=~"5.."}[5m]))
          /
          sum by (instance) (rate(traefik_service_requests_total[5m]))
          * 100 > 5
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "HTTP 5xx error rate above 5% on {{ $labels.instance }}"
          description: "Error rate is {{ $value | printf \"%.2f\" }}%."

Pre-Compute Expensive Queries

Recording rules pre-compute expensive PromQL expressions and store the results as new time series. Use them for any query you run frequently in dashboards:

# /opt/monitoring/prometheus/rules/recording-rules.yml
groups:
  - name: aggregations
    interval: 1m
    rules:
      # Pre-compute CPU usage per instance -- used in every dashboard
      - record: instance:cpu_usage:rate5m
        expr: |
          100 - (avg by (instance) (
            rate(node_cpu_seconds_total{mode="idle"}[5m])
          ) * 100)
 
      # Pre-compute memory usage percentage
      - record: instance:memory_usage_percent
        expr: |
          (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100
 
      # Pre-compute disk usage per device
      - record: instance:disk_usage_percent
        expr: |
          (1 - (
            node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs"}
            / node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs"}
          )) * 100
 
      # HTTP request rate by job
      - record: job:http_requests:rate5m
        expr: sum by (job, code) (rate(traefik_service_requests_total[5m]))

Alertmanager Configuration

# /opt/monitoring/alertmanager/alertmanager.yml
global:
  # Default time an alert stays resolved before it's no longer considered active
  resolve_timeout: 5m
  # SMTP for email alerts
  smtp_smarthost: 'mail.yourdomain.com:587'
  smtp_from: '[email protected]'
  smtp_auth_username: '[email protected]'
  smtp_auth_password: "${SMTP_PASSWORD}"
  smtp_require_tls: true
 
# Alert routing tree
route:
  # Default receiver
  receiver: 'slack-platform'
  # Group alerts with same job+alertname together
  group_by: ['alertname', 'job', 'instance']
  # Wait before sending first notification for a new group
  group_wait: 30s
  # Interval for sending notifications when alerts in group haven't changed
  group_interval: 5m
  # Minimum interval before re-sending a firing alert
  repeat_interval: 4h
 
  routes:
    # Critical alerts go to Slack AND email immediately
    - match:
        severity: critical
      receiver: 'critical-all'
      group_wait: 10s        # Faster notification for critical
      repeat_interval: 1h    # Re-alert every hour if still firing
 
    # Database alerts go to DBA channel
    - match:
        team: dba
      receiver: 'slack-dba'
 
    # Info alerts go to low-noise channel
    - match:
        severity: info
      receiver: 'slack-info'
      group_wait: 2m
      repeat_interval: 24h   # Don't spam info alerts
 
receivers:
  - name: 'slack-platform'
    slack_configs:
      - api_url: "${SLACK_WEBHOOK_URL}"
        channel: '#platform-alerts'
        send_resolved: true
        title: |-
          [{{ .Status | toUpper }}{{ if eq .Status "firing" }}: {{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}
        text: >-
          {{ range .Alerts -}}
          *Alert:* {{ .Annotations.summary }}
          *Severity:* `{{ .Labels.severity }}`
          *Instance:* `{{ .Labels.instance }}`
          *Description:* {{ .Annotations.description }}
          {{ end }}
        color: '{{ if eq .Status "firing" }}danger{{ else }}good{{ end }}'
 
  - name: 'critical-all'
    slack_configs:
      - api_url: "${SLACK_WEBHOOK_URL}"
        channel: '#platform-critical'
        send_resolved: true
        title: "🚨 [CRITICAL] {{ .CommonLabels.alertname }}"
        text: >-
          {{ range .Alerts -}}
          *{{ .Annotations.summary }}*
          {{ .Annotations.description }}
          {{ end }}
    email_configs:
      - to: '[email protected]'
        subject: '[CRITICAL] {{ .CommonLabels.alertname }} on {{ .CommonLabels.instance }}'
        body: |
          {{ range .Alerts }}
          Alert: {{ .Annotations.summary }}
          Description: {{ .Annotations.description }}
          Severity: {{ .Labels.severity }}
          Instance: {{ .Labels.instance }}
          Starts At: {{ .StartsAt }}
          {{ end }}
 
  - name: 'slack-info'
    slack_configs:
      - api_url: "${SLACK_WEBHOOK_URL}"
        channel: '#platform-info'
        send_resolved: false   # Don't notify when info alerts resolve
 
  - name: 'slack-dba'
    slack_configs:
      - api_url: "${SLACK_DBA_WEBHOOK_URL}"
        channel: '#database-alerts'
        send_resolved: true
 
# Inhibition rules -- suppress certain alerts when others are firing
inhibit_rules:
  # If an instance is completely down, suppress other alerts for that instance
  - source_match:
      alertname: TargetDown
    target_match_re:
      alertname: '.+'
    equal:
      - instance
 
  # If disk is critically low, suppress the warning
  - source_match:
      alertname: DiskSpaceCritical
    target_match:
      alertname: DiskSpaceWarning
    equal:
      - instance
      - device

Installing Node Exporter on Every Host

On each server you want to monitor, Node Exporter needs to run. Two options: Docker (simpler, slightly less access) or system service (more access, especially for systemd metrics).

# Download and install Node Exporter on the target host
NODE_EXPORTER_VERSION="1.8.0"
cd /tmp
curl -LO "https://github.com/prometheus/node_exporter/releases/download/v${NODE_EXPORTER_VERSION}/node_exporter-${NODE_EXPORTER_VERSION}.linux-amd64.tar.gz"
tar xf "node_exporter-${NODE_EXPORTER_VERSION}.linux-amd64.tar.gz"
cp "node_exporter-${NODE_EXPORTER_VERSION}.linux-amd64/node_exporter" /usr/local/bin/
chmod +x /usr/local/bin/node_exporter
 
# Create system user
useradd --system --no-create-home --shell /bin/false node_exporter
 
# Create systemd service
cat > /etc/systemd/system/node_exporter.service << 'EOF'
[Unit]
Description=Prometheus Node Exporter
Documentation=https://github.com/prometheus/node_exporter
After=network-online.target
 
[Service]
User=node_exporter
Group=node_exporter
Type=simple
ExecStart=/usr/local/bin/node_exporter \
    --collector.filesystem.mount-points-exclude='^/(sys|proc|dev|host|etc)($$|/)' \
    --collector.processes \
    --collector.systemd \
    --web.listen-address=:9100
Restart=on-failure
RestartSec=5s
 
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
 
[Install]
WantedBy=multi-user.target
EOF
 
systemctl daemon-reload
systemctl enable node_exporter
systemctl start node_exporter
 
# Verify it's running and exposing metrics
curl http://localhost:9100/metrics | head -20

Firewall Rules

Node Exporter should only be accessible from your Prometheus server:

# Allow Prometheus to scrape Node Exporter
# Replace with your Prometheus server's actual IP
ufw allow from PROMETHEUS_SERVER_IP to any port 9100 proto tcp
ufw reload

Never expose port 9100 to the public internet. Node Exporter exposes detailed system information that's valuable for an attacker.

PostgreSQL Exporter Setup

# On the database server (or a host with access to the database)
POSTGRES_EXPORTER_VERSION="0.15.0"
curl -LO "https://github.com/prometheus-community/postgres_exporter/releases/download/v${POSTGRES_EXPORTER_VERSION}/postgres_exporter-${POSTGRES_EXPORTER_VERSION}.linux-amd64.tar.gz"
tar xf "postgres_exporter-${POSTGRES_EXPORTER_VERSION}.linux-amd64.tar.gz"
cp postgres_exporter /usr/local/bin/
 
# Create monitoring user in PostgreSQL
# Run as postgres user:
psql -c "CREATE USER prometheus_exporter WITH PASSWORD 'strong-random-password';"
psql -c "GRANT pg_monitor TO prometheus_exporter;"
psql -c "GRANT SELECT ON pg_stat_statements TO prometheus_exporter;" 2>/dev/null || true
# /etc/default/postgres_exporter
DATA_SOURCE_NAME="postgresql://prometheus_exporter:strong-random-password@localhost:5432/postgres?sslmode=disable"
PG_EXPORTER_DISABLE_SETTINGS_METRICS="false"
PG_EXPORTER_AUTO_DISCOVER_DATABASES="true"
# /etc/systemd/system/postgres_exporter.service
[Unit]
Description=Prometheus PostgreSQL Exporter
After=network.target
 
[Service]
User=prometheus_exporter
EnvironmentFile=/etc/default/postgres_exporter
ExecStart=/usr/local/bin/postgres_exporter \
    --web.listen-address=:9187 \
    --log.level=info
Restart=on-failure
 
[Install]
WantedBy=multi-user.target

Grafana Configuration and Dashboards

Provisioning: Datasources

Provision data sources through code so they're automatically available on fresh deploys:

# /opt/monitoring/grafana/provisioning/datasources/prometheus.yaml
apiVersion: 1
 
datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true
    version: 1
    editable: false
    jsonData:
      httpMethod: POST
      exemplarTraceIdDestinations: []
      # Enable Prometheus features
      prometheusType: Prometheus
      prometheusVersion: "2.52.0"
      cacheLevel: "Low"
      incrementalQuerying: true
      incrementalQueryOverlapWindow: 10m
      queryTimeout: "60s"

Provisioning: Dashboards

Auto-provision dashboard folders:

# /opt/monitoring/grafana/provisioning/dashboards/default.yaml
apiVersion: 1
 
providers:
  - name: 'Default'
    orgId: 1
    folder: 'Infrastructure'
    folderUid: 'infrastructure'
    type: file
    disableDeletion: false
    updateIntervalSeconds: 30
    allowUiUpdates: true
    options:
      path: /var/lib/grafana/dashboards
      foldersFromFilesStructure: true

The Most Useful Pre-Built Dashboards

Import these from Grafana's dashboard repository by ID (Dashboard > Import > Enter ID):

Dashboard ID What It Shows
Node Exporter Full 1860 Complete Linux host metrics
Docker Container & Host Metrics 179 cAdvisor container metrics
PostgreSQL Database 9628 PostgreSQL query stats, connections
Traefik Official 17346 Request rates, error rates, latencies
Blackbox Exporter 7587 Endpoint availability, SSL cert expiry
Alertmanager 9578 Alert history and current state

Building a Custom Overview Dashboard

Import by ID gets you started fast. For your team's actual day-to-day needs, build a custom overview dashboard. Here's the PromQL for the panels we use most:

# CPU Usage - Gauge or stat panel, one per server
instance:cpu_usage:rate5m{instance="web-01.internal:9100"}
 
# Memory Usage %
instance:memory_usage_percent{instance="web-01.internal:9100"}
 
# Disk Usage % - for all mounts on a host
instance:disk_usage_percent{instance="web-01.internal:9100"}
 
# Network receive rate (bytes/sec)
rate(node_network_receive_bytes_total{instance="web-01.internal:9100", device!~"lo|docker.*"}[5m])
 
# Network transmit rate
rate(node_network_transmit_bytes_total{instance="web-01.internal:9100", device!~"lo|docker.*"}[5m])
 
# HTTP requests per second (from Traefik)
sum(rate(traefik_service_requests_total{code!~"4..|5.."}[5m]))
 
# HTTP error rate %
sum(rate(traefik_service_requests_total{code=~"5.."}[5m])) /
sum(rate(traefik_service_requests_total[5m])) * 100
 
# PostgreSQL active connections
pg_stat_activity_count{datname="appdb", state="active"}
 
# System load (normalized by CPU count)
node_load1 / count by (instance)(node_cpu_seconds_total{mode="idle"})
 
# Time since last scrape per target (shows which targets are going stale)
time() - prometheus_target_scrape_pool_last_scrape_timestamp_seconds

The Queries That Actually Matter

PromQL is Prometheus's query language. It's powerful and somewhat unintuitive until you understand its data model. Here are the patterns you'll use constantly:

# ── RATES AND AVERAGES ───────────────────────────────────────────────
 
# Rate of increase over 5 minutes (for counters that only go up)
rate(http_requests_total[5m])
 
# Per-second average rate from irate (more responsive to spikes, use for alerts)
irate(http_requests_total[5m])
 
# Average value over time range
avg_over_time(node_memory_MemAvailable_bytes[1h])
 
# ── FILTERING ────────────────────────────────────────────────────────
 
# Exact match
node_cpu_seconds_total{mode="idle"}
 
# Regex match (all non-idle CPU modes)
node_cpu_seconds_total{mode!~"idle|iowait"}
 
# Multiple label match
node_filesystem_avail_bytes{instance="web-01:9100", fstype!="tmpfs"}
 
# ── AGGREGATION ──────────────────────────────────────────────────────
 
# Sum across all instances
sum(rate(http_requests_total[5m]))
 
# Sum, grouped by instance
sum by (instance) (rate(http_requests_total[5m]))
 
# Average across all instances
avg(node_memory_MemAvailable_bytes)
 
# Maximum value, grouped by job
max by (job) (up)
 
# Count of running instances per job
count by (job) (up == 1)
 
# ── MATH AND CALCULATIONS ────────────────────────────────────────────
 
# Percentage calculation
(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100
 
# Rate per minute (rate() gives per-second by default)
rate(requests_total[5m]) * 60
 
# Bytes to MB
node_filesystem_avail_bytes / 1024 / 1024
 
# ── PREDICTION ───────────────────────────────────────────────────────
 
# Predict disk usage 24 hours from now using 6 hours of history
predict_linear(node_filesystem_avail_bytes[6h], 24 * 3600)
 
# Days until disk runs out
(
  node_filesystem_avail_bytes{fstype!="tmpfs"}
  / -rate(node_filesystem_avail_bytes{fstype!="tmpfs"}[6h])
) / 86400
 
# ── JOINS AND COMPARISONS ────────────────────────────────────────────
 
# Load average normalized by CPU count (multi-label join)
node_load1 / on(instance) group_left count by (instance)(
  node_cpu_seconds_total{mode="idle"}
)
 
# Alert when memory is low AND CPU is high (correlate conditions)
(
  instance:memory_usage_percent > 80
) and (
  instance:cpu_usage:rate5m > 70
)
 
# ── HISTOGRAM QUERIES ────────────────────────────────────────────────
 
# 95th percentile HTTP request duration
histogram_quantile(0.95,
  rate(http_request_duration_seconds_bucket[5m])
)
 
# 99th percentile grouped by endpoint
histogram_quantile(0.99,
  sum by (le, endpoint) (
    rate(http_request_duration_seconds_bucket[5m])
  )
)

Setting Up the Blackbox Exporter

The Blackbox Exporter probes endpoints from outside -- HTTP checks, SSL certificate validation, TCP connectivity. Install it alongside your monitoring stack:

# Add to docker-compose.yml
  blackbox-exporter:
    image: prom/blackbox-exporter:v0.25.0
    container_name: blackbox-exporter
    restart: unless-stopped
    networks:
      - monitoring
    command:
      - "--config.file=/config/blackbox.yml"
    volumes:
      - /opt/monitoring/blackbox:/config:ro
    ports:
      - "127.0.0.1:9115:9115"
# /opt/monitoring/blackbox/blackbox.yml
modules:
  http_2xx:
    prober: http
    timeout: 10s
    http:
      valid_http_versions: ["HTTP/1.1", "HTTP/2.0"]
      valid_status_codes: []    # Default to 2xx
      method: GET
      follow_redirects: true
      tls_config:
        insecure_skip_verify: false
      preferred_ip_protocol: "ip4"
 
  http_post_2xx:
    prober: http
    timeout: 10s
    http:
      method: POST
      headers:
        Content-Type: application/json
 
  tcp_connect:
    prober: tcp
    timeout: 10s
 
  icmp:
    prober: icmp
    timeout: 5s
    icmp:
      preferred_ip_protocol: "ip4"

Add endpoints to monitor in your Prometheus scrape config (already shown above). Grafana dashboard ID 7587 gives you a complete view of all probed endpoints.

Operational Tasks

Reload Configuration Without Restart

# Reload Prometheus config (requires --web.enable-lifecycle flag)
curl -X POST http://localhost:9090/-/reload
 
# Reload Alertmanager config
curl -X POST http://localhost:9093/-/reload
 
# Grafana picks up provisioned dashboard changes automatically
# For settings changes: docker compose restart grafana

Taking Prometheus Snapshots for Backup

# Create a snapshot (requires --web.enable-admin-api)
curl -X POST http://localhost:9090/api/v1/admin/tsdb/snapshot
 
# Returns: {"status":"success","data":{"name":"20260501T030000Z-abc123"}}
# Snapshot is at /var/lib/prometheus/snapshots/20260501T030000Z-abc123/
 
# Back up the snapshot directory
rsync -av /var/lib/prometheus/snapshots/ backup-server:/backups/prometheus/

Pruning Old Metrics

# Delete metrics for a decommissioned instance
# (requires --web.enable-admin-api)
curl -X POST \
  -g 'http://localhost:9090/api/v1/admin/tsdb/delete_series?match[]={instance="decommissioned-server:9100"}' \
  
# Clean up tombstones after deletion
curl -X POST http://localhost:9090/api/v1/admin/tsdb/clean_tombstones

Checking What's Consuming Storage

# Largest time series by cardinality
topk(10,
  count by (__name__)(
    {__name__!=""}
  )
)
 
# Storage used per job
# Check Prometheus's own TSDB metrics
prometheus_tsdb_head_series
 
# Series count by job
count by (job) ({__name__=~".+"})

Frequently Asked Questions

How much storage does Prometheus need?

A typical production server (single host, all exporters) generates approximately 1-5 million samples per day across all metrics. Prometheus compresses time series to approximately 1-2 bytes per sample. For 5 hosts, all exporters, 90-day retention: roughly 5 hosts × 5M samples/day × 1.5 bytes × 90 days ≈ 3.4GB. Add a safety factor and plan for 20-50GB for a small infrastructure. The --storage.tsdb.retention.size flag in the compose file caps it.

Should I expose Prometheus and Grafana directly with HTTPS?

No. Keep Prometheus on localhost and proxy Grafana through Traefik with authentication, or proxy both through Teleport for zero-trust access control. Prometheus exposes detailed infrastructure information and its admin API can delete data. Grafana has had security vulnerabilities. Neither should be directly internet-accessible.

What's the difference between rate() and irate()?

rate() computes the per-second average rate over the entire time window. irate() computes it using only the last two data points, making it much more responsive to sudden spikes. Use rate() for smooth graphs and recording rules. Use irate() for alerting where you want to catch sudden spikes. Use irate() with a window like [5m] to allow for missing scrapes.

Can Prometheus monitor external services I don't control?

Yes, via the Blackbox Exporter. You can probe any HTTP endpoint, check SSL certificate expiry, and verify TCP connectivity. You won't have internal metrics, but you'll know when things are down and when certificates are about to expire.


Prometheus and Grafana give you the observability layer that makes everything else manageable. The Teleport audit logs we ship to Grafana Loki. The self-hosted services from the productivity stack guide all expose metrics via Node Exporter on their hosts. The infrastructure at CoderOasis has had a monitoring blind spot period of about eighteen months total — before we set this up. We've never had a disk-full incident since. That's a good enough return on one afternoon of setup.