Skip to Content

How to Self-Host Grafana: The Complete Developer Guide to Monitoring Dashboards

Build production-ready monitoring dashboards with Grafana, Prometheus, and Docker Compose — from zero to fully observable in one guide.

How to Self-Host Grafana: The Complete Developer Guide to Monitoring Dashboards

You don't need a managed SaaS to get serious about observability. Grafana is open source, runs beautifully in Docker, and gives you production-grade dashboards without the per-seat pricing. This guide walks you through exactly how to self-host Grafana, wire it up to Prometheus, provision dashboards as code, and build alerts that actually wake you up when things break.

If you want a deeper dive into production architecture decisions, check out See Everything: How to Self-Host Grafana for Production Monitoring. For Docker Compose plus systemd deployment specifics, see How to Deploy Grafana in Production with Docker Compose + systemd.

What You'll Need Before You Start

Don't skip this. Running Grafana is easy. Running it well means having the basics in place first.

  • Linux server with Docker and Docker Compose installed (Ubuntu 22.04+ or Debian 12+ recommended)
  • 2 CPU cores, 4GB RAM minimum for a small stack (Grafana + Prometheus + Node Exporter)
  • 20GB disk for metrics retention at default 15-day scrape intervals
  • A domain or subdomain pointed at your server (optional but strongly recommended for TLS)
  • Basic familiarity with YAML, Docker, and Prometheus concepts

If you're planning to add log aggregation with Loki, our Production Guide: Deploy Grafana Loki + Promtail with Docker Compose + Traefik + Let's Encrypt on Ubuntu covers that stack end-to-end.

1. Spin Up Grafana with Docker Compose

The fastest way to self-host Grafana is a single docker-compose.yml. No package managers. No systemd units yet. Just get it running.

Create a project directory and drop this in:

version: "3.8"

services:
  grafana:
    image: grafana/grafana:11.0.0
    container_name: grafana
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=changeme
      - GF_SERVER_ROOT_URL=https://grafana.yourdomain.com
      - GF_INSTALL_PLUGINS=grafana-clock-panel,grafana-piechart-panel
    volumes:
      - grafana-storage:/var/lib/grafana
      - ./provisioning:/etc/grafana/provisioning

volumes:
  grafana-storage:

Then run it:

docker compose up -d

Hit http://your-server-ip:3000 and log in with admin / changeme. You now have Grafana running. But it's not monitoring anything yet.

Why pin the image version?

Always pin your Grafana image tag. grafana/grafana:latest will bite you during an unexpected upgrade. Pin to a specific major.minor like 11.0.0, test upgrades in staging, and bump deliberately.

2. Add Prometheus as Your Metrics Source

Grafana without data is just a pretty empty frame. Prometheus is the standard pairing. Here's a minimal docker-compose.yml that adds Prometheus and Node Exporter for host metrics:

version: "3.8"

services:
  grafana:
    image: grafana/grafana:11.0.0
    container_name: grafana
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=changeme
    volumes:
      - grafana-storage:/var/lib/grafana
      - ./provisioning:/etc/grafana/provisioning
    networks:
      - monitoring

  prometheus:
    image: prom/prometheus:v2.52.0
    container_name: prometheus
    restart: unless-stopped
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus-data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
      - '--storage.tsdb.retention.time=15d'
      - '--web.enable-lifecycle'
    networks:
      - monitoring

  node-exporter:
    image: prom/node-exporter:v1.8.0
    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)($$|/)'
    networks:
      - monitoring

volumes:
  grafana-storage:
  prometheus-data:

networks:
  monitoring:
    driver: bridge

And the prometheus.yml config:

global:
  scrape_interval: 15s
  evaluation_interval: 15s

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

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

Run docker compose up -d again. Prometheus now scrapes itself and your host every 15 seconds. Grafana can query it.

3. Provision Data Sources and Dashboards as Code

Clicking through the UI to add data sources is fine once. Doing it on every deploy is not. Grafana's provisioning system lets you define data sources and dashboards in YAML and JSON files that load on startup.

Create this directory structure:

provisioning/
├── dashboards/
│   ├── dashboards.yml
│   └── node-exporter.json
└── datasources/
    └── datasources.yml

provisioning/datasources/datasources.yml:

apiVersion: 1

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

provisioning/dashboards/dashboards.yml:

apiVersion: 1

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

For the dashboard JSON, grab the official Node Exporter Full dashboard from Grafana.com, save it as node-exporter.json in provisioning/dashboards/, and restart:

docker compose restart grafana

Your dashboard appears automatically. No clicks required. This is how you self-host Grafana like a professional — everything in Git, everything reproducible.

4. Build Your First Custom Dashboard

Provisioning gets you started. But you'll need custom dashboards for your applications. Here's the workflow:

  1. Explore your metrics in Prometheus at :9090 first. Use the expression browser to test queries.
  2. Create a panel in Grafana with that query. Tweak the visualization type, units, and thresholds.
  3. Export the dashboard JSON (Share → Export → Save to file) and commit it to provisioning/dashboards/.

Example query for CPU usage:

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

Example query for memory usage:

