All apps · 0 apps
plex-exporter
Docker app from cplieger's Repository
Overview
Readme
View on GitHubplex-exporter
See what your Plex server is doing in Grafana: sessions, libraries, bandwidth, transcoding.
What it does
Connects to your Plex Media Server and exposes metrics (active sessions, library sizes, bandwidth, transcoding status) in a format that Prometheus can scrape and Grafana can visualize.
Key metrics exposed:
- Library duration, storage, and item counts (movies, episodes, tracks)
- Active session details (user, device, resolution, stream type)
- Transcode type detection (video/audio/both) and subtitle handling
- Session bandwidth and location (LAN/WAN)
- Host CPU and memory utilization (Plex Pass)
- Bandwidth transmission totals (Plex Pass)
- HTTP polling reachability
- Active transcode session count
Why this design
- Polling
/status/sessionsfor real-time session tracking: polls the Plex sessions API every 5s, so new sessions appear within seconds; the tracker prunes sessions after 60s of inactivity - Single binary: direct dependencies are
prometheus/client_golangand a few small helper libraries (see Dependencies); everything else is the standard library - Distroless and rootless: runs on
gcr.io/distroless/static-debian13as UID 65532 with no shell or package manager, minimizing attack surface - Prometheus-native: exposes a standard
/metricsendpoint that works with any Prometheus-compatible scraper and any Grafana dashboard, no custom visualization layer
Limitations
- Plex Pass features degrade gracefully. CPU/memory utilization and bandwidth statistics require Plex Pass. Without it, those metrics are simply absent; every other metric still works.
- Library item counts are cached. Episode, track, and item counts are refreshed every 15 minutes to avoid hammering the Plex API. Counts may lag slightly after large library scans.
Quick start
Available from both ghcr.io/cplieger/plex-exporter and docker.io/cplieger/plex-exporter; identical images and tags.
services:
plex-exporter:
image: ghcr.io/cplieger/plex-exporter:latest
container_name: plex-exporter
restart: unless-stopped
environment:
PLEX_URL: "http://plex:32400" # full URL including scheme and port
PLEX_TOKEN: "your-plex-token" # admin token from Plex Web settings
ports:
- "9594:9594"
Configuration reference
Environment variables
| Variable | Description | Default | Required |
|---|---|---|---|
PLEX_URL |
Full URL of your Plex Media Server including scheme and port (e.g. http://192.0.2.100:32400) |
none | Yes |
PLEX_TOKEN |
Plex authentication token for the server administrator. Get it from Plex Web → Settings → XML view → myPlexAccessToken. Also readable from a file: see PLEX_TOKEN_FILE below |
none | Yes |
PLEX_TOKEN_FILE |
Path to a file holding the Plex token (a Docker or Podman secret). When set it takes precedence over PLEX_TOKEN and keeps the token out of the container environment, so it does not appear in docker inspect. One trailing line ending is stripped; a file holding only whitespace is rejected at startup |
(unset) | No |
LISTEN_ADDR |
Address and port for the metrics HTTP server | :9594 |
No |
PLEX_CA_CERT_PATH |
Path to a PEM file with the CA that signed your Plex server's certificate; TLS verification stays on, pinned to that CA. See TLS / certificate setup. | (unset) | No |
TLS / certificate setup
Pick the configuration that matches your Plex server:
Your PLEX_URL looks like |
What to do |
|---|---|
http://plex:32400 (Docker network, LAN, etc.) |
nothing; TLS isn't in use |
https://<hash>.plex.direct:32400 (Plex's official cert) |
nothing; Let's Encrypt is trusted by default |
https://192.0.2.100:32400 or https://plex.local (self-signed / private CA) |
set PLEX_CA_CERT_PATH to the PEM file of the CA that signed your Plex cert |
Ports
| Port | Description |
|---|---|
9594 |
Prometheus metrics endpoint (/metrics) and health check (/api/health) |
Hardened and Kubernetes deployments
The health probe writes a marker file to /tmp/.healthy (see Healthcheck), so a read-only root filesystem needs a writable /tmp. Without one the probe can never pass and the container is reported unhealthy while the exporter itself works. A small in-memory mount is enough; the marker is an empty file.
Docker Compose:
read_only: true
tmpfs:
- "/tmp:size=1m,mode=1777,noexec,nosuid,nodev"
Kubernetes, alongside securityContext.readOnlyRootFilesystem: true:
volumes:
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 8Mi
# in the container spec
volumeMounts:
- name: tmp
mountPath: /tmp
One more thing worth knowing on Kubernetes: the Prometheus Operator adds pod, endpoint and container labels to every scraped series, and the pod value changes on each restart. A panel reading a single gauge over a time range therefore renders one entry per dead pod until the old series ages out.
The shipped dashboard handles this by asking current-state tiles for an instant value rather than a range, so stale pod series cannot appear. If you need those labels gone at ingestion instead, drop them in the ServiceMonitor:
metricRelabelings:
- action: labeldrop
regex: (pod|endpoint|container)
Do this only for a single-replica deployment. Those labels are what distinguishes one replica's series from another's, so dropping all three on a multi-replica ServiceMonitor makes two replicas emit identical label sets, which Prometheus warns against explicitly. With more than one replica, keep pod and aggregate in the query instead.
Metrics reference
HTTP Endpoints
| Endpoint | Method | Description |
|---|---|---|
/metrics |
GET | Prometheus metrics (see below) |
/api/health |
GET | Returns {"status":"OK"} when ready, 503 when starting/stopping |
Server Metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
plex_server_info |
Gauge (always 1) | server, server_id, version, platform, platform_version, plex_pass |
Server metadata and Plex Pass status |
plex_host_cpu_utilization_ratio |
Gauge | server, server_id |
Host CPU utilization as a ratio (0.0–1.0). Requires Plex Pass. |
plex_host_memory_utilization_ratio |
Gauge | server, server_id |
Host memory utilization as a ratio (0.0–1.0). Requires Plex Pass. |
plex_transmit_bytes_total |
Counter | server, server_id |
Cumulative bytes transmitted (from Plex bandwidth API). Requires Plex Pass. Resets on container restart; indicative only. |
plex_active_transcode_sessions |
Gauge | server, server_id |
Number of active video transcode sessions (from root endpoint, no Plex Pass needed) |
plex_http_reachable |
Gauge | server, server_id |
HTTP polling reachability: 1 = last refresh succeeded, 0 = failed |
plex_session_poll_reachable |
Gauge | server, server_id |
Session poll reachability: 1 = last /status/sessions poll succeeded, 0 = failed |
plex_http_retries_total |
Counter | server, server_id |
Total HTTP retries performed by the Plex client's retry round-tripper across all requests |
plex_exporter_errors_total |
Counter | server, server_id, type |
Exporter error count by type. Types: refresh, sessions_fetch, metadata_fetch, invalid_rating_key, metrics_server, library_items. |
Library Metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
plex_library_duration_milliseconds |
Gauge | server, server_id, library_type, library, library_id |
Total duration of all items in the library (ms) |
plex_library_storage_bytes |
Gauge | server, server_id, library_type, library, library_id |
Total storage used by the library (bytes) |
plex_library_items |
Gauge | server, server_id, library_type, library, library_id, content_type |
Number of items in the library. content_type is movies, episodes, tracks, photos, or items. Refreshed every 15 minutes. Absent until the count is read once; a library read as empty reports 0. |
Session Metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
plex_plays_active |
Gauge | server, server_id, library, library_id, library_type, media_type, title, child_title, grandchild_title, grandchild_index, stream_type, stream_resolution, stream_file_resolution, device, device_type, user, session, transcode_type, subtitle_action, location, local |
Currently active play sessions (1 per session). Reported once the exporter has seen the session playing, so a stream that was already paused when the exporter started is absent until it resumes. Use count(plex_plays_active) for total stream count. Removed after 60s of inactivity. |
plex_play_seconds_total |
Counter | (same as above) | Cumulative play time for the session (seconds) |
plex_session_bandwidth_kbps |
Gauge | server, server_id, session, user, location |
Real-time session bandwidth from the Plex Sessions API (kbps) |
plex_session_bitrate_kbps |
Gauge | server, server_id, session, user, location |
Live stream bitrate per session (kbps). Kept as its own series rather than a label on the play metrics, so adaptive-streaming bitrate changes cannot inflate label cardinality. |
Session Label Reference
| Label | Values | Description |
|---|---|---|
stream_type |
directplay, copy, transcode |
How the stream is being delivered |
transcode_type |
none, video, audio, both |
What is being transcoded |
subtitle_action |
none, burn, copy, transcode |
How subtitles are handled |
location |
lan, wan |
Client network location |
local |
true, false |
Whether the client is on the local network |
media_type |
movie, episode, track, etc. |
Plex media type |
For episodes: title = show name, child_title = season,
grandchild_title = episode title, grandchild_index = episode
number (track number for music). For movies: title = movie
name, others are empty.
Beyond the values above, every user-controlled label value is
normalized to a bounded set, so an unexpected Plex response can never
explode Prometheus cardinality: a value outside the documented set
becomes other and missing data becomes unknown. This covers
stream_type, media_type, location, subtitle_action, and the
resolution labels. An empty Plex subtitleDecision is reported as
subtitle_action="none".
Alerting
plex-exporter exposes Prometheus metrics on /metrics (see
Metrics reference) and writes its own diagnostics to its
container log. The rules ship as one file per expression language: scrape
/metrics and evaluate the six PromQL rules in
alerts/promql.yaml with Prometheus or the Mimir ruler,
and ship the container's logs to Loki (Grafana Alloy's Docker log discovery does
this with no configuration) to evaluate the three LogQL rules in
alerts/logql.yaml with Loki's ruler. Load each half into
its own ruler: neither ruler parses the other's expressions. Firing alerts
deliver through your Alertmanager either way. They cover:
| Alert | Fires when | Severity |
|---|---|---|
PlexExporterTargetDown |
no successful scrape of the exporter for 15m, so the scrape itself is failing | warning |
PlexExporterTargetAbsent |
the exporter has no up series at all for 15m, so the target has left service discovery |
warning |
PlexAPIUnreachable |
the authenticated Plex API poll reports plex_http_reachable=0 for 10m (often a revoked or invalid PLEX_TOKEN) |
warning |
PlexSessionPollFailing |
the /status/sessions poll reports plex_session_poll_reachable=0 for 10m while the rest of the API answers, so every session metric is absent or stale |
warning |
PlexExporterCollectionErrors |
the plex_exporter_errors_total counter keeps rising for some type over 30m |
warning |
PlexLibraryItemsCollapsed |
a library's item count drops more than 50% versus its level ~1-2h earlier and stays down for 30m, a drop to exactly zero included | warning |
PlexExporterFatalError |
the exporter logs an ERROR: a rejected config or token, a bind failure, a metrics-server failure, or a recovered panic |
warning |
PlexExporterSessionMapFull |
the session tracker is at its cap and drops new Plex sessions, so the session metrics undercount | warning |
PlexExporterRefreshIncomplete |
a refresh cycle runs out of time before the Plex Pass gauges are read, so they keep serving values from an earlier cycle | warning |
Thresholds, the for: windows, and the severity labels are starting points;
add your scrape job label to the selectors if you run more than one instance,
adjust the container selector to whatever your log collector sets, and route
by whatever labels your Alertmanager uses.
PlexExporterTargetDown and PlexExporterTargetAbsent are the two rules that
must carry a job matcher, because they ask whether this exporter is visible at
all. Set both to whatever your scrape config calls the exporter, and keep the
matcher exact rather than a regex: a regex asks whether any matching target is
up, so one healthy replica masks a failed one, and it leaves the absent()
result with no label to route on.
Every metric rule reads a series the exporter publishes, so all of them go quiet
together when it stops being scraped, which is what those two rules exist to
catch. The three log rules are the ones that still fire when there is nothing
left to scrape: a configuration the exporter refuses outright reaches
PlexExporterFatalError before any series exists.
Healthcheck
The image ships a HEALTHCHECK (the CLI probe /plex-exporter health) that verifies the HTTP server is listening; /api/health serves the same status over HTTP. The container exits (and Docker restarts it) only on a non-recoverable startup error: a bad token or other 4xx (except 408 and 429), the wrong server (404), a TLS/certificate misconfiguration, or a metrics-server start failure. A transient startup failure (DNS, dial, timeout, a 408 or 429, or a 5xx from a Plex that is still starting up) instead brings the exporter up degraded but healthy: it binds /metrics, reports plex_http_reachable=0, and recovers automatically once Plex is reachable again.
Security
Connects outbound to Plex only. The /metrics endpoint serves
read-only Prometheus data (standard for internal exporters).
PLEX_TOKEN is never logged or exposed in metrics.
The Plex client sends the token in a request header, refuses
redirects, restricts requests to the configured server, bounds
response reads, and caps each request at 30s. TLS verification
always stays on; PLEX_CA_CERT_PATH pins a private CA instead of
disabling it. Rating keys are validated as integers before URL
construction, and the metrics server sets explicit read, write,
and header timeouts. The image runs as a non-root user on a
distroless base with no shell or package manager.
Static analysis and vulnerability scans run in CI on every change; current results are on the repository's Security tab. One accepted finding: semgrep reports two informational matches, both reviewed as false positives.
Dependencies
All dependencies are updated automatically via Renovate and pinned by digest or version for reproducibility.
| Dependency | Source |
|---|---|
| golang | Go |
| gcr.io/distroless/static | Distroless |
| github.com/prometheus/client_golang | GitHub |
| github.com/prometheus/client_model | GitHub |
| github.com/cplieger/plexapi/v2 | GitHub |
| github.com/cplieger/webhttp/v3 | GitHub |
| github.com/cplieger/health | GitHub |
| github.com/cplieger/envx/v2 | GitHub |
| github.com/cplieger/slogx | GitHub |
| github.com/cplieger/runesafe/v2 | GitHub |
| golang.org/x/sync | golang.org/x |
| pgregory.net/rapid | pkg.go.dev |
Credits
This is an original tool building on the Grafana Hackathon 2022 prometheus-plex-exporter lineage: the @jsclayton post-hackathon fork and the actively maintained @timothystewart6 fork. It also uses the Plex Media Server API and prometheus/client_golang.
Contributing
Issues and pull requests are welcome. Please open an issue first for larger changes so the approach can be discussed before implementation.
Disclaimer
This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.
This project was built with AI-assisted tooling using Claude, GPT, and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.
License
GPL-3.0-or-later. See LICENSE.
Install plex-exporter on Unraid in a few clicks.
Find plex-exporter in Community Apps on your Unraid server, review the template, and click Install. Unraid handles the Docker app or plugin setup from the published template.
Requirements
Plex URL: the full URL of your Plex server including scheme and port, for example http://192.0.2.100:32400.
Plex Token: the server administrator's token, from Plex Web: Settings, then the XML view of any item, then myPlexAccessToken.
Host CPU, memory and bandwidth metrics require Plex Pass; everything else works without it.
The metrics endpoint has no authentication: keep port 9594 on your LAN.
Categories
Download Statistics
Related apps
Explore more like this
Explore allDetails
cplieger/plex-exporter:latestRuntime arguments
- Web UI
http://[IP]:[PORT:9594]/metrics- Network
bridge- Privileged
- false
Template configuration
Full URL of your Plex Media Server including scheme and port, e.g. http://192.0.2.100:32400
- Target
- PLEX_URL
Plex authentication token of the server administrator (Plex Web: Settings, XML view, myPlexAccessToken)
- Target
- PLEX_TOKEN
Prometheus metrics (/metrics) and health (/api/health)
- Target
- 9594
- Default
- 9594
- Value
- 9594
Only for an https Plex URL with a self-signed or private-CA certificate: in-container path of the CA PEM file (add a matching path mapping)
- Target
- PLEX_CA_CERT_PATH