A complete, practical path from "I've never run Prometheus" to "I can instrument apps, write PromQL, and run alerting in production." Every step runs locally with Docker — no cloud needed. Work through it top to bottom, or jump to the part you need.
The journey:
rate() and quantiles| You need | Why |
|---|---|
| Docker installed | Every component here runs as a local container |
| A terminal | All commands are CLI |
| Basic HTTP/curl familiarity | Targets expose metrics over HTTP |
No cloud account is required. A little YAML comfort helps, since config and rules are YAML. Confirm Docker works: docker run --rm hello-world.
Prometheus is an open-source monitoring and alerting system built around a time-series database (TSDB). Its defining design choices:
http_requests_total{method="GET", status="500"} is a distinct series you can slice and aggregate.What it is not: it is not a logging system (use Loki/ELK), not a distributed tracing system (use Tempo/Jaeger), and not built for high-cardinality event data or exact billing. It excels at numeric, time-series metrics for alerting and dashboards.
┌────────────┐ scrape (pull) ┌──────────────┐
│ targets │◀─────────────────│ Prometheus │──▶ PromQL / API / Grafana
│ /metrics │ │ TSDB │──▶ alerting rules
└────────────┘ └──────┬───────┘
▼
┌──────────────┐
│ Alertmanager │──▶ Slack / PagerDuty / email
└──────────────┘
Create a minimal config, then start Prometheus with Docker.
# prometheus.yml
global:
scrape_interval: 15s # how often to scrape targets
evaluation_interval: 15s # how often to evaluate rules
scrape_configs:
- job_name: 'prometheus' # Prometheus scraping itself
static_configs:
- targets: ['localhost:9090']
docker run --rm -p 9090:9090 \
-v "$(pwd)/prometheus.yml:/etc/prometheus/prometheus.yml" \
prom/prometheus
Open http://localhost:9090:
prometheus job UP.up and execute. up == 1 means a target is reachable; up == 0 means it's down. This single metric is the foundation of "is my thing alive?" alerting.You now have a working Prometheus scraping itself. Everything else is adding targets, queries, and rules.
A sample is a (timestamp, float64 value). A time series is a unique combination of a metric name and labels:
http_requests_total{job="api", method="POST", status="200"} → 1027
Adding a label value creates a new series. That's powerful — and the source of the #1 production pitfall, cardinality (Part 10).
| Type | Meaning | Example | Query with |
|---|---|---|---|
| Counter | Only goes up (resets to 0 on restart) | http_requests_total, errors_total |
rate() / increase() |
| Gauge | Goes up and down | memory_bytes, queue_depth, temperature |
value directly, avg, max |
| Histogram | Buckets of observations + sum + count | http_request_duration_seconds |
histogram_quantile(), rate() |
| Summary | Client-side quantiles + sum + count | rpc_duration_seconds |
quantile labels directly |
Rules of thumb:
rate() to get per-second change.prometheus.yml has three sections you'll use constantly: global, scrape_configs, and rule_files.
global:
scrape_interval: 15s
external_labels:
cluster: dev # attached to all series when federating / remote-writing
rule_files:
- "rules/*.yml" # recording + alerting rules
scrape_configs:
- job_name: 'node'
static_configs:
- targets: ['node-exporter:9100']
labels:
env: dev
- job_name: 'api'
metrics_path: /metrics
scrape_interval: 10s # override global per-job
static_configs:
- targets: ['api:8080']
Static targets are fine for a demo. In real systems you use service discovery so Prometheus finds targets automatically as they come and go:
kubernetes_sd_configs) — discover pods/services/endpoints by labels/annotations.file_sd_configs) — a JSON/YAML file another tool writes.relabel_configs then filter and rewrite discovered targets (keep only annotated pods, set the instance label, map ports). Relabeling is the part everyone finds confusing at first — think of it as a pipeline that transforms the label set of each target before scraping.
# Validate config before reloading
promtool check config prometheus.yml
# Hot-reload without restart (if --web.enable-lifecycle is set)
curl -X POST http://localhost:9090/-/reload
Exporters translate third-party systems into metrics (e.g., node_exporter for host metrics, blackbox_exporter for probing endpoints). For your code, use a client library.
docker run --rm -p 9100:9100 prom/node-exporter
# then add a scrape job for node-exporter:9100 and query, e.g.:
# 100 - (avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) # CPU %
The pattern is identical across languages (Go, Java, Node, etc.): create metrics, update them in your handlers, and expose /metrics.
from prometheus_client import Counter, Histogram, start_http_server
import time, random
REQUESTS = Counter("app_requests_total", "Total requests", ["method", "status"])
LATENCY = Histogram("app_request_duration_seconds", "Request latency", ["method"])
def handle(method):
with LATENCY.labels(method).time(): # observes duration into buckets
time.sleep(random.random() / 5)
status = "500" if random.random() < 0.1 else "200"
REQUESTS.labels(method, status).inc() # increment the counter
if __name__ == "__main__":
start_http_server(8000) # exposes /metrics on :8000
while True:
handle(random.choice(["GET", "POST"]))
time.sleep(0.2)
Scrape localhost:8000, then query app_requests_total — you'll see one series per (method, status) pair.
<namespace>_<name>_<unit> with a base unit (seconds, bytes) — e.g. app_request_duration_seconds. Counters end in _total.PromQL is where Prometheus becomes powerful. Build it up in layers.
up # all up series
app_requests_total{status="500"} # only 500s
app_requests_total{status=~"5.."} # regex: any 5xx
app_requests_total{status!="200"} # negation
rate(app_requests_total[5m]) # per-second rate over a 5-minute window
Use rate() for counters — it's resilient to counter resets. Use increase() for "how many over this window." Pick a range ([5m]) at least 4× your scrape interval.
sum(rate(app_requests_total[5m])) # total RPS across everything
sum by (status) (rate(app_requests_total[5m])) # RPS grouped by status
sum without (instance) (rate(app_requests_total[5m])) # collapse the instance label
topk(5, sum by (route)(rate(app_requests_total[5m]))) # busiest 5 routes
sum, avg, max, min, count all take by (...) / without (...).
sum(rate(app_requests_total{status=~"5.."}[5m]))
/
sum(rate(app_requests_total[5m])) # fraction of requests failing
histogram_quantile(
0.95,
sum by (le) (rate(app_request_duration_seconds_bucket[5m]))
) # p95 latency in seconds
The le ("less than or equal") label holds the bucket boundaries — you must keep le in the by (...) clause for histogram_quantile to work.
rate() a gauge, and don't graph a raw counter.[5m] with 15s scrape is safe; [30s] is too short).Recording rules evaluate a query on a schedule and save the result as a new series. Use them for dashboards and alerts that would otherwise recompute a heavy expression every time.
# rules/recording.yml
groups:
- name: api-slo
interval: 30s
rules:
- record: job:app_requests:rate5m
expr: sum by (job) (rate(app_requests_total[5m]))
- record: job:app_errors:ratio5m
expr: |
sum by (job) (rate(app_requests_total{status=~"5.."}[5m]))
/
sum by (job) (rate(app_requests_total[5m]))
Now dashboards query the cheap job:app_errors:ratio5m instead of the full expression. Convention: name recorded series level:metric:operation.
Prometheus evaluates alert rules and fires alerts; Alertmanager dedupes, groups, silences, and routes them to receivers (Slack, PagerDuty, email).
# rules/alerts.yml
groups:
- name: api-alerts
rules:
- alert: HighErrorRate
expr: job:app_errors:ratio5m > 0.05 # >5% errors
for: 10m # sustained for 10 min (avoids flapping)
labels:
severity: page
annotations:
summary: "High error rate on {{ $labels.job }}"
description: "Error ratio is {{ $value | humanizePercentage }} (>5%) for 10m."
- alert: TargetDown
expr: up == 0
for: 5m
labels: { severity: page }
annotations:
summary: "Target {{ $labels.instance }} is down"
The for clause is what separates a blip from an incident — the condition must hold continuously before the alert fires.
# alertmanager.yml
route:
receiver: slack-default
group_by: ['alertname', 'job'] # collapse related alerts into one notification
group_wait: 30s
repeat_interval: 4h
routes:
- matchers: [ severity="page" ]
receiver: pagerduty
receivers:
- name: slack-default
slack_configs:
- channel: '#alerts'
api_url: '<webhook-url>'
- name: pagerduty
pagerduty_configs:
- service_key: '<key>'
Good alerts are symptom-based (users are seeing errors) not cause-based (CPU is 90%), have a sensible for:, and always link to a runbook in the annotation.
Prometheus's built-in graph is for exploration; Grafana is for dashboards.
docker run --rm -p 3000:3000 grafana/grafana
http://host.docker.internal:9090.Design dashboards around the RED/USE methods and use the recording rules from Part 7 so panels stay fast.
This is what separates "it works on my laptop" from "hero."
Each unique label-set is a series held in memory. A label with unbounded values (user ID, email, request URL, session ID) can explode series count and OOM Prometheus.
# Find your worst offenders
topk(10, count by (__name__)({__name__=~".+"})) # biggest metrics by series count
count(app_requests_total) # series for one metric
Rules: keep labels bounded and low-cardinality; never label by ID/URL/timestamp; drop noisy labels/metrics with metric_relabel_configs.
--storage.tsdb.retention.time=15d (default 15 days)./federate) — useful for global rollups.up == 0 and on Prometheus itself (scrape duration, TSDB head series, WAL).promtool check rules / promtool check config.rate()/increase().rate(...[30s]) with a 15s scrape has too few samples. Use at least 4× the interval ([1m]+), or $__rate_interval.histogram_quantile.le from a histogram query. histogram_quantile needs the le label — keep it in your by (...).for: on alerts. Every transient blip pages someone. Add a for: so the condition must hold before firing.UP under Status → Targets. Query app_requests_total and count the series.app_requests_total / app_request_duration_seconds_bucket: request rate, 5xx error ratio, and p95 latency.for: 10m. Reload with promtool check rules first.topk(10, count by (__name__)({__name__=~".+"})) and identify your highest-series metric. Would any of its labels be dangerous if unbounded?Self-check:
rate() vs increase() vs the raw value?You now have the full loop: expose metrics → scrape → query with PromQL → record → alert → visualize → harden. That's zero to hero.
Learned the concepts? Test yourself with Prometheus interview questions.
Go to the question bank →