How to Self-Host Grafana: Build Production-Ready Monitoring Dashboards
Most teams collect metrics. Few teams actually look at them. The gap is not tooling—it is a working dashboard that someone trusts. This guide shows you how to self-host Grafana, connect it to real data sources, and build dashboards that survive restarts, redeploys, and 3 AM pages.
If you want a deeper walkthrough on production-grade setup, see our companion post on See Everything: How to Self-Host Grafana for Production Monitoring.
What You Will Build
By the end of this guide you will have:
- Grafana running in Docker with persistent storage
- Prometheus collecting system and application metrics
- Dashboards and data sources provisioned from version-controlled files
- Alerting rules that notify you before users do
- A reverse proxy with HTTPS for secure access
Prerequisites
Before you start, you need:
- A Linux server (Ubuntu 22.04+ or Debian 12+ recommended) with at least 2 CPU cores and 4 GB RAM
- Docker and Docker Compose installed
- A domain name pointed at your server (for HTTPS)
- Basic familiarity with YAML and command-line tools
- Ports 80, 443, and 9090 available (or adjust the configs below)
Check your Docker version:
docker --version
docker compose version
If Docker is missing, install it:
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
newgrp docker
Step 1: Spin Up Grafana with Docker Compose
The cleanest way to self-host Grafana is a single Docker Compose file. It keeps your stack reproducible and your config in Git.
Create a project directory and a docker-compose.yml:
mkdir ~/grafana-stack && cd ~/grafana-stack
mkdir -p provisioning/datasources provisioning/dashboards grafana-data
Now write the Compose file:
version: "3.8"
services:
grafana:
image: grafana/grafana:11.1.0
container_name: grafana
restart: unless-stopped
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=change-me-in-production
- GF_SERVER_ROOT_URL=https://grafana.yourdomain.com
- GF_INSTALL_PLUGINS=grafana-clock-panel,grafana-simple-json-datasource
volumes:
- ./grafana-data:/var/lib/grafana
- ./provisioning:/etc/grafana/provisioning
networks:
- monitoring
prometheus:
image: prom/prometheus:v2.53.0
container_name: prometheus
restart: unless-stopped
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=30d'
- '--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:
prometheus-data:
networks:
monitoring:
driver: bridge
Start the stack:
docker compose up -d
Verify Grafana is running at http://your-server-ip:3000. Log in with admin / change-me-in-production.
Step 2: Configure Prometheus to Scrape Metrics
Grafana does not collect metrics. It visualizes what Prometheus (or another data source) collects. You need a prometheus.yml that tells Prometheus what to scrape.
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']
- job_name: 'grafana'
static_configs:
- targets: ['grafana:3000']
Reload Prometheus without restarting:
curl -X POST http://localhost:9090/-/reload
Check the Prometheus targets page at http://your-server-ip:9090/targets. All jobs should show UP.
Step 3: Provision Data Sources as Code
Manual data source configuration dies the moment you rebuild the container. Provision it from YAML instead.
Create provisioning/datasources/prometheus.yaml:
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
jsonData:
httpMethod: POST
manageAlerts: true
alertmanagerUid: alertmanager
Restart Grafana to pick up the provisioned data source:
docker compose restart grafana
Navigate to Connections → Data Sources in Grafana. You should see Prometheus listed and marked as default.
For a more detailed look at provisioning patterns, read How to Self-Host Grafana: The Complete Developer Guide to Monitoring Dashboards.
Step 4: Build Your First Dashboard
Now the fun part. Create a dashboard that shows what matters: CPU, memory, disk, and network.
Import the Node Exporter Full Dashboard
Grafana maintains an official dashboard for Node Exporter. Import it by ID:
- Go to Dashboards → New → Import
- Enter dashboard ID
1860 - Select your Prometheus data source
- Click Import
You now have a 20+ panel dashboard showing every system metric you need. It updates automatically and works out of the box.
Create a Custom Dashboard from Scratch
If you prefer to build your own, start with a single panel. Here is a PromQL query for CPU usage:
100 - (avg by (instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
Add it to a new dashboard, set the panel title to CPU Usage, and set the unit to percent (0-100). Repeat for memory:
100 * (1 - ((node_memory_MemAvailable_bytes or node_memory_MemFree_bytes) / node_memory_MemTotal_bytes))
Save the dashboard. Then export it as JSON so you can version-control it:
- Open the dashboard
- Click the gear icon (Dashboard settings)
- Choose JSON Model
- Copy and save to
provisioning/dashboards/system-overview.json
Step 5: Provision Dashboards from Git
Manual exports are fine once. For a team, you want dashboards loaded automatically on every startup.
Create provisioning/dashboards/default.yaml:
apiVersion: 1
providers:
- name: 'default'
orgId: 1
folder: 'Infrastructure'
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /etc/grafana/provisioning/dashboards
Place your exported dashboard JSON in the same provisioning/dashboards/ directory. Grafana watches this path and reloads dashboards every 10 seconds. Commit the entire provisioning/ folder to Git and your dashboards are now infrastructure-as-code.
Another angle on this workflow is covered in How to Self-Host Grafana: The Complete Developer Guide to Monitoring Dashboards.
Step 6: Secure Grafana with HTTPS and a Reverse Proxy
Running Grafana on port 3000 over HTTP is fine for local testing. In production, you need TLS and a reverse proxy.
Option A: Caddy (Simplest)
Caddy handles HTTPS automatically via Let's Encrypt. Add it to your Compose file:
caddy:
image: caddy:2.8
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data
- caddy-config:/config
networks:
- monitoring
volumes:
caddy-data:
caddy-config:
Create Caddyfile:
grafana.yourdomain.com {
reverse_proxy grafana:3000
}
Option B: Nginx
If you already run Nginx, add a server block:
server {
listen 443 ssl http2;
server_name grafana.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/grafana.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/grafana.yourdomain.com/privkey.pem;
location / {
proxy_pass http://localhost:3000;
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;
}
}
Update GF_SERVER_ROOT_URL in your Compose file to match your domain, then restart:
docker compose up -d
Tips and Troubleshooting
Grafana shows "No Data"
- Check that Prometheus is scraping targets at
http://your-server:9090/targets - Verify the data source URL in Grafana matches the Docker service name (
http://prometheus:9090inside the container network) - Confirm your PromQL query works in the Prometheus expression browser first
Dashboards disappear after restart
- Ensure you mounted a host volume to
/var/lib/grafana - Without a volume, Grafana data lives inside the container and dies with it
Permission denied on volumes
- Grafana runs as UID 472. Fix ownership with:
sudo chown -R 472:472 grafana-data
Alerts not firing
- Alert rules evaluate on the Grafana side, not Prometheus. Go to Alerting → Alert rules and check the state
- Ensure your contact point (email, Slack, PagerDuty) is configured in Alerting → Contact points
High memory usage
- Limit Prometheus retention with
--storage.tsdb.retention.time=15dif disk or RAM is tight - Add resource limits to your Compose services for production workloads
Next Steps
You now have a self-hosted Grafana stack that collects metrics, displays them in useful dashboards, and loads its own configuration from Git. That is the foundation. From here, consider:
- Adding Loki for log aggregation and correlating logs with metrics
- Setting up Alertmanager for routing alerts to the right on-call engineer
- Using Grafana's Git sync or Terraform provider for larger teams
- Adding custom application metrics via the Prometheus client libraries
Monitoring is not a one-time setup. It is a habit. Start with one dashboard that answers one real question—like "is the API slow right now?"—and expand from there.
Need Help Scaling This?
If you are running production workloads and need HA, SSO, fine-grained access control, or custom alerting pipelines, we can help. Contact the Sysbrix team and we will get your observability stack running like it should.