How To Set Up Server Monitoring With Prometheus and Grafana Using Docker Compose
Tricknowtech Team 10 min read
Goal
In this tutorial, you will deploy Prometheus, node_exporter, and Grafana as a Docker Compose stack on an Ubuntu server, configure Prometheus to scrape the host's own CPU, memory, disk, and network metrics, and build a live Grafana dashboard from a pre-built community template — while keeping Prometheus and node_exporter off the public internet entirely and Grafana behind minimal, deliberate exposure.
Prerequisites
An Ubuntu 22.04 or 24.04 server with a non-root sudo user
Docker Engine and the Docker Compose plugin already installed
ufw enabled, with SSH access already allowed
SSH key or password access to the server from your workstation
Optional: a registered domain and a completed Nginx + Certbot reverse-proxy setup, for reaching Grafana without an SSH tunnel
Let an AI agent do this for you
Copy a ready-made prompt for an AI coding assistant with terminal access to your server (Claude Code, Cursor, or similar) — it can carry out the steps below for you. Review what it plans to run before it executes anything.
Prometheus, node_exporter, and Grafana are three separate tools that are commonly run together but each do one job. This tutorial deploys all three as a small Docker Compose stack on a single Ubuntu server, wires them together, and locks down network access so only the dashboard is reachable from outside.
Step 1 — How the three pieces fit together
Before writing any config, it helps to know what each component actually does, since the setup steps only make sense in light of this:
Prometheus is a time-series database. On a fixed interval, it scrapes (pulls, over plain HTTP) metrics from a list of configured targets and stores the results so you can query them later.
node_exporter is a small HTTP server that reads a machine's own CPU, memory, disk, and network statistics out of the kernel (/proc, /sys) and exposes them in the plain-text format Prometheus expects. It doesn't store history or render anything — it's a translator between the OS and Prometheus's scrape format.
Grafana is a separate visualization layer. It has no metrics of its own; it queries a data source — here, Prometheus — and turns the results into dashboards.
Each of these normally runs as its own systemd service or container. This tutorial runs all three as containers under one Docker Compose file, on a server that already has Docker Engine and the Compose plugin installed.
Step 2 — Create the project directory and Compose file
Create a directory to hold the stack's configuration and start files:
bash
sudo mkdir -p /opt/monitoring
cd /opt/monitoring
Create docker-compose.yml with the following contents:
node_exporter normally runs directly on the host so it can read the real machine's process table and filesystems. Running it in a container instead requires two concessions: pid: host lets it see the host's actual processes instead of just its own container's, and mounting the host's root filesystem read-only at /host (with the rslave propagation flag, so mounts added on the host later are visible too) lets it report stats for the real host disks rather than the container's own overlay filesystem. The --path.rootfs=/host flag tells it where that mount lives. node-exporter has no ports: entry at all — only Prometheus needs to reach it, and it does so over the Compose network's internal DNS, so no host port needs to be published, not even to loopback. restart: unless-stopped on all three services tells Docker to bring them back up automatically after a host reboot or a crash, without also restarting containers you stopped on purpose.
“You'll see this stack documented elsewhere with ports like "9090:9090" and "3000:3000" — that binds to all interfaces (0.0.0.0) and makes the port reachable from the public internet the instant the container starts. Binding to 127.0.0.1 instead keeps Prometheus and Grafana reachable only from the server itself; you'll reach them from outside via an SSH tunnel during setup, and later via a reverse proxy for Grafana only. This tutorial applies that restriction from the very first docker compose up rather than exposing everything now and closing it off in Step 6 — there's no reason to let a service sit open to the internet even briefly while you finish the rest of the setup.”
Step 3 — Configure Prometheus's scrape targets
Create prometheus.yml in the same directory, alongside docker-compose.yml:
The prometheus job scrapes Prometheus's own metrics endpoint (useful for monitoring Prometheus itself). The node job scrapes node-exporter:9100 — the Compose service name, not localhost. Docker Compose runs an embedded DNS resolver on the network it creates for the stack, so containers can reach each other by service name; localhost from inside the Prometheus container would mean the Prometheus container itself, which has nothing listening on port 9100.
Step 4 — Start the stack
bash
docker compose up -d
docker compose ps
docker compose ps should list all three services (prometheus, node-exporter, grafana) with a State of Up. If one of them isn't, jump ahead to the troubleshooting note at the end.
Step 5 — Verify Prometheus is scraping both targets
Because port 9090 is bound to the server's loopback interface only, open an SSH tunnel from your workstation instead of browsing to the server's public IP directly:
Leave that terminal open and visit http://localhost:9090/targets in your local browser. Both the prometheus and node jobs should show State: UP. If node shows DOWN, see the troubleshooting note below before continuing.
Step 6 — Keep Prometheus and Grafana off the public internet
Check your firewall's current rules — SSH should already be allowed if you followed standard server-hardening steps:
bash
sudo ufw status
“Do not rely on a ufw deny rule alone to block a Docker-published port. When Docker publishes a container port with something like "9090:9090", it inserts its own iptables rules ahead of ufw's, and those rules can leave the port reachable from outside even while ufw status shows it denied. Binding to 127.0.0.1 — as this Compose file already does — avoids the problem entirely, because the port is never opened on the public interface in the first place.”
Prometheus (9090) and node-exporter (9100, unpublished) don't need to be reachable from anywhere but the server itself — Grafana talks to Prometheus over the internal Compose network at http://prometheus:9090. Grafana's UI (3000) is the only piece that needs to be usable from outside, and it shouldn't be exposed raw. Put it behind an Nginx reverse proxy with TLS on a subdomain (see the earlier Nginx + Certbot tutorial), proxying to the loopback address:
The Upgrade/Connection headers matter here because some Grafana features — Grafana Live streaming panels and live log tailing — use a WebSocket connection; ordinary dashboard panels just poll over HTTP on a timer and don't need one. Include the headers anyway: they match Grafana's own reverse-proxy documentation, cost nothing when unused, and mean you won't have to revisit this config the first time you turn on a feature that does need them.
Once the proxy and a certificate for something like grafana.example.com are in place, allow only web traffic through the firewall — never open 3000 itself:
bash
sudo ufw allow 'Nginx Full'
Step 7 — Add Prometheus as a Grafana data source
Until the reverse proxy is in place, reach Grafana the same way you reached Prometheus — tunnel both ports at once if you like:
Visit http://localhost:3000 and log in with the default admin / admin.
“Grafana forces a password change on first login by default. If for any reason you're not prompted, change it immediately under your profile settings. Treat that admin password exactly like an API key or database credential — write it down somewhere secret, never in a file with open permissions, and never reuse it elsewhere.”
In the left menu, go to Connections → Data sources → Add data source → Prometheus. Set the URL to http://prometheus:9090 — not localhost. Grafana is running in its own container; localhost from inside that container refers to the Grafana container itself, which has no Prometheus process on it. prometheus is the Compose service name, resolved by Docker's internal DNS to the Prometheus container's address on the shared network. Click Save & test — you should see a confirmation that Grafana successfully queried the Prometheus API.
Step 8 — Import a pre-built dashboard for node_exporter
Rather than building panels from scratch, import a community dashboard built for node_exporter's metric names. In Grafana, go to Dashboards → New → Import. In a separate tab, search grafana.com/dashboards for "Node Exporter Full" and note the dashboard ID shown on its page — look this up rather than trusting a hardcoded ID from an older tutorial, since IDs and dashboard availability change over time. Paste that ID into the "Import via grafana.com" field on the Import screen, click Load, select your Prometheus data source when prompted, and click Import.
Step 9 — Verify you're seeing live data
The imported dashboard should populate with CPU, memory, disk, and network graphs for the host within a minute or two — this confirms the full chain of node_exporter → Prometheus → Grafana is working end to end. If a panel briefly shows "No data," wait through one or two scrape intervals (30 seconds) before assuming something's wrong.
Troubleshooting: a target shows State: DOWN
This almost always means one of two things: the exporter container isn't running, or Prometheus is pointed at the wrong address.
Confirm docker compose ps shows node-exporter as Up — if it exited, the logs will usually show why. Then double-check prometheus.yml targets node-exporter:9100, the Compose service name, not localhost:9100 — from inside the Prometheus container, localhost only ever refers to Prometheus's own container. After fixing prometheus.yml, apply the change with: