॥ श्री ॥

Monitoring & Observability

Self-Hosting & Servers 2026-04-22

Portability note: Compose examples use rootless Podman and host.containers.internal (the host gateway from a container). When using Docker, replace podman-compose with docker compose and host.containers.internal with host-gateway (add extra_hosts: [host-gateway:host-gateway] to the service). All concepts, architecture patterns, and CLI commands are container-runtime-agnostic.

Monitoring & Observability

System metrics, log aggregation, alerting, uptime tracking, container visibility, and network performance monitoring. All run rootless with bind-mount volumes labelled :Z. Named volumes omit :Z — Podman manages their labels automatically.

For multi-node, replicated, and HA deployments (Elasticsearch cluster, OpenSearch cluster, VictoriaMetrics cluster) see the Clusters wiki.

---

Key Concepts

The three pillars (now four) of observability

  • Metrics — numeric time-series data (CPU %, request rate, error count). Cheap to store, fast to query. Prometheus + Grafana.
  • Logs — structured or unstructured event records. Expensive at scale. Loki (label-indexed streams) or Elasticsearch (fully indexed).
  • Traces — records of a request as it flows through multiple services. Shows where latency is introduced. Tempo + OpenTelemetry.
  • Profiles (emerging) — continuous CPU/memory profiling of running processes. Parca + eBPF. The "fourth pillar."

Pull vs push model for metrics

Prometheus uses a pull model — it scrapes /metrics endpoints on a schedule. This means you know what's being scraped and can see scrape errors in Prometheus itself. Push model (Pushgateway, InfluxDB's Telegraf) is used for short-lived jobs. Most interviewers will ask why Prometheus scrapes rather than having apps push to it: better target discovery, single place to detect unreachable targets, less firewall complexity.

Cardinality and why it matters

Cardinality is the number of unique label combinations for a metric. http_requests_total{method="GET", path="/api/users/123"} where path contains user IDs creates millions of unique series. High cardinality is the number-one cause of Prometheus memory exhaustion. Always bound labels to a small, known set of values (path="/api/users/:id" not the literal ID).

Rate vs irate vs increase

  • rate(metric[5m]) — per-second average rate over the last 5 minutes. Smooth, good for dashboards.
  • irate(metric[5m]) — instantaneous rate based on last two data points. Spiky, good for detecting brief bursts.
  • increase(metric[5m]) — total increase over the window (rate × duration). Good for "how many errors in the last 5 min".

Alert fatigue

The condition where too many noisy/low-priority alerts cause on-call engineers to start ignoring pages. Symptoms: alerts that resolve themselves, alerts that require no action, duplicate alerts for the same underlying cause. Fix: alert only on symptoms (high error rate) not causes (CPU high), use inhibition rules, tune for: duration so brief spikes don't fire, use Karma dashboard for triage.

Multiwindow, multi-burn-rate alerts

For SLO alerts, a single threshold alert (error rate > 1%) fires too often for minor blips and too slowly for catastrophic failures. The recommended pattern from the Google SRE book uses two windows: a fast window (e.g., 1h) catches rapid burns, a slow window (e.g., 6h) catches slow burns. Pyrra generates these automatically from SLO definitions.

OpenTelemetry SDK instrumentation

Auto-instrumentation (via agents) adds traces and metrics to your app with zero code changes for common frameworks (Django, Express, Spring). Manual instrumentation wraps specific code sections in spans. The SDK exports to an OTel Collector, which fans out to Tempo (traces), Prometheus (metrics), and Loki (logs) — one SDK call, multiple backends.

Log levels and when to use them

  • DEBUG — verbose, only in dev; never leave on in production
  • INFO — normal operation events ("started", "processed 100 items")
  • WARN — recoverable unexpected condition that deserves attention
  • ERROR — operation failed; action required
  • FATAL/CRITICAL — service cannot continue; immediate page

Structured logging (JSON) vs unstructured

Structured logs ({"level":"error","msg":"DB timeout","user_id":42,"latency_ms":5000}) are parseable by Loki, Graylog, and other tools without Grok patterns. Unstructured logs (ERROR: DB timeout for user 42 after 5000ms) require regex extraction, which is brittle. Always use structured logging in production services.

Understanding Grafana variables and templating

Dashboard variables let one dashboard serve multiple services ($service), environments ($environment), or time ranges. They're backed by Prometheus label queries. A variable $namespace with query label_values(kube_pod_info, namespace) gives a dropdown of all Kubernetes namespaces. This is a core Grafana skill for platform teams building shared observability tooling.

Distributed tracing — spans, traces, and why they matter

A trace represents a single request as it flows through multiple services. It's composed of spans — each span represents one operation (HTTP call, DB query, cache lookup) with a start time, duration, and attributes. Spans are linked by a trace ID propagated in headers (traceparent in W3C format, X-B3-TraceId in Zipkin format). Without tracing, diagnosing latency in a microservices architecture means correlating logs across 10 services by timestamp — error-prone and slow. With tracing (Tempo + OTel), you click on a slow request in Grafana and see exactly which service and which operation contributed the latency. The key metric: P99 latency per span, not just the total.

Continuous profiling — flamegraphs and eBPF

A profiler samples what the CPU is executing thousands of times per second, building a statistical picture of where time is spent. A flamegraph visualises this as a call stack — the width of each frame is proportional to the time spent in that function. Parca uses eBPF to profile running processes with no code changes and near-zero overhead. Continuous profiling (running 24/7, not just during incidents) lets you correlate CPU spikes with deployments — "this function got 3x slower after the commit that added field validation." This is the "fourth pillar" of observability because it answers "why is the CPU high" when metrics only tell you that it is.

Thanos architecture — sidecar vs receive mode

Thanos extends Prometheus with long-term storage and global query. Two deployment modes: (1) Sidecar — a Thanos sidecar runs next to each Prometheus instance, uploads TSDB blocks to object storage (S3/MinIO) after they're sealed (every 2h). The Store Gateway serves these blocks for long-range queries. Simple but adds 2h latency before data is queryable globally. (2) Receive — Prometheus remote-writes metrics to Thanos Receive in real-time. Global queryability immediately, but adds write path complexity and a potential bottleneck. For most setups: sidecar mode is simpler and sufficient. For multi-cluster global dashboards with sub-2h data: receive mode.

Load testing methodology — k6 concepts

Load testing has three phases: (1) Baseline — what's the latency and error rate at 1 concurrent user? (2) Load test — ramp to expected production traffic, verify latency SLOs hold. (3) Stress test — push beyond expected load until the system breaks, to find the failure mode and capacity ceiling. k6 models load as virtual users (VUs) running test scripts. Key metrics: http_req_duration (latency), http_req_failed (error rate), iterations (throughput). The output feeds into Grafana dashboards (k6 has a Prometheus remote write output). Run load tests before every major release in CI with a pass/fail threshold on P95 latency.

Chaos engineering — intentional failure injection

Chaos engineering tests whether a system actually survives the failures it's designed to handle. Toxiproxy injects network conditions (latency, packet loss, bandwidth limits, connection drops) between your services — you test that your circuit breaker trips when the database latency spikes to 2s, rather than discovering this in production. The practice: (1) define a steady state (normal error rate, latency), (2) hypothesise that the system survives failure X, (3) inject failure X, (4) verify steady state is maintained (or fix if not). Chaos engineering is not about breaking things randomly — it's a disciplined experiment that builds confidence in fault tolerance.

Log aggregation architectures — push vs pull, and the pipeline

Three approaches: (1) Agent-based push — Alloy/Fluent Bit runs on each host, tails log files, and pushes to Loki/Elasticsearch. Low latency, agent adds resource overhead. (2) Syslog forwarding — services write to syslog, rsyslog/syslog-ng forwards centrally. Works for systemd services without any agent. (3) Direct SDK — applications write structured logs directly to Loki's push API. Pipeline tools (Vector.dev) add buffering, transformation (parse, filter, enrich), and fan-out (logs go to both Loki and an S3 archive). The key design decision: parse logs at the source (less data transmitted, structured from the start) vs at the destination (simpler agents, parsing can be changed without redeployment). ---

Observability Philosophy

Before diving into tools, it helps to have a framework for what you're trying to observe. Two complementary models are widely used in practice.

The Four Golden Signals

Coined by Google's SRE team, these four metrics cover nearly everything that matters about a running service:

  • Latency — how long does a request take? Track both successful and failed requests separately. A failed request that returns instantly is fast but still broken.
  • Traffic — how many requests per second? This establishes your baseline and helps you notice unusual spikes or drops.
  • Errors — what fraction of requests fail? Include both explicit errors (HTTP 5xx) and implicit failures (HTTP 200 with a corrupted response).
  • Saturation — how full is the system? CPU usage, memory pressure, disk I/O queue depth. Saturation often predicts problems before latency or errors spike.

SLI, SLO, SLA, and Error Budgets

These terms define how reliability is measured and negotiated:

  • SLI (Service Level Indicator) — a specific metric that measures reliability. Example: "the fraction of HTTP requests that complete successfully in under 500ms."
  • SLO (Service Level Objective) — a target value for an SLI over a time window. Example: "the SLI above must be ≥ 99.9% over any 30-day rolling window."
  • SLA (Service Level Agreement) — a contractual commitment to an SLO, with consequences for violation. SLOs are internal; SLAs are external.
  • Error Budget — the amount of unreliability an SLO allows. 99.9% SLO over 30 days = 0.1% budget = 43.2 minutes of downtime/slowness per month.

The error budget is the most useful concept for day-to-day decisions: if you've used half your budget two weeks into the month, you slow down deployments. If you have plenty of budget left, you can move faster. This replaces "can we deploy on Fridays?" with a data-driven answer.

# Concrete SLI: 99th percentile latency below 500ms
# SLO: this must hold 99.9% of the time over 30 days

# Prometheus query for the SLI:
histogram_quantile(0.99,
  sum(rate(http_request_duration_seconds_bucket{job="myapi"}[5m])) by (le)
) < 0.5

# Error budget consumed (last 30 days):
1 - (
  sum(rate(http_requests_total{job="myapi", status!~"5.."}[30d]))
  /
  sum(rate(http_requests_total{job="myapi"}[30d]))
)

Pyrra (below) provides a dashboard-based SLO management UI that calculates error budgets and burn rates automatically from Prometheus metrics.

The USE Method

Complementing the four golden signals for infrastructure monitoring: for every resource (CPU, memory, disk, network), measure:

  • Utilisation — what percentage of the resource is being used?
  • Saturation — how much additional demand is queued (run queue length, memory swap)?
  • Errors — are there hardware errors, dropped packets, disk errors?

Apply USE to every physical resource: CPU cores, memory, storage, network interfaces. Combine with the four golden signals (which apply to services) for complete coverage.

---

Prometheus Scrapes /metrics endpoints on a schedule, evaluates alerting rules, and feeds dashboards in Grafana. The foundation of the standard self-hosted observability stack.

# ~/prometheus/compose.yaml
services:
  prometheus:
    image: prom/prometheus:latest
    ports:
      - 127.0.0.1:9090:9090
    volumes:
      - /home/user/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro,Z
      - prometheus_data:/prometheus
    restart: unless-stopped

volumes:
  prometheus_data:
cd ~/prometheus && podman-compose up -d
Minimal prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s

rule_files:
  - /etc/prometheus/alerts.yml

alerting:
  alertmanagers:
    - static_configs:
        - targets: ['host.containers.internal:9093']

scrape_configs:
  - job_name: node
    static_configs:
      - targets: ['host.containers.internal:9100']

  - job_name: cadvisor
    static_configs:
      - targets: ['host.containers.internal:8080']

Node Exporter — system metrics

# ~/node-exporter/compose.yaml
services:
  node-exporter:
    image: prom/node-exporter
    network_mode: host
    volumes:
      - /proc:/host/proc:ro,rslave
      - /sys:/host/sys:ro,rslave
      - /:/rootfs:ro,rslave
    command: --path.procfs=/host/proc --path.sysfs=/host/sys
    restart: unless-stopped
cd ~/node-exporter && podman-compose up -d

cAdvisor — container metrics

# ~/cadvisor/compose.yaml
services:
  cadvisor:
    image: gcr.io/cadvisor/cadvisor:latest
    ports:
      - 127.0.0.1:8080:8080
    volumes:
      - /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
      - /:/rootfs:ro
      - /var/run:/var/run:ro
      - /sys:/sys:ro
    restart: unless-stopped
cd ~/cadvisor && podman-compose up -d

Common operations

# Check Prometheus targets status
curl http://localhost:9090/api/v1/targets | python3 -m json.tool | grep -A3 health

# Query a metric via API
curl "http://localhost:9090/api/v1/query?query=up" | python3 -m json.tool

# Reload config without restart
curl -X POST http://localhost:9090/-/reload

# Check config validity before reloading
podman exec prometheus promtool check config /etc/prometheus/prometheus.yml

# Check alert rules
podman exec prometheus promtool check rules /etc/prometheus/alerts.yml

# View current active alerts
curl http://localhost:9090/api/v1/alerts | python3 -m json.tool
Example alert rules (alerts.yml)
groups:
  - name: host
    rules:
      - alert: HighCPU
        expr: 100 - (avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "High CPU on {{ $labels.instance }}"

      - alert: DiskNearlyFull
        expr: (node_filesystem_avail_bytes / node_filesystem_size_bytes) * 100 < 10
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Disk nearly full on {{ $labels.instance }}"

      - alert: ServiceDown
        expr: up == 0
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Service {{ $labels.job }} is down"
Recording rules

pre-compute expensive or frequently used queries and store the result as a new metric. This makes dashboards and alert rules load faster, and lets you build higher-level metrics from raw ones:

# prometheus/recording_rules.yml
groups:
  - name: recording
    interval: 1m
    rules:
      # Pre-compute request rate per job — used by many dashboards
      - record: job:http_requests_total:rate5m
        expr: sum(rate(http_requests_total[5m])) by (job)

      # Pre-compute error ratio per job — used by SLO alerts
      - record: job:http_request_errors:ratio5m
        expr: |
          sum(rate(http_requests_total{status=~"5.."}[5m])) by (job)
          /
          sum(rate(http_requests_total[5m])) by (job)

      # Node CPU utilisation — expensive query, compute once
      - record: instance:node_cpu_utilisation:rate5m
        expr: |
          1 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) by (instance)

Add this file to your Prometheus config:

rule_files:
  - /etc/prometheus/alerts.yml
  - /etc/prometheus/recording_rules.yml

Recording rule results are stored as time series with the record: name — query them just like any other metric: job:http_requests_total:rate5m. Naming convention: level:metric:operations (e.g., job:http_requests_total:rate5m).

---

Alertmanager

Purpose: Routes firing Prometheus alerts to notification channels — ntfy, Slack, email, PagerDuty, and more. Handles deduplication, grouping, silencing, and inhibition.

# ~/alertmanager/compose.yaml
services:
  alertmanager:
    image: prom/alertmanager:latest
    ports:
      - 127.0.0.1:9093:9093
    volumes:
      - /home/user/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro,Z
    restart: unless-stopped
cd ~/alertmanager && podman-compose up -d
Example alertmanager.yml — route alerts to ntfy
route:
  group_by: ['alertname', 'severity']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  receiver: ntfy

receivers:
  - name: ntfy
    webhook_configs:
      - url: http://host.containers.internal:8090/alerts
        send_resolved: true

inhibit_rules:
  - source_match:
      severity: critical
    target_match:
      severity: warning
    equal: [alertname, instance]

Alertmanager → ntfy bridge (severity-aware routing)

Use alertmanager-ntfy as a thin webhook bridge to map Prometheus severity labels to ntfy priority levels:

# ~/alertmanager-ntfy/compose.yaml
services:
  alertmanager-ntfy:
    image: ghcr.io/alexbakker/alertmanager-ntfy:latest
    ports:
      - 127.0.0.1:9095:8080
    volumes:
      - /home/user/alertmanager-ntfy/config.yaml:/config.yaml:ro,Z
    restart: unless-stopped
# ~/alertmanager-ntfy/config.yaml
ntfy:
  base_url: http://host.containers.internal:8090
  topic: alerts
  priority_map:
    critical: urgent
    warning: default
    info: low

labels:
  - name: severity
cd ~/alertmanager-ntfy && podman-compose up -d

Update alertmanager.yml to route by severity to the bridge:

route:
  group_by: ['alertname', 'severity']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  receiver: ntfy-default
  routes:
    - match:
        severity: critical
      receiver: ntfy-critical
    - match:
        severity: warning
      receiver: ntfy-warning

receivers:
  - name: ntfy-default
    webhook_configs:
      - url: http://host.containers.internal:9095/hook
        send_resolved: true
  - name: ntfy-critical
    webhook_configs:
      - url: http://host.containers.internal:9095/hook
        send_resolved: true
  - name: ntfy-warning
    webhook_configs:
      - url: http://host.containers.internal:9095/hook
        send_resolved: true

The bridge maps the severity label to ntfy priority levels automatically — criticalurgent (breaks through Do Not Disturb), warningdefault, infolow.

---

Grafana

Purpose: The standard visualisation layer for Prometheus, Loki, InfluxDB, and 50+ other data sources. Drag-and-drop dashboards, alerting, and team sharing.

# ~/grafana/compose.yaml
services:
  grafana:
    image: grafana/grafana:latest
    ports:
      - 127.0.0.1:3001:3000
    volumes:
      - grafana_data:/var/lib/grafana
    environment:
      GF_SECURITY_ADMIN_PASSWORD: changeme
      GF_SERVER_ROOT_URL: https://grafana.home.local
      GF_INSTALL_PLUGINS: grafana-clock-panel,grafana-piechart-panel,grafana-worldmap-panel
    restart: unless-stopped

volumes:
  grafana_data:
cd ~/grafana && podman-compose up -d

Common operations

# Install a plugin
podman exec grafana grafana-cli plugins install grafana-clock-panel
podman restart grafana

# Reset admin password
podman exec grafana grafana-cli admin reset-admin-password newpassword

# Check Grafana health
curl http://localhost:3001/api/health

# Export a dashboard as JSON
curl -u admin:changeme http://localhost:3001/api/dashboards/uid/YOUR_UID | python3 -m json.tool
Useful dashboard imports

(Dashboard → Import → paste ID):

  • 1860 — Node Exporter Full (complete server metrics)
  • 14282 — PostgreSQL overview
  • 11835 — Redis dashboard
  • 15141 — Kafka overview
  • 10991 — RabbitMQ overview
  • 12378 — InfluxDB 2.x system metrics

Grafana Provisioning (Dashboards and Datasources as Code)

Rather than configuring Grafana through the UI (which is lost if you recreate the container), provision datasources and dashboards from config files. Grafana reads these at startup and applies them automatically:

# ~/grafana/compose.yaml — add provisioning volume mounts
volumes:
  - grafana_data:/var/lib/grafana
  - /home/user/grafana/provisioning:/etc/grafana/provisioning:ro,Z
  - /home/user/grafana/dashboards:/etc/grafana/dashboards:ro,Z
# ~/grafana/provisioning/datasources/prometheus.yaml
apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    url: http://host.containers.internal:9090
    isDefault: true
    editable: false

  - name: Loki
    type: loki
    url: http://host.containers.internal:3100
    editable: false
# ~/grafana/provisioning/dashboards/main.yaml
apiVersion: 1
providers:
  - name: default
    type: file
    disableDeletion: true      # prevent accidental deletion via UI
    updateIntervalSeconds: 30  # hot-reload when dashboard JSON files change
    options:
      path: /etc/grafana/dashboards

Place exported dashboard JSON files in ~/grafana/dashboards/. Grafana picks them up automatically — no browser interaction required. Export a dashboard via:

# Export dashboard JSON by UID
curl -u admin:changeme http://localhost:3001/api/dashboards/uid/YOUR_UID \
  | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(d['dashboard'], indent=2))" \
  > ~/grafana/dashboards/my-dashboard.json

For BI-focused Grafana usage (connecting to databases, building analytical dashboards), see the Business Intelligence wiki.

---

Grafana Alloy (Unified Telemetry Collector)

Purpose: Replaces Promtail, Grafana Agent, and OpenTelemetry Collector in a single binary. Scrapes metrics, ships logs to Loki, and forwards traces to Tempo. The recommended replacement for running separate collection agents.

# ~/alloy/compose.yaml
services:
  alloy:
    image: grafana/alloy:latest
    ports:
      - 127.0.0.1:12345:12345
    volumes:
      - /home/user/alloy/config.alloy:/etc/alloy/config.alloy:ro,Z
      - /var/log:/var/log:ro
    command: run /etc/alloy/config.alloy
    restart: unless-stopped
cd ~/alloy && podman-compose up -d

---

Loki (Log Aggregation)

Purpose: Log aggregation system from Grafana Labs. Stores logs indexed by labels — cheap, fast, and queryable in Grafana alongside your metrics. Use Alloy (or the older Promtail) to ship container and system logs into Loki.

# ~/loki/compose.yaml
services:
  loki:
    image: grafana/loki:latest
    ports:
      - 127.0.0.1:3100:3100
    volumes:
      - /home/user/loki:/loki:Z
    restart: unless-stopped
cd ~/loki && podman-compose up -d

Common operations

# Check Loki is ready
curl http://localhost:3100/ready

# Query logs via the API (LogQL)
curl "http://localhost:3100/loki/api/v1/query_range" \
  --data-urlencode 'query={job="containerlogs"}' \
  --data-urlencode 'start=1h ago' | python3 -m json.tool | head -30

# List all label names
curl http://localhost:3100/loki/api/v1/labels | python3 -m json.tool

# Flush in-memory chunks to storage
curl -X POST http://localhost:3100/flush
Ship container logs with Alloy

— add to your config.alloy:

local.file_match "containers" {
  path_targets = [{
    __path__ = "/var/log/containers/*.log",
    job      = "containerlogs",
  }]
}

loki.source.file "containers" {
  targets    = local.file_match.containers.targets
  forward_to = [loki.write.default.receiver]
}

loki.write "default" {
  endpoint {
    url = "http://localhost:3100/loki/api/v1/push"
  }
}

---

Netdata

Purpose: Real-time system and container metrics with zero configuration. Auto-discovers running containers, processes, databases, and services. Provides built-in anomaly detection, and exports to Prometheus for Grafana dashboards.

# ~/netdata/compose.yaml
services:
  netdata:
    image: netdata/netdata:latest
    ports:
      - 127.0.0.1:19999:19999
    volumes:
      - netdata_config:/etc/netdata
      - netdata_lib:/var/lib/netdata
      - netdata_cache:/var/cache/netdata
      - /etc/passwd:/host/etc/passwd:ro
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
      - /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
    cap_add:
      - SYS_PTRACE
    security_opt:
      - apparmor=unconfined
    restart: unless-stopped

volumes:
  netdata_cache:
  netdata_config:
  netdata_lib:
cd ~/netdata && podman-compose up -d

Access at http://localhost:19999. Good first option when you want metrics immediately without writing any configuration.

---

Netdata Parent (Multi-Host Hub)

Purpose: A Netdata "parent" node acts as a streaming hub for child agents. Children stream metrics to the parent; the parent's UI shows all hosts in a unified multi-host dashboard — without sending data to netdata.cloud.

# ~/netdata-parent/compose.yaml
services:
  netdata-parent:
    image: netdata/netdata:latest
    ports:
      - 127.0.0.1:19998:19999
    volumes:
      - /home/user/netdata-parent/config:/etc/netdata:Z
      - /home/user/netdata-parent/lib:/var/lib/netdata:Z
      - /home/user/netdata-parent/cache:/var/cache/netdata:Z
    environment:
      NETDATA_CLAIM_TOKEN: ""   # leave blank for fully local hub
    cap_add: [SYS_PTRACE, SYS_ADMIN]
    restart: unless-stopped
cd ~/netdata-parent && podman-compose up -d
Configure child agents to stream to the parent

(/etc/netdata/stream.conf on each child):

[stream]
  enabled = yes
  destination = parent.home.local:19999
  api key = xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx   # generate with: uuidgen
Allow incoming streams on the parent

(/etc/netdata/stream.conf):

[xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx]   # same UUID as child's api key
  enabled = yes
  default memory mode = dbengine

Restart both instances. The parent's dashboard at http://localhost:19998 will show all streaming child nodes under the Nodes tab.

Caddy:

netdata-hub.home.local { tls internal; reverse_proxy localhost:19998 }

---

Uptime Kuma

Purpose: Self-hosted uptime monitoring with beautiful status pages. Monitors HTTP/HTTPS endpoints, TCP ports, DNS resolution, MQTT topics, and Docker container health. Sends alerts via ntfy, Telegram, Slack, email, and 50+ integrations.

# ~/uptime-kuma/compose.yaml
services:
  uptime-kuma:
    image: louislam/uptime-kuma:latest
    ports:
      - 127.0.0.1:3002:3001
    volumes:
      - /home/user/uptime-kuma:/app/data:Z
    restart: unless-stopped
cd ~/uptime-kuma && podman-compose up -d

Common operations

# Backup Uptime Kuma data
cp -r /home/user/uptime-kuma /home/user/uptime-kuma.bak

# View logs
podman logs -f uptime-kuma

# Check all monitors via API (requires API key from Settings → API Keys)
curl -H "Authorization: Bearer YOUR_API_KEY" http://localhost:3002/api/v1/monitor

Access at http://localhost:3002. The built-in status page can be shared with users to communicate outages.

---

Beszel (Multi-Host Monitoring)

Purpose: Minimal, lightweight server monitoring with a central dashboard. Each server runs a tiny agent that reports CPU, RAM, disk, and network to the hub. Better than Netdata for monitoring multiple remote servers from one screen.

# ~/beszel/compose.yaml — hub (central server)
services:
  beszel:
    image: henrygd/beszel:latest
    ports:
      - 127.0.0.1:8090:8090
    volumes:
      - /home/user/beszel/data:/beszel_data:Z
    restart: unless-stopped
cd ~/beszel && podman-compose up -d

Agent on each monitored server

# ~/beszel-agent/compose.yaml
services:
  beszel-agent:
    image: henrygd/beszel-agent:latest
    network_mode: host
    volumes:
      - /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
    environment:
      PORT: 45876
      KEY: your-public-key-from-hub
    restart: unless-stopped
cd ~/beszel-agent && podman-compose up -d

---

Dozzle (Container Log Viewer)

Purpose: Live container log viewer in the browser. Zero setup — mount the Podman socket and browse logs for any running container in real time. Supports log search, filtering, and multi-host aggregation.

# ~/dozzle/compose.yaml
services:
  dozzle:
    image: amir20/dozzle:latest
    ports:
      - 127.0.0.1:8888:8080
    volumes:
      - /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
    restart: unless-stopped
cd ~/dozzle && podman-compose up -d

---

Healthchecks.io (Cron Monitoring)

Purpose: Dead man's switch for cron jobs and scheduled tasks. Your scripts ping a URL when they finish — Healthchecks alerts you if the ping doesn't arrive on schedule. Essential for monitoring backup jobs, data sync tasks, and other scheduled work.

# ~/healthchecks/compose.yaml
services:
  healthchecks:
    image: healthchecks/healthchecks:latest
    ports:
      - 127.0.0.1:8000:8000
    environment:
      SECRET_KEY: changeme-run-openssl-rand-base64-32
      SITE_ROOT: https://hc.home.local
      ALLOWED_HOSTS: hc.home.local,localhost
      DEBUG: "False"
    volumes:
      - /home/user/healthchecks/data:/data:Z
    restart: unless-stopped
cd ~/healthchecks && podman-compose up -d

Use in a backup script

podman exec restic restic backup /data && \
  curl -fsS --retry 3 https://hc.home.local/ping/your-uuid

---

Speedtest Tracker

Purpose: Runs automated Ookla/LibreSpeed tests on a schedule and stores results with charts. Useful for documenting ISP performance over time and catching degradation before it becomes a problem.

# ~/speedtest/compose.yaml
services:
  speedtest:
    image: lscr.io/linuxserver/speedtest-tracker:latest
    ports:
      - 127.0.0.1:8092:80
    environment:
      APP_KEY: base64:changeme-run-openssl-rand-base64-32
      DB_CONNECTION: sqlite
      PUID: "1000"
      PGID: "1000"
    volumes:
      - /home/user/speedtest/config:/config:Z
    restart: unless-stopped
cd ~/speedtest && podman-compose up -d

---

SmokePing (Latency & Packet Loss)

Purpose: Network latency and packet loss monitor. Sends probes to configurable targets (your ISP gateway, 1.1.1.1, a VPS) and plots RTT over time — excellent for diagnosing intermittent network issues.

# ~/smokeping/compose.yaml
services:
  smokeping:
    image: lscr.io/linuxserver/smokeping:latest
    ports:
      - 127.0.0.1:8081:80
    volumes:
      - /home/user/smokeping/config:/config:Z
      - /home/user/smokeping/data:/data:Z
    restart: unless-stopped
cd ~/smokeping && podman-compose up -d

---

Gatus (Endpoint Monitoring)

Purpose: Declarative, Git-friendly uptime and health monitoring. Define endpoints in YAML — HTTP, TCP, DNS, ICMP — with configurable conditions. Lighter than Uptime Kuma and easy to version-control. Ships a built-in status page.

# ~/gatus/compose.yaml
services:
  gatus:
    image: twinproduction/gatus:latest
    ports:
      - 127.0.0.1:8088:8080
    volumes:
      - /home/user/gatus/config:/config:ro,Z
    restart: unless-stopped
cd ~/gatus && podman-compose up -d
Example config.yaml
endpoints:
  - name: Jellyfin
    url: http://host.containers.internal:8096/health
    interval: 60s
    conditions:
      - "[STATUS] == 200"
    alerts:
      - type: ntfy
        failure-threshold: 2
        description: "Jellyfin is down"

  - name: Nextcloud
    url: https://files.home.local
    interval: 5m
    conditions:
      - "[STATUS] == 200"
      - "[RESPONSE_TIME] < 2000"

Gatus integrates with ntfy, Slack, email, Telegram, and more. Its config file is easy to keep in Git alongside your other service configs.

---

Thanos (Prometheus Long-Term Storage, HA & Federation)

Purpose: Thanos extends Prometheus to solve its three main production limitations: retention (Prometheus stores data locally; Thanos uploads blocks to object storage — MinIO, S3, GCS — for unlimited retention), high availability (Thanos Querier deduplicates data from multiple Prometheus replicas, so you can run 2+ Prometheus instances with identical configs), and federation (Thanos Query Frontend federates across multiple Prometheus clusters — query all environments from a single Grafana datasource). The standard choice for production Prometheus at scale.

Architecture overview

Prometheus ──► Thanos Sidecar ──► Object Store (MinIO)
                    │
                    ▼
             Thanos Store Gateway ◄── Object Store (MinIO)
                    │
Thanos Ruler ──►    │
                    ▼
             Thanos Querier ◄── Grafana / PromQL clients
                    │
             Thanos Query Frontend (caching layer)
                    │
             Thanos Compactor (compaction + downsampling)