100 * (1 - ((node_memory_MemAvailable_bytes or node_memory_MemFree_bytes) / node_memory_MemTotal_bytes))

Pro tip: use Grafana variables for things like $instance and $job so one dashboard works across all your servers.

5. Set Up Alerting That Actually Works

Grafana 11 ships with unified alerting built in. No more separate Alertmanager setup unless you want it. Here's how to configure a simple high-CPU alert via provisioning:

Create provisioning/alerting/alerting.yml:

apiVersion: 1

contactPoints:
  - orgId: 1
    name: 'email-alerts'
    receivers:
      - uid: 'email'
        type: email
        settings:
          addresses: [email protected]
          singleEmail: false

policies:
  - orgId: 1
    receiver: 'email-alerts'
    group_by: ['alertname', 'grafana_folder', 'uid']

groups:
  - orgId: 1
    name: 'infrastructure-alerts'
    folder: 'Infrastructure'
    interval: 60s
    rules:
      - uid: 'high-cpu'
        title: 'High CPU Usage'
        condition: 'B'
        data:
          - refId: 'A'
            relativeTimeRange:
              from: 300
              to: 0
            datasourceUid: 'prometheus'
            model:
              expr: |
                100 - (avg by (instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80
              refId: 'A'
          - refId: 'B'
            relativeTimeRange:
              from: 0
              to: 0
            datasourceUid: '__expr__'
            model:
              type: threshold
              expression: 'A'
              conditions:
                - evaluator:
                    type: gt
                    params:
                      - 80

Mount the alerting folder in your compose file:

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

Restart Grafana and your alert is live. When CPU stays above 80% for five minutes, you get an email. Adjust thresholds to your environment — 80% might be normal for batch workloads.

6. Secure Your Grafana Instance

Running Grafana on port 3000 with a default password is not acceptable for production. Lock it down.

Change the admin password immediately

Use environment variables, not hardcoded defaults:

environment:
  - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin}
  - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}

Store the actual password in a .env file that never gets committed.

Enable HTTPS

Put Traefik, Nginx, or Caddy in front of Grafana. Here's a minimal Traefik snippet:

labels:
  - "traefik.enable=true"
  - "traefik.http.routers.grafana.rule=Host(`grafana.yourdomain.com`)"
  - "traefik.http.routers.grafana.entrypoints=websecure"
  - "traefik.http.routers.grafana.tls.certresolver=letsencrypt"

For a full Traefik + Let's Encrypt + Loki stack, our Production Guide: Deploy Grafana Loki + Promtail with Docker Compose + Traefik + Let's Encrypt on Ubuntu has you covered.

Disable signup and anonymous access

environment:
  - GF_AUTH_ANONYMOUS_ENABLED=false
  - GF_AUTH_DISABLE_SIGNOUT_MENU=false
  - GF_AUTH_DISABLE_LOGIN_FORM=false

7. Tips, Gotchas, and Troubleshooting

Here is what breaks when you self-host Grafana — and how to fix it fast.

Dashboards not showing after provisioning

Check that your dashboard provider YAML points to the right path. The path is inside the container, not your host. If you mount ./provisioning/dashboards to /etc/grafana/provisioning/dashboards, your provider path should be /etc/grafana/provisioning/dashboards.

Permission denied on bind mounts

Grafana runs as user ID 472 inside the container. If you use bind mounts, the host directory needs to be writable by that UID:

sudo chown -R 472:472 ./data

Or run Grafana as your host user by setting user: "1000:1000" in Docker Compose.

Alerts not firing

Verify your alert query actually returns data in Explore. Then check the alert state in Alerting → Alert rules. A common mistake is setting the threshold condition reference (refId: 'B') but forgetting to define the expression query with __expr__ as the datasource.

High memory usage

Grafana's default SQLite database is fine for small setups. For teams or heavy dashboard usage, switch to PostgreSQL or MySQL:

environment:
  - GF_DATABASE_TYPE=postgres
  - GF_DATABASE_HOST=postgres:5432
  - GF_DATABASE_NAME=grafana
  - GF_DATABASE_USER=grafana
  - GF_DATABASE_PASSWORD=${DB_PASSWORD}

Back up your dashboards

Since you're provisioning dashboards as JSON files, your Git repo is your backup. For the Grafana database (users, permissions, annotations), snapshot the volume regularly:

docker run --rm -v grafana-storage:/data -v $(pwd):/backup alpine tar czf /backup/grafana-backup-$(date +%F).tar.gz -C /data .

Closing Thoughts

Self-hosting Grafana gives you full control over your monitoring stack, your data, and your costs. With Docker Compose, provisioning as code, and a solid alerting setup, you have an observability platform that rivals anything SaaS — without the recurring bill.

Start with the basics: Grafana + Prometheus + Node Exporter. Provision your data sources and dashboards in YAML. Add alerts that matter. Lock it down with HTTPS and proper auth. Then iterate.

If you're running this in production and need help with scaling, custom integrations, or hardening — get in touch with our team. We build and maintain monitoring stacks for teams that can't afford blind spots.

Portainer Docker Setup: A Developer's Guide to Visual Container Management
Deploy Portainer, connect your hosts, and manage Docker containers through a clean web UI—no CLI memorization required.