# ~/thanos/compose.yaml
services:

  # Sidecar: sits next to Prometheus, uploads TSDB blocks to object store
  thanos-sidecar:
    image: quay.io/thanos/thanos:latest
    command:
      - sidecar
      - --tsdb.path=/prometheus
      - --prometheus.url=http://host.containers.internal:9090
      - --objstore.config-file=/etc/thanos/objstore.yaml
      - --http-address=0.0.0.0:10902
      - --grpc-address=0.0.0.0:10901
    ports:
      - "127.0.0.1:10901:10901"   # gRPC (Querier connects here)
      - "127.0.0.1:10902:10902"   # HTTP status page
    volumes:
      - prometheus_data:/prometheus:ro
      - /home/user/thanos/objstore.yaml:/etc/thanos/objstore.yaml:ro,Z
    restart: unless-stopped

  # Store Gateway: serves historical blocks from object storage to Querier
  thanos-store:
    image: quay.io/thanos/thanos:latest
    command:
      - store
      - --objstore.config-file=/etc/thanos/objstore.yaml
      - --http-address=0.0.0.0:10904
      - --grpc-address=0.0.0.0:10903
      - --data-dir=/var/thanos/store
    ports:
      - "127.0.0.1:10903:10903"   # gRPC
      - "127.0.0.1:10904:10904"   # HTTP
    volumes:
      - thanos_store_data:/var/thanos/store
      - /home/user/thanos/objstore.yaml:/etc/thanos/objstore.yaml:ro,Z
    restart: unless-stopped

  # Querier: deduplicates from Sidecar + Store Gateway, exposes PromQL endpoint
  thanos-querier:
    image: quay.io/thanos/thanos:latest
    command:
      - query
      - --http-address=0.0.0.0:9091
      - --grpc-address=0.0.0.0:10905
      - --store=thanos-sidecar:10901           # real-time data from Prometheus
      - --store=thanos-store:10903             # historical data from object store
      - --query.replica-label=prometheus_replica
    ports:
      - "127.0.0.1:9091:9091"    # Querier UI + PromQL endpoint (point Grafana here)
    depends_on: [thanos-sidecar, thanos-store]
    restart: unless-stopped

  # Query Frontend: caching + query splitting layer in front of Querier
  thanos-query-frontend:
    image: quay.io/thanos/thanos:latest
    command:
      - query-frontend
      - --http-address=0.0.0.0:9092
      - --query-frontend.downstream-url=http://thanos-querier:9091
      - --query-range.split-interval=24h
      - --query-range.max-retries-per-request=5
      - --query-range.response-cache-config-file=/etc/thanos/cache.yaml
    ports:
      - "127.0.0.1:9092:9092"    # Use this as Grafana datasource URL for best performance
    depends_on: [thanos-querier]
    restart: unless-stopped

  # Compactor: compacts and downsamples historical blocks (only one instance)
  thanos-compactor:
    image: quay.io/thanos/thanos:latest
    command:
      - compact
      - --wait
      - --objstore.config-file=/etc/thanos/objstore.yaml
      - --data-dir=/var/thanos/compact
      - --retention.resolution-raw=30d     # keep raw (15s) data for 30 days
      - --retention.resolution-5m=180d     # keep 5m downsamples for 180 days
      - --retention.resolution-1h=365d     # keep 1h downsamples for 1 year
      - --http-address=0.0.0.0:10906
    ports:
      - "127.0.0.1:10906:10906"
    volumes:
      - thanos_compact_data:/var/thanos/compact
      - /home/user/thanos/objstore.yaml:/etc/thanos/objstore.yaml:ro,Z
    restart: unless-stopped

  # Ruler: evaluates alerting and recording rules against Thanos query layer
  thanos-ruler:
    image: quay.io/thanos/thanos:latest
    command:
      - rule
      - --data-dir=/var/thanos/ruler
      - --eval-interval=30s
      - --rule-file=/etc/thanos/rules/*.yaml
      - --alertmanagers.url=http://host.containers.internal:9093
      - --query=thanos-querier:9091
      - --objstore.config-file=/etc/thanos/objstore.yaml
      - --http-address=0.0.0.0:10908
      - --grpc-address=0.0.0.0:10907
      - --label=ruler_cluster="homelab"
    ports:
      - "127.0.0.1:10907:10907"
      - "127.0.0.1:10908:10908"
    volumes:
      - thanos_ruler_data:/var/thanos/ruler
      - /home/user/thanos/rules:/etc/thanos/rules:ro,Z
      - /home/user/thanos/objstore.yaml:/etc/thanos/objstore.yaml:ro,Z
    restart: unless-stopped

volumes:
  prometheus_data:
    external: true    # shared with the Prometheus container
  thanos_store_data:
  thanos_compact_data:
  thanos_ruler_data:

Object store config (/home/user/thanos/objstore.yaml) — MinIO backend

type: S3
config:
  bucket: thanos-metrics
  endpoint: minio.home.local:9000
  access_key: minioadmin
  secret_key: changeme
  insecure: true             # use false + proper cert in production
  signature_version2: false
# Create the MinIO bucket first
mc alias set local http://localhost:9000 minioadmin changeme
mc mb local/thanos-metrics

cd ~/thanos && podman-compose up -d

Wire Prometheus to upload blocks (add to prometheus.yml)

# Enable TSDB block storage (required for Thanos Sidecar)
# Thanos Sidecar reads from the same TSDB path Prometheus writes to.
# Ensure prometheus_data volume is shared between prometheus and thanos-sidecar containers.

# Remote-write to Thanos Receive (alternative architecture — push instead of sidecar):
# remote_write:
#   - url: http://thanos-receive:19291/api/v1/receive
Point Grafana at Thanos Query Frontend

In Grafana → Data Sources → Prometheus:

  • URL: http://localhost:9092 (Query Frontend — cached, split queries)
  • Or http://localhost:9091 (Querier — direct, no cache)

Multi-cluster federation

# On the global Thanos Querier, add store endpoints from remote clusters:
thanos-querier:
  command:
    - query
    - --store=thanos-sidecar-cluster1:10901     # cluster 1 Sidecar
    - --store=thanos-sidecar-cluster2:10901     # cluster 2 Sidecar
    - --store=thanos-store:10903                # shared object store (historical)
    - --query.replica-label=prometheus_replica
    # Add as many --store flags as needed (one per Prometheus/Sidecar endpoint)

HA setup (2× Prometheus, deduplicated by Thanos)

# Run two Prometheus instances with identical scrape configs but different replica labels:
# prometheus-1: --storage.tsdb.path=/prometheus --web.listen-address=:9090
# prometheus-2: --storage.tsdb.path=/prometheus --web.listen-address=:9090

# In each Prometheus's external_labels:
global:
  external_labels:
    cluster: homelab
    prometheus_replica: prometheus-1   # change to prometheus-2 on second instance

# Thanos Querier deduplicates using --query.replica-label=prometheus_replica
# Result: you see one consistent time series even when one Prometheus restarts

Compaction and downsampling explained

# Compactor runs continuously (--wait flag) and:
# 1. Merges small 2h TSDB blocks into larger ones (reduces object store files)
# 2. Downsamples raw data (15s → 5m → 1h resolution) for fast long-range queries
# 3. Applies retention policies to delete old blocks

# Check compactor status
curl http://localhost:10906/metrics | grep thanos_compact

# View blocks in object store
podman run --rm -e AWS_ACCESS_KEY_ID=minioadmin -e AWS_SECRET_ACCESS_KEY=changeme \
  quay.io/thanos/thanos:latest \
  tools bucket ls \
  --objstore.config="type: S3
config:
  bucket: thanos-metrics
  endpoint: minio.home.local:9000
  insecure: true"

Ruler — alerting rules that evaluate across long-term data

# /home/user/thanos/rules/alerts.yaml
groups:
  - name: long-term-alerts
    interval: 5m
    rules:
      # Alert if any host has had >90% CPU for more than 1 hour total in the last day
      - alert: HighCPULastDay
        expr: |
          sum_over_time(
            (avg by(instance) (rate(node_cpu_seconds_total{mode!="idle"}[5m])) > 0.9)[24h:5m]
          ) * 5 > 60
        labels:
          severity: warning
        annotations:
          summary: "{{ $labels.instance }} had high CPU for >1h in last 24h"

Caddy:

thanos.home.local         { tls internal; reverse_proxy localhost:9092 }
thanos-query.home.local   { tls internal; reverse_proxy localhost:9091 }
thanos-compact.home.local { tls internal; reverse_proxy localhost:10906 }

Troubleshooting Thanos

| Issue | Solution | |-------|----------| | Sidecar cannot read TSDB blocks | Ensure prometheus_data volume is shared between Prometheus and Thanos Sidecar containers; mount as :ro on the Sidecar | | Querier shows gaps in data | Store Gateway may be lagging — check thanos_objstore_* metrics; compactor takes time to upload blocks (default 2h) | | Compactor halted — conflict | Only one Compactor can run at a time; check for a second running instance or a stale lock file in the MinIO bucket (thanos/ prefix) | | duplicate label set error in Querier | Two store endpoints return the same series with the same labels — set --query.replica-label to the label that differentiates your Prometheus replicas | | Query Frontend cache miss for all queries | Cache config file may not be mounted correctly; start without --query-range.response-cache-config-file to confirm frontend works, then add caching | | Ruler alerts not firing | Verify Ruler's --query flag points to the Querier address; check Ruler logs for rule evaluation errors | | Blocks not appearing in Store Gateway | Blocks take up to 2h to upload (Sidecar uploads completed blocks only); force immediate upload by restarting the Sidecar |

---

VictoriaMetrics (Prometheus-Compatible, High Performance)

Purpose: Drop-in Prometheus replacement with 10× lower memory usage, better compression, and faster queries. Fully compatible with the Prometheus remote-write protocol and PromQL — point any Prometheus-scraping agent (Grafana Alloy, Telegraf, node-exporter) at VictoriaMetrics without code changes. Ideal when Prometheus starts consuming too much RAM or when you need long-term metric retention.

# ~/victoriametrics/compose.yaml
services:
  victoriametrics:
    image: victoriametrics/victoria-metrics:latest
    ports:
      - 127.0.0.1:8428:8428
    volumes:
      - /home/user/victoriametrics/data:/victoria-metrics-data:Z
    command: --storageDataPath=/victoria-metrics-data --retentionPeriod=12 --selfScrapeInterval=10s
    restart: unless-stopped
cd ~/victoriametrics && podman-compose up -d

Common operations

# Check server health
curl http://localhost:8428/health

# Query metrics (MetricsQL / PromQL)
curl "http://localhost:8428/api/v1/query?query=up"

# List all metric names
curl http://localhost:8428/api/v1/label/__name__/values | python3 -m json.tool | head -20

# Snapshot for backup
curl -X POST http://localhost:8428/snapshot/create
Reconfigure Grafana to use VictoriaMetrics

instead of Prometheus:

  • Data Sources → Prometheus → URL: http://host.containers.internal:8428
Remote-write from Prometheus to VictoriaMetrics

(dual-write for migration):

# In prometheus.yml
remote_write:
  - url: http://host.containers.internal:8428/api/v1/write

For the horizontally scalable cluster variant (vminsert / vmselect / vmstorage), see the Clusters wiki.

---

Grafana Tempo (Distributed Tracing)

Purpose: Distributed tracing backend from Grafana Labs. Stores traces from OpenTelemetry, Jaeger, Zipkin, and other instrumented services, then lets you correlate them with Prometheus metrics and Loki logs in the same Grafana dashboard.

# ~/tempo/compose.yaml
services:
  tempo:
    image: grafana/tempo:latest
    ports:
      - 127.0.0.1:3200:3200
      - 127.0.0.1:4317:4317
      - 127.0.0.1:4318:4318
    volumes:
      - /home/user/tempo/config.yaml:/etc/tempo.yaml:ro,Z
      - /home/user/tempo/data:/var/tempo:Z
    command: -config.file=/etc/tempo.yaml
    restart: unless-stopped
cd ~/tempo && podman-compose up -d
Minimal config.yaml
server:
  http_listen_port: 3200

distributor:
  receivers:
    otlp:
      protocols:
        grpc:
          endpoint: 0.0.0.0:4317
        http:
          endpoint: 0.0.0.0:4318

storage:
  trace:
    backend: local
    local:
      path: /var/tempo/blocks
    wal:
      path: /var/tempo/wal

Connect Grafana to Tempo: Configuration → Data Sources → Tempo → URL: http://host.containers.internal:3200. Enable the Trace to Logs correlation with your Loki datasource for one-click trace-to-log navigation.

---

Zabbix (Agent-Based Monitoring)

Purpose: Enterprise-grade infrastructure monitoring with active and passive agent support. Zabbix agents run on monitored hosts and push detailed metrics — process lists, file monitoring, log parsing, custom scripts, and SNMP traps. Strong choice for monitoring Windows servers, network equipment, and bare-metal machines that don't expose Prometheus /metrics endpoints.

# ~/zabbix/compose.yaml
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: zabbix
      POSTGRES_PASSWORD: changeme
      POSTGRES_DB: zabbix
    volumes:
      - zabbix_pg_data:/var/lib/postgresql/data
    restart: unless-stopped

  zabbix-server:
    image: zabbix/zabbix-server-pgsql:alpine-latest
    ports:
      - 0.0.0.0:10051:10051
    environment:
      DB_SERVER_HOST: postgres
      POSTGRES_USER: zabbix
      POSTGRES_PASSWORD: changeme
      POSTGRES_DB: zabbix
    depends_on: [postgres]
    restart: unless-stopped

  zabbix-web:
    image: zabbix/zabbix-web-nginx-pgsql:alpine-latest
    ports:
      - 127.0.0.1:8400:8080
    environment:
      ZBX_SERVER_HOST: zabbix-server
      DB_SERVER_HOST: postgres
      POSTGRES_USER: zabbix
      POSTGRES_PASSWORD: changeme
      PHP_TZ: Asia/Kolkata
    depends_on: [zabbix-server]
    restart: unless-stopped

volumes:
  zabbix_pg_data:
cd ~/zabbix && podman-compose up -d

Default login: Admin / zabbix. Change immediately. Add hosts under Configuration → Hosts.

Install Zabbix agent on monitored hosts
# Option A: Install inside a Distrobox container (recommended on this system)
distrobox create --name zabbix-agent --image fedora:latest
distrobox enter zabbix-agent -- bash -c "
  sudo dnf install -y zabbix-agent2
  sudo sed -i 's/Server=127.0.0.1/Server=zabbix.home.local/' /etc/zabbix/zabbix_agent2.conf
  sudo systemctl enable --now zabbix-agent2
"
sudo firewall-cmd --add-port=10050/tcp --permanent && sudo firewall-cmd --reload

# Option B: On a conventional Linux host (not Shani OS)
sudo dnf install zabbix-agent2
sudo sed -i 's/Server=127.0.0.1/Server=zabbix.home.local/' /etc/zabbix/zabbix_agent2.conf
sudo systemctl enable --now zabbix-agent2
sudo firewall-cmd --add-port=10050/tcp --permanent && sudo firewall-cmd --reload

Firewall (server side — for active agents):

sudo firewall-cmd --add-port=10051/tcp --permanent && sudo firewall-cmd --reload

---

Zabbix Proxy

Purpose: Collects monitoring data on behalf of the Zabbix server and forwards it in batches. Essential for monitoring remote networks where direct agent-to-server connections are impractical, and for reducing load on the main Zabbix server. The proxy runs locally in the remote network — only a single outbound connection is needed from that network to the Zabbix server.

# ~/zabbix-proxy/compose.yaml
services:
  proxy-db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: zabbix
      POSTGRES_PASSWORD: changeme
      POSTGRES_DB: zabbix_proxy
    volumes:
      - zabbix_proxy_pg_data:/var/lib/postgresql/data
    restart: unless-stopped

  zabbix-proxy:
    image: zabbix/zabbix-proxy-pgsql:alpine-latest
    ports:
      - 0.0.0.0:10051:10051
    environment:
      ZBX_SERVER_HOST: zabbix.home.local
      ZBX_SERVER_PORT: "10051"
      ZBX_PROXYMODE: "0"              # 0 = active (proxy pushes to server)
      ZBX_HOSTNAME: remote-proxy-01   # must match the proxy name in the server UI
      DB_SERVER_HOST: proxy-db
      POSTGRES_USER: zabbix
      POSTGRES_PASSWORD: changeme
      POSTGRES_DB: zabbix_proxy
    depends_on: [proxy-db]
    restart: unless-stopped

volumes:
  zabbix_proxy_pg_data:
cd ~/zabbix-proxy && podman-compose up -d
Register the proxy in the Zabbix server UI
  1. Go to Administration → Proxies → Create proxy.
  2. Set the Proxy name to match ZBX_HOSTNAME above (remote-proxy-01).
  3. Set Proxy mode to Active. Save.

In active mode (recommended), the proxy initiates the connection to the Zabbix server — no inbound firewall rules are needed on the proxy host.

---

SigNoz (OpenTelemetry-Native Observability)

Purpose: All-in-one observability platform built natively on OpenTelemetry. Combines metrics, traces, and logs in a single UI — without needing to run separate Prometheus + Tempo + Loki stacks. Best for teams already using OpenTelemetry instrumentation.

# ~/signoz/compose.yaml
services:
  clickhouse:
    image: clickhouse/clickhouse-server:24-alpine
    volumes:
      - signoz_clickhouse_data:/var/lib/clickhouse
    restart: unless-stopped

  query-service:
    image: signoz/query-service:latest
    ports:
      - 127.0.0.1:8085:8085
    environment:
      ClickHouseUrl: tcp://clickhouse:9000
    depends_on: [clickhouse]
    restart: unless-stopped

  frontend:
    image: signoz/frontend:latest
    ports:
      - 127.0.0.1:3301:3301
    depends_on: [query-service]
    restart: unless-stopped

  otel-collector:
    image: signoz/signoz-otel-collector:latest
    ports:
      - 127.0.0.1:4317:4317   # OTLP gRPC
      - 127.0.0.1:4318:4318   # OTLP HTTP
    depends_on: [clickhouse]
    restart: unless-stopped

volumes:
  signoz_clickhouse_data:
cd ~/signoz && podman-compose up -d

Use the official install.sh script from the SigNoz repo for production — it sets up all dependencies and volume mounts correctly.

Access at http://localhost:3301. Instrument your apps with the OpenTelemetry SDK and point them at http://localhost:4317 (gRPC) or http://localhost:4318 (HTTP).

---

OpenTelemetry Collector

Purpose: Vendor-neutral telemetry pipeline for traces, metrics, and logs. Receives telemetry from your applications via OTLP, Jaeger, Zipkin, or Prometheus scrape; processes and enriches it; then fans it out to multiple backends simultaneously. Removes per-backend SDK lock-in from your application code.

# ~/otel-collector/compose.yaml
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    ports:
      - 127.0.0.1:4317:4317
      - 127.0.0.1:4318:4318
      - 127.0.0.1:8889:8889
    volumes:
      - /home/user/otel/otel-collector.yaml:/etc/otelcol-contrib/config.yaml:ro,Z
    restart: unless-stopped
cd ~/otel-collector && podman-compose up -d
Example otel-collector.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318
  prometheus:
    config:
      scrape_configs:
        - job_name: otel-collector
          static_configs:
            - targets: [localhost:8888]

processors:
  batch:
    timeout: 5s
  memory_limiter:
    limit_mib: 512

exporters:
  otlp/tempo:
    endpoint: host.containers.internal:4317
    tls:
      insecure: true
  loki:
    endpoint: http://host.containers.internal:3100/loki/api/v1/push
  prometheus:
    endpoint: 0.0.0.0:8889

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch, memory_limiter]
      exporters: [otlp/tempo]
    logs:
      receivers: [otlp]
      processors: [batch]
      exporters: [loki]
    metrics:
      receivers: [otlp, prometheus]
      processors: [batch]
      exporters: [prometheus]

Point your applications' OTLP SDK at http://localhost:4317 (gRPC) or http://localhost:4318 (HTTP). They send telemetry to the Collector, which routes to Tempo, Loki, and Prometheus — zero application changes needed when you swap backends.

---

Checkmk Free (Agent-Based Infrastructure Monitoring)

Purpose: Full-stack IT infrastructure monitoring with auto-discovery, agent-based checks, SNMP, hardware health (IPMI/iDRAC), service states, inventory, and a powerful notification engine. More approachable than Zabbix for users who want a polished setup wizard. The free edition supports unlimited hosts with a full feature set for home lab and small-business use.

# ~/checkmk/compose.yaml
services:
  checkmk:
    image: checkmk/check-mk-free:latest
    ports:
      - 127.0.0.1:8095:5000
    volumes:
      - /home/user/checkmk/data:/omd/sites:Z
    tmpfs:
      - /omd/sites/cmk/tmp:uid=1000,gid=1000
    restart: unless-stopped
cd ~/checkmk && podman-compose up -d

Access at http://localhost:8095/cmk. The admin password is shown in the container startup logs (podman logs checkmk).

Install the agent on hosts to monitor
curl -o check-mk-agent.rpm \
  http://checkmk.home.local/cmk/check_mk/agents/check-mk-agent-2.3.0-1.noarch.rpm
sudo rpm -i check-mk-agent.rpm
sudo systemctl enable --now check-mk-agent.socket

Checkmk auto-discovers all running services (systemd units, listening ports, running processes) on registered agents — far less manual configuration than Prometheus exporters.

---

Karma (Alertmanager Dashboard)

Purpose: Read-only, real-time web dashboard for Alertmanager. Shows all firing alerts across multiple Alertmanager instances in a clear, filterable card layout — grouped by labels, silenced alerts visible, and instant search across alert names, labels, and annotations. Indispensable when you have many alert rules and need to quickly triage what's firing.

# ~/karma/compose.yaml
services:
  karma:
    image: ghcr.io/prymitive/karma:latest
    ports:
      - 127.0.0.1:8094:8080
    environment:
      ALERTMANAGER_URI: http://host.containers.internal:9093
      ALERTMANAGER_NAME: home
    restart: unless-stopped
cd ~/karma && podman-compose up -d

Access at http://localhost:8094. Karma auto-refreshes every 30 seconds.

Multiple Alertmanager instances

-e ALERTMANAGER_0_URI=http://host.containers.internal:9093 \
-e ALERTMANAGER_0_NAME=homelab \
-e ALERTMANAGER_1_URI=http://192.168.1.50:9093 \
-e ALERTMANAGER_1_NAME=nas

---

Graylog (Log Management & SIEM-Lite)

Purpose: Centralised log management platform. Where Loki stores logs as compressed streams and queries them with LogQL, Graylog parses, indexes, and makes logs fully searchable via OpenSearch — every field in every message is indexed, so you can query http_status:500 AND source:caddy across millions of events in milliseconds. Use Graylog when you need structured, searchable log analysis; use Loki+Grafana when you want lightweight log storage alongside metrics.

# ~/graylog/compose.yaml
services:
  mongodb:
    image: mongo:6
    volumes:
      - graylog_mongo_data:/data/db
    restart: unless-stopped

  opensearch:
    image: opensearchproject/opensearch:2
    environment:
      OPENSEARCH_JAVA_OPTS: "-Xms1g -Xmx1g"
      discovery.type: single-node
      plugins.security.disabled: "true"
      action.auto_create_index: "false"
    volumes:
      - graylog_os_data:/usr/share/opensearch/data
    ulimits:
      memlock: { soft: -1, hard: -1 }
      nofile: { soft: 65536, hard: 65536 }
    restart: unless-stopped

  graylog:
    image: graylog/graylog:6.3
    ports:
      - 127.0.0.1:9000:9000       # Web UI
      - 127.0.0.1:12201:12201     # GELF TCP
      - 127.0.0.1:12201:12201/udp # GELF UDP
      - 127.0.0.1:1514:1514       # Syslog TCP
      - 127.0.0.1:1514:1514/udp   # Syslog UDP
    environment:
      GRAYLOG_PASSWORD_SECRET: changeme-run-openssl-rand-base64-48
      # SHA2 of your admin password: echo -n yourpassword | sha256sum | cut -d' ' -f1
      # Value below is the hash of 'admin' — CHANGE IT before deploying
      GRAYLOG_ROOT_PASSWORD_SHA2: "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918"
      GRAYLOG_HTTP_EXTERNAL_URI: https://graylog.home.local/
      GRAYLOG_ELASTICSEARCH_HOSTS: http://opensearch:9200
      GRAYLOG_MONGODB_URI: mongodb://mongodb:27017/graylog
      TZ: Asia/Kolkata
    volumes:
      - /home/user/graylog/data:/usr/share/graylog/data:Z
      - /home/user/graylog/config:/usr/share/graylog/data/config:Z
    depends_on: [mongodb, opensearch]
    restart: unless-stopped

volumes:
  graylog_mongo_data:
  graylog_os_data:
cd ~/graylog && podman-compose up -d

Access at http://localhost:9000. Login with admin / your password. Create inputs under System → Inputs.

Send logs from other containers via GELF

# Add to any service's compose.yaml
logging:
  driver: gelf
  options:
    gelf-address: "udp://localhost:12201"
    tag: "myapp"

Send Caddy access logs to Graylog via Syslog

{
  log {
    output net localhost:1514 {
      dial_timeout 3s
    }
    format json
  }
}

Ship logs from any Linux host via Filebeat → Graylog

# /etc/filebeat/filebeat.yml on remote host
filebeat.inputs:
  - type: log
    paths: ["/var/log/*.log", "/var/log/caddy/*.log"]
    json.keys_under_root: true

output.logstash:
  hosts: ["graylog.home.local:5044"]

Graylog vs Loki: Use Loki (via Grafana Alloy) for lightweight log tailing alongside Prometheus metrics. Use Graylog when you need full-text indexing, structured field search, and a dedicated log analysis UI.

---

Changedetection.io (Website Change Monitor)

Purpose: Monitor any webpage for changes and get notified when content updates. Watches price drops, government notices, stock availability, documentation changes, and more. Supports CSS selectors, visual diffing, and notifications via ntfy, email, Telegram, Slack, Discord, and 80+ other services.

# ~/changedetection/compose.yaml
services:
  changedetection:
    image: ghcr.io/dgtlmoon/changedetection.io:latest
    ports:
      - 127.0.0.1:5000:5000
    volumes:
      - /home/user/changedetection/data:/datastore:Z
    environment:
      PUID: "1000"
      PGID: "1000"
    restart: unless-stopped
cd ~/changedetection && podman-compose up -d

Access at http://localhost:5000. Add URLs to watch, optionally set a CSS/XPath selector, configure the check interval, and connect a notification service.

Send notifications via ntfy

(Settings → Notifications → Add notification URL):

ntfy://host.containers.internal:8090/your-topic

Caddy:

changes.home.local { tls internal; reverse_proxy localhost:5000 }

---

OpenObserve (All-in-One Observability)

Purpose: Rust-based unified observability platform — metrics, logs, and traces in a single binary with a built-in web UI. Claims ~140× lower storage cost than Elasticsearch for log ingestion. A compelling alternative to running the full Grafana + Loki + Tempo stack when you want one service instead of three. Accepts OpenTelemetry, Prometheus remote-write, and Loki-compatible log APIs.

# ~/openobserve/compose.yaml
services:
  openobserve:
    image: public.ecr.aws/zinclabs/openobserve:latest
    ports:
      - 127.0.0.1:5080:5080
    volumes:
      - /home/user/openobserve/data:/data:Z
    environment:
      ZO_ROOT_USER_EMAIL: admin@example.com
      ZO_ROOT_USER_PASSWORD: changeme
      ZO_DATA_DIR: /data
    restart: unless-stopped
cd ~/openobserve && podman-compose up -d

Access at http://localhost:5080. Ingest logs via the Loki-compatible endpoint (/api/{org}/loki/api/v1/push), send metrics via Prometheus remote-write, and send traces via OTLP.

Caddy:

openobserve.home.local { tls internal; reverse_proxy localhost:5080 }

---

Prometheus Pushgateway

Purpose: An intermediary for short-lived jobs that cannot be scraped by Prometheus — batch jobs, cron tasks, and scripts that run and exit before Prometheus's scrape interval. The job pushes metrics to the Pushgateway on completion; Prometheus then scrapes the Pushgateway at its normal interval. Essential for monitoring backup jobs, ETL pipelines, and any workload where the process is already gone by the time Prometheus would scrape it.

# ~/pushgateway/compose.yaml
services:
  pushgateway:
    image: prom/pushgateway:latest
    ports:
      - 127.0.0.1:9091:9091
    restart: unless-stopped
cd ~/pushgateway && podman-compose up -d

Add to prometheus.yml

scrape_configs:
  - job_name: pushgateway
    honor_labels: true
    static_configs:
      - targets: ['host.containers.internal:9091']

Push metrics from a script

# Push a single metric (backup job duration)
cat <<EOF | curl --data-binary @- http://localhost:9091/metrics/job/restic_backup/instance/homeserver
# HELP restic_backup_duration_seconds Duration of the last backup run
# TYPE restic_backup_duration_seconds gauge
restic_backup_duration_seconds 142.3
# HELP restic_backup_success Whether the last backup succeeded (1=yes, 0=no)
# TYPE restic_backup_success gauge
restic_backup_success 1
EOF

# Delete a metric group after the job
curl -X DELETE http://localhost:9091/metrics/job/restic_backup/instance/homeserver

In a backup systemd service

# Wrap your backup command and push success/failure
START=$(date +%s)
podman exec restic restic backup /data && SUCCESS=1 || SUCCESS=0
DURATION=$(($(date +%s) - START))
cat <<EOF | curl --data-binary @- http://localhost:9091/metrics/job/restic_backup
restic_backup_success $SUCCESS
restic_backup_duration_seconds $DURATION
EOF

Caddy:

pushgateway.home.local { tls internal; reverse_proxy localhost:9091 }

---

Pyrra (SLO Management)

Purpose: SLO (Service Level Objective) management for Prometheus. Define SLOs in YAML, and Pyrra generates the recording rules, alerting rules, and Grafana dashboards automatically. Calculates error budgets, burn rates, and multi-window alerts — the proper way to move from raw metric alerts to SLO-based alerting without writing complex PromQL by hand.

# ~/pyrra/compose.yaml
services:
  pyrra-api:
    image: ghcr.io/pyrra-dev/pyrra:latest
    ports:
      - 127.0.0.1:9099:9099
    volumes:
      - /home/user/pyrra/slos:/etc/pyrra:Z
    command: filesystem --config-files=/etc/pyrra
    restart: unless-stopped

  pyrra-kubernetes:
    image: ghcr.io/pyrra-dev/pyrra:latest
    command: kubernetes
    restart: unless-stopped
cd ~/pyrra && podman-compose up -d

Add to prometheus.yml

scrape_configs:
  - job_name: pyrra
    static_configs:
      - targets: ['host.containers.internal:9099']

rule_files:
  - /etc/prometheus/pyrra/*.yaml
Example SLO definition (/home/user/pyrra/slos/api-availability.yaml)
apiVersion: pyrra.dev/v1alpha1
kind: ServiceLevelObjective
metadata:
  name: api-availability
  namespace: monitoring
spec:
  target: "99.9"
  window: 4w
  serviceLevel:
    objectives:
      - ratio:
          errors:
            metric: http_requests_total{job="myapi", code=~"5.."}
          total:
            metric: http_requests_total{job="myapi"}

Access Pyrra's UI at http://localhost:9099 to view current SLO status, error budget remaining, and burn rate over time.

Caddy:

pyrra.home.local { tls internal; reverse_proxy localhost:9099 }

---

Grafana OnCall (On-Call Scheduling)

Purpose: Self-hosted on-call scheduling and escalation platform — a PagerDuty/OpsGenie alternative. Define on-call schedules (weekly rotations, override shifts), escalation chains (page the primary → wait 5 min → page the secondary → alert the manager), and route Alertmanager or Grafana alerts through it. Integrates natively with Grafana and has mobile apps for iOS and Android.

# ~/grafana-oncall/compose.yaml
services:
  engine:
    image: grafana/oncall:latest
    ports:
      - 127.0.0.1:8080:8080
    environment:
      SECRET_KEY: changeme-run-openssl-rand-hex-32
      DATABASE_TYPE: sqlite3
      BROKER_TYPE: redis
      BASE_URL: https://oncall.home.local
      REDIS_URI: redis://redis:6379/0
      DJANGO_SETTINGS_MODULE: settings.hobby
    volumes:
      - /home/user/oncall/data:/var/lib/oncall:Z
    depends_on: [redis]
    restart: unless-stopped

  celery:
    image: grafana/oncall:latest
    command: ./celery_with_beat.sh
    environment:
      SECRET_KEY: changeme-run-openssl-rand-hex-32
      DATABASE_TYPE: sqlite3
      BROKER_TYPE: redis
      BASE_URL: https://oncall.home.local
      REDIS_URI: redis://redis:6379/0
      DJANGO_SETTINGS_MODULE: settings.hobby
    volumes:
      - /home/user/oncall/data:/var/lib/oncall:Z
    depends_on: [redis]
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    restart: unless-stopped
cd ~/grafana-oncall && podman-compose up -d

Access at http://localhost:8080. Connect to Grafana under Settings → Plugins → Grafana OnCall, then configure integrations under Integrations → Alertmanager to receive alerts.

Caddy:

oncall.home.local { tls internal; reverse_proxy localhost:8080 }

---

Loki Alert Rules (Log-Based Alerting)

Purpose: LogQL-based alerting fires Prometheus-compatible alerts based on log patterns — distinct from metric alerts. Use log alerts to fire when error rates in logs exceed a threshold, when a specific log pattern appears (like FATAL or panic:), or when a log stream goes silent (indicating a dead service). Loki alert rules are configured using the Loki Ruler and work alongside Alertmanager exactly like Prometheus rules.

Enable the ruler in Loki config
# Add to your Loki config (if using the single-binary image)
ruler:
  storage:
    type: local
    local:
      directory: /loki/rules
  rule_path: /loki/rules-temp
  alertmanager_url: http://host.containers.internal:9093
  ring:
    kvstore:
      store: inmemory
  enable_api: true
Example rule files (/home/user/loki/rules/homelab/rules.yaml)
groups:
  - name: log-alerts
    rules:
      # Fire when error rate in app logs exceeds 10/min for 5 minutes
      - alert: HighErrorRate
        expr: |
          sum(rate({job="containerlogs", container="myapp"} |= "ERROR" [1m])) > 10
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High error rate in myapp logs"
          description: "More than 10 errors/min for 5 minutes"

      # Fire when a specific fatal error appears
      - alert: PanicDetected
        expr: |
          count_over_time({job="containerlogs"} |= "panic:" [5m]) > 0
        for: 0m
        labels:
          severity: critical
        annotations:
          summary: "Panic detected in container logs"

      # Fire when a service produces no logs (dead service detection)
      - alert: ServiceSilent
        expr: |
          absent(rate({job="containerlogs", container="myapp"}[10m]))
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "myapp has produced no logs for 10 minutes"
Create the rules directory and restart Loki
mkdir -p /home/user/loki/rules/homelab
# Place rule files there
podman restart loki

Query via API to verify rules are loaded

curl http://localhost:3100/loki/api/v1/rules | python3 -m json.tool

Log-based and metric-based alerts both route through the same Alertmanager — you get a unified alert feed from both systems, deduplicated and routed to ntfy/Slack/email by the same alertmanager.yml.

---

Parca (Continuous Profiling)

Purpose: Always-on CPU and memory profiling for your running services — captures flamegraphs in production without manual sampling. Stores profiles over time so you can compare CPU usage before and after a code change or pinpoint a memory leak by diffing two time windows. Adds the fourth pillar of observability alongside metrics, logs, and traces.

# ~/parca/compose.yaml
services:
  parca:
    image: ghcr.io/parca-dev/parca:latest
    ports:
      - 127.0.0.1:7070:7070
    volumes:
      - /home/user/parca/parca.yaml:/etc/parca/parca.yaml:ro,Z
    command: /parca --config-path=/etc/parca/parca.yaml
    restart: unless-stopped

  parca-agent:
    image: ghcr.io/parca-dev/parca-agent:latest
    privileged: true
    pid: host
    network_mode: host
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:ro
      - /sys/fs/bpf:/sys/fs/bpf
      - /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
    command: >
      --node=homeserver
      --remote-store-address=localhost:7070
      --remote-store-insecure
    restart: unless-stopped
# ~/parca/parca.yaml
object_storage:
  bucket:
    type: FILESYSTEM
    config:
      directory: /var/lib/parca

scrape_configs:
  - job_name: parca-server
    scrape_interval: 10s
    static_configs:
      - targets: ['localhost:7070']
cd ~/parca && podman-compose up -d

Access at http://localhost:7070. Select a profile type (CPU, memory allocations), choose a time range, and Parca renders an interactive flamegraph. Use the Compare view to diff two time windows.

The Parca Agent uses eBPF to profile any process on the host without code changes. Requires kernel ≥ 5.3 with BTF support — verify with ls /sys/kernel/btf/vmlinux.

Caddy:

parca.home.local { tls internal; reverse_proxy localhost:7070 }

---

Elasticsearch + ELK Stack (Single-Node)

Purpose: Distributed search and analytics engine — the E in the ELK stack (Elasticsearch + Logstash + Kibana). Stores, indexes, and searches structured and unstructured log data at scale. For multi-node production clusters see the Clusters wiki.

# ~/elk/compose.yaml
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.13.4
    ports:
      - 127.0.0.1:9200:9200
    volumes:
      - es_data:/usr/share/elasticsearch/data
    environment:
      discovery.type: single-node
      xpack.security.enabled: "false"
      ES_JAVA_OPTS: "-Xms512m -Xmx1g"
    ulimits:
      memlock: { soft: -1, hard: -1 }
      nofile: { soft: 65536, hard: 65536 }
    restart: unless-stopped

  kibana:
    image: docker.elastic.co/kibana/kibana:8.13.4
    ports:
      - 127.0.0.1:5601:5601
    environment:
      ELASTICSEARCH_HOSTS: http://elasticsearch:9200
    depends_on: [elasticsearch]
    restart: unless-stopped

  logstash:
    image: docker.elastic.co/logstash/logstash:8.13.4
    ports:
      - 127.0.0.1:5044:5044    # Beats input
      - 127.0.0.1:5000:5000    # TCP/syslog input
      - 127.0.0.1:9600:9600    # Logstash monitoring API
    volumes:
      - /home/user/elk/logstash/pipeline:/usr/share/logstash/pipeline:ro,Z
      - /home/user/elk/logstash/config/logstash.yml:/usr/share/logstash/config/logstash.yml:ro,Z
    environment:
      LS_JAVA_OPTS: "-Xms256m -Xmx512m"
    depends_on: [elasticsearch]
    restart: unless-stopped

volumes:
  es_data:
# Required on the host before starting
sudo sysctl -w vm.max_map_count=262144
echo "vm.max_map_count=262144" | sudo tee /etc/sysctl.d/99-elasticsearch.conf

cd ~/elk && podman-compose up -d

Kibana

http://localhost:5601 — create index patterns under Stack Management → Index Patterns.

Minimal logstash.yml
# ~/elk/logstash/config/logstash.yml
http.host: "0.0.0.0"
xpack.monitoring.enabled: false
pipeline.workers: 2
pipeline.batch.size: 125

Pipeline: Beats → parse → Elasticsearch (beats-to-es.conf)

# ~/elk/logstash/pipeline/beats-to-es.conf
input {
  beats {
    port => 5044
  }
}

filter {
  if [fields][type] == "nginx" {
    grok {
      match => { "message" => "%{COMBINEDAPACHELOG}" }
    }
    date {
      match => ["timestamp", "dd/MMM/yyyy:HH:mm:ss Z"]
    }
    geoip {
      source => "clientip"
    }
    mutate {
      remove_field => ["message", "timestamp"]
    }
  }

  if [fields][type] == "syslog" {
    grok {
      match => { "message" => "%{SYSLOGTIMESTAMP:syslog_timestamp} %{SYSLOGHOST:syslog_hostname} %{DATA:syslog_program}(?:\\[%{POSINT:syslog_pid}\\])?: %{GREEDYDATA:syslog_message}" }
    }
    date {
      match => ["syslog_timestamp", "MMM  d HH:mm:ss", "MMM dd HH:mm:ss"]
    }
  }
}

output {
  elasticsearch {
    hosts => ["http://elasticsearch:9200"]
    index => "logs-%{[fields][type]}-%{+YYYY.MM.dd}"
  }
}

Common Logstash operations

# Check pipeline status
curl http://localhost:9600/_node/pipelines?pretty

# Check node stats (throughput, queue depth)
curl http://localhost:9600/_node/stats?pretty | python3 -m json.tool | grep -A5 events

# Validate a pipeline config before deploying
podman exec logstash logstash --config.test_and_exit \
  -f /usr/share/logstash/pipeline/beats-to-es.conf

Common Elasticsearch operations

# Cluster health
curl http://localhost:9200/_cluster/health?pretty

# List all indices with size and doc count
curl "http://localhost:9200/_cat/indices?v&s=store.size:desc"

# Delete an index
curl -X DELETE http://localhost:9200/logs-2024.01.01

# Check ILM policy status for an index
curl http://localhost:9200/logs-000001/_ilm/explain?pretty

Index Lifecycle Management (ILM) — auto-manage index ageing

curl -X PUT http://localhost:9200/_ilm/policy/logs-policy \
  -H "Content-Type: application/json" -d '
{
  "policy": {
    "phases": {
      "hot": {
        "min_age": "0ms",
        "actions": {
          "rollover": { "max_primary_shard_size": "50gb", "max_age": "1d" },
          "set_priority": { "priority": 100 }
        }
      },
      "warm": {
        "min_age": "7d",
        "actions": {
          "shrink": { "number_of_shards": 1 },
          "forcemerge": { "max_num_segments": 1 },
          "set_priority": { "priority": 50 }
        }
      },
      "delete": {
        "min_age": "90d",
        "actions": { "delete": {} }
      }
    }
  }
}'

---

Beats — Lightweight Log & Metric Shippers

Purpose: Single-purpose, lightweight agents (Go binaries, no JVM) that run on monitored hosts and ship data to Logstash or Elasticsearch directly.

| Beat | Ships | Use Case | |------|-------|----------| | Filebeat | Log files | Application logs, access logs, syslog | | Metricbeat | System metrics | CPU, memory, disk, container stats | | Packetbeat | Network traffic | HTTP, DNS, MySQL, Redis protocol analysis | | Auditbeat | Audit events | File integrity monitoring, auditd events | | Heartbeat | Uptime | Active monitoring, HTTP/TCP/ICMP checks | | Winlogbeat | Windows Event Log | Windows security and application logs |

Filebeat — ship log files to Logstash

# ~/filebeat/compose.yaml
services:
  filebeat:
    image: docker.elastic.co/beats/filebeat:8.13.4
    user: root
    volumes:
      - /home/user/filebeat/filebeat.yml:/usr/share/filebeat/filebeat.yml:ro,Z
      - /var/log:/var/log:ro
      - /run/user/1000/podman/podman.sock:/run/podman/podman.sock:ro
      - filebeat_data:/usr/share/filebeat/data
    restart: unless-stopped

volumes:
  filebeat_data:
# ~/filebeat/filebeat.yml
filebeat.inputs:
  - type: log
    enabled: true
    paths:
      - /var/log/*.log
      - /var/log/caddy/*.log
    fields:
      type: syslog
    fields_under_root: true

  - type: log
    enabled: true
    paths:
      - /var/log/nginx/access.log
    fields:
      type: nginx
    fields_under_root: true

output.logstash:
  hosts: ["host.containers.internal:5044"]

processors:
  - add_host_metadata: ~
  - add_cloud_metadata: ~

logging.level: info
cd ~/filebeat && podman-compose up -d

Metricbeat — ship system metrics

# ~/metricbeat/compose.yaml
services:
  metricbeat:
    image: docker.elastic.co/beats/metricbeat:8.13.4
    user: root
    network_mode: host
    volumes:
      - /home/user/metricbeat/metricbeat.yml:/usr/share/metricbeat/metricbeat.yml:ro,Z
      - /proc:/hostfs/proc:ro
      - /sys/fs/cgroup:/hostfs/sys/fs/cgroup:ro
      - /:/hostfs:ro
      - /run/user/1000/podman/podman.sock:/run/podman/podman.sock:ro
    command: metricbeat -e --system.hostfs=/hostfs
    restart: unless-stopped

---

OpenSearch (Single-Node)

Purpose: Fully open-source fork of Elasticsearch 7.10 under the Apache 2.0 licence. Drop-in API compatible — any Logstash output, Filebeat, or Metricbeat that targets Elasticsearch works against OpenSearch without changes. For multi-node production clusters see the Clusters wiki.

# ~/opensearch/compose.yaml
services:
  opensearch:
    image: opensearchproject/opensearch:2
    ports:
      - 127.0.0.1:9200:9200
      - 127.0.0.1:9600:9600
    environment:
      discovery.type: single-node
      DISABLE_SECURITY_PLUGIN: "true"
      OPENSEARCH_JAVA_OPTS: "-Xms512m -Xmx1g"
    volumes:
      - opensearch_data:/usr/share/opensearch/data
    ulimits:
      memlock: { soft: -1, hard: -1 }
      nofile: { soft: 65536, hard: 65536 }
    restart: unless-stopped

  opensearch-dashboards:
    image: opensearchproject/opensearch-dashboards:2
    ports:
      - 127.0.0.1:5601:5601
    environment:
      OPENSEARCH_HOSTS: '["http://opensearch:9200"]'
      DISABLE_SECURITY_DASHBOARDS_PLUGIN: "true"
    depends_on: [opensearch]
    restart: unless-stopped

volumes:
  opensearch_data:
sudo sysctl -w vm.max_map_count=262144
echo "vm.max_map_count=262144" | sudo tee /etc/sysctl.d/99-elasticsearch.conf

cd ~/opensearch && podman-compose up -d

---

Fluent Bit (Lightweight Log Forwarder)

Purpose: Ultra-lightweight (< 1 MB binary, ~1 MB RAM at idle) log and metrics forwarder written in C. The modern replacement for Fluentd in resource-constrained environments. Collects from files, syslog, systemd journal, Docker, and container runtimes; then ships to Elasticsearch, OpenSearch, Loki, ClickHouse, S3, Kafka, and 40+ other outputs.

# ~/fluent-bit/compose.yaml
services:
  fluent-bit:
    image: fluent/fluent-bit:latest
    ports:
      - 127.0.0.1:24224:24224/tcp
      - 127.0.0.1:24224:24224/udp
      - 127.0.0.1:2020:2020
    volumes:
      - /home/user/fluent-bit/fluent-bit.conf:/fluent-bit/etc/fluent-bit.conf:ro,Z
      - /home/user/fluent-bit/parsers.conf:/fluent-bit/etc/parsers.conf:ro,Z
      - /var/log:/var/log:ro
      - /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
    restart: unless-stopped

fluent-bit.conf — collect system logs and ship to Elasticsearch + Loki

[SERVICE]
    Flush         5
    Daemon        Off
    Log_Level     info
    Parsers_File  parsers.conf
    HTTP_Server   On
    HTTP_Listen   0.0.0.0
    HTTP_Port     2020
    storage.type  filesystem
    storage.path  /var/log/fluent-bit-storage/

[INPUT]
    Name              tail
    Path              /var/log/*.log
    Tag               syslog.*
    Parser            syslog-rfc3164
    DB                /var/log/fluent-bit-syslog.db
    Mem_Buf_Limit     5MB
    Skip_Long_Lines   On

[INPUT]
    Name              systemd
    Tag               journal.*
    Systemd_Filter    _SYSTEMD_UNIT=caddy.service
    Strip_Underscores On

[FILTER]
    Name           record_modifier
    Match          *
    Record         hostname ${HOSTNAME}
    Record         environment homelab

[OUTPUT]
    Name                es
    Match               *
    Host                host.containers.internal
    Port                9200
    Logstash_Format     On
    Logstash_Prefix     fluent
    Suppress_Type_Name  On

[OUTPUT]
    Name        loki
    Match       *
    Host        host.containers.internal
    Port        3100
    Labels      job=fluent-bit,host=${HOSTNAME}
    Line_Format json

Common operations

# Check pipeline stats
curl http://localhost:2020/api/v1/metrics | python3 -m json.tool

# Test config before deploying
podman exec fluent-bit fluent-bit --config /fluent-bit/etc/fluent-bit.conf --dry-run

Fluent Bit vs Logstash vs Filebeat: Use Fluent Bit for a tiny-footprint forwarder (perfect for every container/host to a central aggregator). Use Filebeat when you're in the Elastic ecosystem. Use Logstash for heavy-duty filtering, complex Grok patterns, or multiple conditional outputs.

---

Vector.dev (High-Performance Log & Metric Pipeline)

Purpose: Rust-based observability data pipeline. Collects logs, metrics, and traces; transforms them with a powerful built-in VRL (Vector Remap Language) scripting layer; and routes to any backend. Significantly higher throughput than Logstash or Fluent Bit on multi-core hardware, with end-to-end acknowledgements and disk-backed buffering. A single Vector instance can replace Filebeat + Logstash, or Promtail + Grafana Alloy, in many setups.

# ~/vector/compose.yaml
services:
  vector:
    image: timberio/vector:latest-alpine
    ports:
      - 127.0.0.1:8686:8686    # Vector API
      - 127.0.0.1:6000:6000    # Syslog TCP
      - 127.0.0.1:6001:6001/udp
    volumes:
      - /home/user/vector/vector.yaml:/etc/vector/vector.yaml:ro,Z
      - /var/log:/var/log:ro
      - /home/user/vector/data:/var/lib/vector:Z
      - /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
    restart: unless-stopped

vector.yaml — collect, enrich, and fan out to Elasticsearch and Loki

api:
  enabled: true
  address: "0.0.0.0:8686"

data_dir: /var/lib/vector

sources:
  syslog_tcp:
    type: syslog
    address: "0.0.0.0:6000"
    mode: tcp

  host_logs:
    type: file
    include:
      - /var/log/*.log
      - /var/log/caddy/*.log

  docker_logs:
    type: docker_logs
    docker_host: "unix:///run/user/1000/podman/podman.sock"

  host_metrics:
    type: host_metrics
    scrape_interval_secs: 15
    collectors: [cpu, disk, filesystem, load, memory, network]

transforms:
  enrich_all:
    type: remap
    inputs: [syslog_tcp, docker_logs]
    source: |
      .hostname = get_hostname!()
      .environment = "homelab"

  filter_noise:
    type: filter
    inputs: [enrich_all]
    condition: |
      !includes(["debug", "trace"], downcase(string!(.level ?? "")))

sinks:
  elasticsearch_out:
    type: elasticsearch
    inputs: [filter_noise]
    endpoints: ["http://host.containers.internal:9200"]
    mode: bulk
    bulk:
      index: "vector-%Y.%m.%d"
    buffer:
      type: disk
      max_size: 268435456   # 256 MB

  loki_out:
    type: loki
    inputs: [filter_noise]
    endpoint: "http://host.containers.internal:3100"
    labels:
      job: vector
      host: "{{ hostname }}"
    encoding:
      codec: json
    buffer:
      type: disk
      max_size: 134217728   # 128 MB

  prometheus_out:
    type: prometheus_exporter
    inputs: [host_metrics]
    address: "0.0.0.0:9598"

Common operations

# Check topology and component health
curl http://localhost:8686/health
curl http://localhost:8686/components | python3 -m json.tool

# Validate config before deploying
podman exec vector vector validate /etc/vector/vector.yaml

# Test VRL expressions interactively
podman run --rm -it timberio/vector:latest-alpine vector vrl

Vector vs Fluent Bit vs Logstash: Vector has the highest throughput and most expressive transformation language (VRL). Fluent Bit has the smallest footprint for edge/sidecar deployments. Logstash has the richest plugin ecosystem and best Kibana integration. For a new homelab log pipeline shipping to both Elasticsearch and Loki, Vector is the best starting point.

---

k6 / Grafana k6 (Load Testing)

Purpose: Open-source load testing tool with a JavaScript scripting API. Write realistic traffic simulations in JS, run them locally or in CI, and push metrics directly into your existing Prometheus stack via remote-write — then visualise results in Grafana with the official k6 dashboard. Native companion to the Prometheus + Grafana stack already documented here.

# Install k6 via Nix
nix-env -iA nixpkgs.k6

# Or via Snap
snap install k6

Basic load test script (~/k6/smoke-test.js)

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '1m', target: 20 },   // ramp up to 20 VUs
    { duration: '3m', target: 20 },   // hold for 3 min
    { duration: '1m', target: 0 },    // ramp down
  ],
  thresholds: {
    http_req_failed: ['rate<0.01'],           // <1% errors
    http_req_duration: ['p(95)<500'],         // 95th percentile < 500ms
  },
};

export default function () {
  const res = http.get('https://myapp.home.local/api/health');
  check(res, { 'status is 200': (r) => r.status === 200 });
  sleep(1);
}
Run locally
k6 run ~/k6/smoke-test.js

# Run with more VUs and a duration override
k6 run --vus 50 --duration 60s ~/k6/smoke-test.js

Push results to Prometheus (remote-write to VictoriaMetrics or Prometheus)

# Using the experimental Prometheus remote-write output
K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9090/api/v1/write \
K6_PROMETHEUS_RW_TREND_AS_NATIVE_HISTOGRAM=true \
  k6 run --out=experimental-prometheus-rw ~/k6/smoke-test.js
Run as a Podman container in CI (Woodpecker example)
# .woodpecker.yml
steps:
  load-test:
    image: grafana/k6:latest
    environment:
      K6_PROMETHEUS_RW_SERVER_URL: http://prometheus.home.local:9090/api/v1/write
    commands:
      - k6 run --out=experimental-prometheus-rw /k6/smoke-test.js
    volumes:
      - /home/user/k6:/k6:ro

Import the k6 Grafana dashboard

In Grafana → Dashboards → Import → Dashboard ID 18030 (official k6 Prometheus dashboard). This gives you p50/p95/p99 latency, VU count, request rate, and error rate per test run, all correlated with your application metrics.

---

Toxiproxy (Network Failure Simulation)

Purpose: A programmable TCP proxy that injects network failures — latency, packet loss, bandwidth throttling, connection resets, and timeouts — between your services. Use Toxiproxy to test how your monitored services behave when dependencies are degraded: does Alertmanager fire? Do your Prometheus alerts have the right thresholds? Does your application retry correctly? Essential for chaos engineering and validating monitoring alert fidelity.

# ~/toxiproxy/compose.yaml
services:
  toxiproxy:
    image: ghcr.io/shopify/toxiproxy:latest
    ports:
      - "127.0.0.1:8474:8474"    # Toxiproxy REST API
      - "127.0.0.1:15432:15432"  # proxied postgres (example)
      - "127.0.0.1:16379:16379"  # proxied redis (example)
    restart: unless-stopped
cd ~/toxiproxy && podman-compose up -d

# Install the CLI
nix-env -iA nixpkgs.toxiproxy   # or: go install github.com/Shopify/toxiproxy/v2/cli/toxiproxy-cli@latest
Create proxies for your services
# Proxy for Postgres (real Postgres at localhost:5432, proxied at localhost:15432)
toxiproxy-cli create postgres --listen 0.0.0.0:15432 --upstream localhost:5432

# Proxy for Redis
toxiproxy-cli create redis --listen 0.0.0.0:16379 --upstream localhost:6379

# List all proxies
toxiproxy-cli list

Inject failures via REST API or CLI

# Add 200ms latency to all Postgres connections
toxiproxy-cli toxic add postgres --type latency --attribute latency=200 --attribute jitter=50

# Simulate 30% packet loss on Redis
toxiproxy-cli toxic add redis --type slicer --attribute average_size=1 --attribute delay_us=0

# Bandwidth throttle to 100 KB/s (simulates slow link)
toxiproxy-cli toxic add postgres --type bandwidth --attribute rate=100

# Timeout — close connections after 2s of inactivity
toxiproxy-cli toxic add postgres --type timeout --attribute timeout=2000

# Remove a toxic
toxiproxy-cli toxic remove postgres --toxicName latency_downstream

# Take a proxy completely offline (simulates full outage)
toxiproxy-cli toggle postgres

Use in integration tests (Python example)

import requests

TOXIPROXY_API = "http://localhost:8474"

def add_latency(proxy_name, latency_ms):
    requests.post(f"{TOXIPROXY_API}/proxies/{proxy_name}/toxics", json={
        "type": "latency", "name": "db_slow",
        "attributes": {"latency": latency_ms, "jitter": 10}
    })

def remove_toxic(proxy_name, toxic_name):
    requests.delete(f"{TOXIPROXY_API}/proxies/{proxy_name}/toxics/{toxic_name}")

# In your test:
add_latency("postgres", 500)
# ... run test that should degrade gracefully ...
remove_toxic("postgres", "db_slow")

Pair Toxiproxy with your Prometheus + Alertmanager stack: inject a fault, verify the correct alert fires within the expected for: duration, then check that it resolves when you remove the toxic. This validates your alert thresholds are calibrated to actual failure modes rather than theoretical ones.

---

Netdata → Grafana Datasource Integration

Purpose: Netdata (already documented above) exposes a Prometheus-compatible metrics endpoint — you can query it directly from Grafana as a datasource alongside your regular Prometheus instance. This gives you Netdata's per-second system metrics (CPU, RAM, disk I/O, network, containers) in the same Grafana dashboards as your application metrics, without running a separate Prometheus scrape job.

Step 1 — Enable Prometheus exporter in Netdata

Netdata exposes Prometheus metrics at /api/v1/allmetrics?format=prometheus by default on port 19999. No configuration required — it's always on.

# Test the endpoint
curl http://localhost:19999/api/v1/allmetrics?format=prometheus | head -30

Step 2 — Add Netdata as a Prometheus datasource in Grafana

In Grafana → Connections → Data Sources → Add → Prometheus:

  • Name: Netdata
  • URL: http://netdata.home.local:19999/api/v1/allmetrics?format=prometheus
  • Scrape interval: 1s (Netdata collects at 1s resolution)
  • Query timeout: 30s

Or configure via provisioning YAML:

# /home/user/grafana/provisioning/datasources/netdata.yaml
apiVersion: 1
datasources:
  - name: Netdata
    type: prometheus
    access: proxy
    url: http://host.containers.internal:19999/api/v1/allmetrics?format=prometheus
    isDefault: false
    jsonData:
      timeInterval: "1s"

Step 3 — Query Netdata metrics in Grafana panels

# CPU usage per core
netdata_cpu_cpu_percentage_average{dimension="user"}

# System RAM usage
netdata_system_ram_MiB_average{dimension="used"}

# Disk I/O
rate(netdata_disk_io_kilobytes_persec_average[1m])

# Network traffic per interface
netdata_net_kilobits_persec_average{dimension="received"}

# Container CPU (Netdata monitors all Podman containers)
netdata_cgroups_cpu_percentage_average{chart=~"cgroup_.*"}

Step 4 — Import a Netdata Grafana dashboard

Go to Grafana → Dashboards → Import → Dashboard ID 7107 (Netdata System Overview). This gives you a full system health dashboard powered by Netdata's Prometheus endpoint.

When to use which: Keep Prometheus as your primary datasource for application metrics, SLO calculations, and alert evaluation. Use the Netdata datasource for host-level dashboards where 1-second resolution matters (disk spike analysis, container burst profiling). Both can be combined in a single Grafana dashboard row by row.

---

Caddy Configuration

grafana.home.local         { tls internal; reverse_proxy localhost:3001 }
prometheus.home.local      { tls internal; reverse_proxy localhost:9090 }
alerts.home.local          { tls internal; reverse_proxy localhost:9093 }
karma.home.local           { tls internal; reverse_proxy localhost:8094 }
pushgateway.home.local     { tls internal; reverse_proxy localhost:9091 }
pyrra.home.local           { tls internal; reverse_proxy localhost:9099 }
oncall.home.local          { tls internal; reverse_proxy localhost:8080 }
netdata.home.local         { tls internal; reverse_proxy localhost:19999 }
netdata-hub.home.local     { tls internal; reverse_proxy localhost:19998 }
uptime.home.local          { tls internal; reverse_proxy localhost:3002 }
beszel.home.local          { tls internal; reverse_proxy localhost:8090 }
dozzle.home.local          { tls internal; reverse_proxy localhost:8888 }
hc.home.local              { tls internal; reverse_proxy localhost:8000 }
speedtest.home.local       { tls internal; reverse_proxy localhost:8092 }
smokeping.home.local       { tls internal; reverse_proxy localhost:8081 }
gatus.home.local           { tls internal; reverse_proxy localhost:8088 }
victoriametrics.home.local { tls internal; reverse_proxy localhost:8428 }
tempo.home.local           { tls internal; reverse_proxy localhost:3200 }
zabbix.home.local          { tls internal; reverse_proxy localhost:8400 }
signoz.home.local          { tls internal; reverse_proxy localhost:3301 }
checkmk.home.local         { tls internal; reverse_proxy localhost:8095 }
graylog.home.local         { tls internal; reverse_proxy localhost:9000 }
changes.home.local         { tls internal; reverse_proxy localhost:5000 }
openobserve.home.local     { tls internal; reverse_proxy localhost:5080 }
parca.home.local           { tls internal; reverse_proxy localhost:7070 }
toxiproxy.home.local       { tls internal; reverse_proxy localhost:8474 }

---

Troubleshooting

| Issue | Solution | |-------|----------| | Grafana shows "No data" | Ensure Prometheus scrape targets are reachable; verify the datasource URL uses host.containers.internal not localhost | | Loki not receiving logs | Check that Alloy/Promtail is running and the Loki push URL is correct; check podman logs loki | | Prometheus scrape failing | Confirm the target endpoint responds at /metrics with a 200; check the port is bound and reachable | | Alertmanager not sending alerts | Verify receiver config syntax; test with amtool alert add; check podman logs alertmanager | | Node Exporter shows wrong metrics | Ensure --path.procfs and --path.sysfs flags are set correctly when running in a container | | Netdata container metrics missing | Mount the Podman socket: -v /run/user/$(id -u)/podman/podman.sock:/var/run/docker.sock:ro | | Uptime Kuma push monitors not firing | Verify the monitor URL is accessible from the container; check that ntfy topic/webhook URL is correct | | Dozzle shows no containers | Rootless Podman uses /run/user/$(id -u)/podman/podman.sock — not /var/run/docker.sock | | Beszel agent not reporting | Verify the public key from the hub is correctly pasted; check that port 45876 is reachable from the hub | | Gatus not sending alerts | Verify the alert integration config syntax; check podman logs gatus for connection errors | | Healthchecks ping not received | Verify SITE_ROOT is the URL the script calls; check that the UUID matches the check in the UI | | VictoriaMetrics not receiving data | Verify the remote-write URL is http://host.containers.internal:8428/api/v1/write; check podman logs victoriametrics | | Tempo traces not appearing | Ensure the OTel SDK targets the correct endpoint (4317 for gRPC, 4318 for HTTP); check podman logs tempo | | OTel Collector dropping spans | Check memory_limiter isn't too aggressive; increase limit_mib; view pipeline stats at http://localhost:8888/metrics | | Checkmk agent not connecting | Ensure check-mk-agent.socket is active on the monitored host; verify TCP port 6556 is reachable | | Zabbix agent not connecting | Verify Server= in zabbix_agent2.conf matches the Zabbix server IP; check port 10051/tcp is open | | Zabbix Proxy not registering | Confirm ZBX_HOSTNAME in the proxy compose matches exactly the name in the server UI under Administration → Proxies | | SigNoz no data after deployment | Ensure the OTel collector is running and your app is sending to the correct port; check ClickHouse is healthy | | Karma shows no alerts | Verify ALERTMANAGER_URI is reachable from the container using host.containers.internal | | Graylog web UI unreachable | Ensure GRAYLOG_HTTP_EXTERNAL_URI matches the URL you're accessing; check OpenSearch and MongoDB are healthy first | | Graylog GELF input not receiving logs | Verify the log driver uses gelf with gelf-address: udp://; check firewall isn't blocking 12201/udp | | Graylog OpenSearch connection refused | The plugins.security.disabled: "true" env var is required for OpenSearch 2.x without TLS | | Changedetection not detecting changes | Try adding a CSS selector to target the specific element; some sites require the Playwright-based browser fetcher for JavaScript-rendered content | | alertmanager-ntfy bridge not delivering | Verify the bridge container is running and the url in alertmanager.yml uses host.containers.internal; check podman logs alertmanager-ntfy | | Netdata parent shows no child nodes | Confirm the api key UUID in both child and parent stream.conf match exactly; restart the child agent after editing | | Parca Agent missing profiles | eBPF requires kernel ≥ 5.3 with BTF — verify with ls /sys/kernel/btf/vmlinux; the agent must run privileged: true with pid: host | | Elasticsearch OOM-killed | Limit JVM heap with ES_JAVA_OPTS="-Xms512m -Xmx1g"; default is 50% of host RAM | | Elasticsearch vm.max_map_count too low | Run sudo sysctl -w vm.max_map_count=262144 on the host and persist in /etc/sysctl.d/99-elasticsearch.conf | | Kibana Kibana server is not ready yet | Wait for Elasticsearch to fully start first; check podman logs kibana | | Logstash Pipeline aborted due to error | Check podman logs logstash; most common causes are Grok pattern mismatch or Elasticsearch unreachable | | Filebeat connection refused to Logstash | Verify Logstash Beats input is on port 5044; use host.containers.internal:5044 not localhost:5044 | | OpenSearch cluster_manager not discovered | cluster.initial_cluster_manager_nodes must list all manager-eligible nodes on first boot only | | Fluent Bit losing events on container restart | Enable storage.type filesystem on a persistent volume; without this, in-flight events are lost on restart | | Vector pipeline component showing errors | Run curl localhost:8686/components to see component health; run vector validate /etc/vector/vector.yaml before deploying | | Vector disk buffer filling up | Increase max_size in the sink buffer config, or fix the downstream sink connectivity; Vector applies backpressure rather than dropping events | | k6 experimental-prometheus-rw: connection refused | Ensure Prometheus has --web.enable-remote-write-receiver flag or use VictoriaMetrics which accepts remote-write by default | | k6 thresholds not appearing in Grafana | Import dashboard ID 18030 and set the datasource to the Prometheus instance receiving k6 remote-write; confirm K6_PROMETHEUS_RW_SERVER_URL is reachable from where k6 runs | | Toxiproxy proxy not affecting traffic | Ensure your app connects to the Toxiproxy port (e.g., 15432) rather than directly to Postgres (5432); use toxiproxy-cli list to verify the proxy is enabled | | Toxiproxy toxic added but latency not observed | Some toxics are directional — add the toxic to both upstream and downstream if needed; verify with toxiproxy-cli inspect <proxy> | | Netdata Grafana datasource returns no data | The Prometheus query format differs from native Prometheus — use netdata_ prefixed metric names; verify with curl http://netdata:19999/api/v1/allmetrics?format=prometheus | grep netdata_ | | Netdata metrics disappear after host restart | Netdata stores metrics in /var/cache/netdata — mount this as a volume (/home/user/netdata/cache:/var/cache/netdata:Z) to persist across container restarts |

---

Alerting Best Practices

Alert on symptoms, not causes

Alert on what the user experiences (high latency, error rate, service down) not on infrastructure metrics that may or may not be impacting users (CPU at 80% might be fine). A CPU alert that fires daily and never requires action is alert noise; an error-rate alert that fires rarely but always requires action is signal.

Multi-window multi-burn-rate alerting (SLO-based)

Instead of a simple threshold alert, use two windows that catch both fast and slow error budget burns:

# alerts.yml — SLO burn rate alert (99.9% availability SLO)
groups:
  - name: slo_alerts
    rules:
      # Fast burn: 14.4× rate over 1h → will exhaust budget in 5 days
      - alert: ErrorBudgetFastBurn
        expr: |
          (
            job:http_request_errors:ratio5m > (14.4 * 0.001)
            and
            job:http_request_errors:ratio1h > (14.4 * 0.001)
          )
        for: 2m
        labels:
          severity: critical
          slo: availability
        annotations:
          summary: "Fast error budget burn on {{ $labels.job }}"

      # Slow burn: 3× rate over 6h → will exhaust budget in 24 days
      - alert: ErrorBudgetSlowBurn
        expr: |
          (
            job:http_request_errors:ratio5m > (3 * 0.001)
            and
            job:http_request_errors:ratio6h > (3 * 0.001)
          )
        for: 15m
        labels:
          severity: warning
          slo: availability
        annotations:
          summary: "Slow error budget burn on {{ $labels.job }}"

Inhibition rules — reduce alert storms

When a ServiceDown fires, suppress all SlowResponse and HighErrorRate alerts for the same instance — the downstream symptoms are causally related to the root cause:

# alertmanager.yml
inhibit_rules:
  - source_match:
      alertname: ServiceDown
    target_match_re:
      alertname: (SlowResponse|HighErrorRate|DiskNearlyFull)
    equal: [instance]

---

Grafana Loki LogQL Reference

LogQL is Loki's query language. Understanding its patterns is essential for building useful log dashboards.

# Filter by label
{job="containerlogs", container="myapp"}

# Filter by log content
{job="containerlogs"} |= "ERROR"

# Exclude pattern
{job="containerlogs"} != "health check"

# Regex match
{job="containerlogs"} |~ "status=5[0-9]{2}"

# Parse JSON logs and filter on a field
{job="containerlogs"} | json | level="error"

# Parse unstructured logs with logfmt
{job="containerlogs"} | logfmt | status >= 500

# Count error rate (metric query)
sum(rate({job="containerlogs"} |= "ERROR" [5m])) by (container)

# P99 latency from a structured log field
quantile_over_time(0.99, {job="containerlogs"} | json | unwrap latency_ms [5m]) by (endpoint)

# Top 5 containers by error count (last hour)
topk(5,
  sum(count_over_time({job="containerlogs"} |= "ERROR" [1h])) by (container)
)

---

Prometheus Exporters Reference

Common exporters you'll configure beyond node-exporter and cAdvisor:

| Exporter | Port | What it exposes | |----------|------|-----------------| | postgres_exporter | 9187 | PostgreSQL query times, connections, replication lag | | redis_exporter | 9121 | Redis hits/misses, memory, commands/sec | | mysql_exporter | 9104 | MySQL queries, connections, slow queries | | rabbitmq_exporter | 9419 | Queue depth, message rates, consumer lag | | kafka_exporter | 9308 | Topic offsets, consumer group lag | | blackbox_exporter | 9115 | HTTP/TCP/ICMP probes from external perspective | | snmp_exporter | 9116 | Network device metrics via SNMP | | nginx_exporter | 9113 | Nginx request rates, active connections | | cadvisor | 8080 | Container CPU/memory/network/disk | | process_exporter | 9256 | Per-process CPU, memory, open files |

Blackbox Exporter (external probing)

# ~/blackbox/compose.yaml
services:
  blackbox:
    image: prom/blackbox-exporter:latest
    ports:
      - 127.0.0.1:9115:9115
    volumes:
      - /home/user/blackbox/config.yml:/etc/blackbox_exporter/config.yml:ro,Z
    restart: unless-stopped
# ~/blackbox/config.yml
modules:
  http_2xx:
    prober: http
    timeout: 5s
    http:
      valid_http_versions: [HTTP/1.1, HTTP/2.0]
      valid_status_codes: []  # default: 2xx
      follow_redirects: true
  tcp_connect:
    prober: tcp
    timeout: 5s
# prometheus.yml — scrape blackbox for your services
- job_name: blackbox
  metrics_path: /probe
  params:
    module: [http_2xx]
  static_configs:
    - targets:
        - https://nextcloud.home.local
        - https://gitea.home.local
        - https://grafana.home.local
  relabel_configs:
    - source_labels: [__address__]
      target_label: __param_target
    - source_labels: [__param_target]
      target_label: instance
    - target_label: __address__
      replacement: host.containers.internal:9115

---

DIUN (Docker Image Update Notifier)

Purpose: Watches your running container images and notifies you when a newer version is available upstream — without automatically updating. Gives you visibility into available updates before Renovate creates a PR, and works for containers not managed by Renovate (e.g. manually run one-offs).

# ~/diun/compose.yaml
services:
  diun:
    image: crazymax/diun:latest
    volumes:
      - /home/user/diun:/data:Z
      - /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
    environment:
      TZ: Asia/Kolkata
      LOG_LEVEL: info
      DIUN_WATCH_SCHEDULE: "0 8 * * 1"          # Weekly on Monday 8 AM
      DIUN_PROVIDERS_DOCKER: "true"
      DIUN_NOTIF_NTFY_ENDPOINT: http://host.containers.internal:8090
      DIUN_NOTIF_NTFY_TOPIC: container-updates
    restart: unless-stopped
cd ~/diun && podman-compose up -d

---

Caddy (additional routes)

blackbox.home.local { tls internal; reverse_proxy localhost:9115 }

---

Troubleshooting (additional)

| Issue | Solution | |-------|----------| | Prometheus scrape timeout errors | Increase scrape_timeout in prometheus.yml (default 10s); check the target is not overloaded | | High cardinality causing Prometheus OOM | Run topk(10, count by (__name__)({__name__=~".+"})) to find high-cardinality metrics; drop labels at scrape time with metric_relabel_configs | | Loki out of order errors | Ensure log timestamps are monotonically increasing; use allow_structured_metadata: true in Loki config if timestamps are close together | | Grafana dashboard loads slowly | Enable query caching in Grafana data source settings; use recording rules in Prometheus to pre-compute expensive queries | | Alertmanager duplicate alerts | Add group_by labels that are common across duplicates; use equal in inhibition rules to match root cause and symptom alerts | | DIUN not detecting updated images | Verify the Podman socket is mounted and readable; check DIUN_PROVIDERS_DOCKER is "true"; inspect logs with podman logs diun |