Jesse A. Green / notebook

Live demo: the DocCompiler guide library as a static, read-only snapshot (October 2026). No backend, no accounts, nothing to submit.

Plex Media Server Docker Administrator & Developer Guide

Generated April 2026 from plexinc/pms-docker at 1cef65e

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

Operational reference for the official plexinc/pms-docker image. This guide is written for sysadmins, NAS operators, devops engineers, and Kubernetes platform teams running Plex Media Server (PMS) in production containers. It treats the repository as a deployment artifact (Docker image plus Helm chart) rather than as application source — the application itself (PMS) is proprietary Plex software shipped as a .deb package the image installs on top of Ubuntu.

1. Architecture Overview

plexinc/pms-docker is the canonical containerized packaging of Plex Media Server, published and maintained by Plex, Inc. The image is built on Ubuntu 24.04, supervised by s6-overlay (PID 1: /init), and ships PMS as a .deb either baked in at build time (pinned tags) or installed on every container start (public/beta tags).

The repository is purely deployment material: there is no PMS source code here. The components that do live in this repo are:

  • A multi-arch Dockerfile that lays down PMS plus s6-overlay
  • Four shell scripts in root/ that handle install, healthchecks, and service control
  • Three init scripts under root/etc/cont-init.d/ that handle UID/GID, claim-token exchange, GPU device groups, and self-update
  • The PMS service definition under root/etc/services.d/plex/
  • Three Compose templates (host, bridge, macvlan)
  • An unRAID community-app template (plex-unRAID.xml)
  • A first-class Helm chart at charts/plex-media-server (chart 1.5.0, app 1.43.0)

Operational Architecture

┌──────────────────────────────────────────────────────────────────┐
│  Docker host (Linux preferred)                                   │
│                                                                  │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │  Container: plexinc/pms-docker                             │  │
│  │  Base: ubuntu:24.04                                        │  │
│  │                                                            │  │
│  │  s6-overlay /init  (PID 1)                                 │  │
│  │    cont-init.d/  (run sequentially on start)               │  │
│  │      ├── 40-plex-first-run                                 │  │
│  │      │     UID/GID setup, /config ownership,               │  │
│  │      │     Preferences.xml seeding,                        │  │
│  │      │     POST /api/claim/exchange (claim token)          │  │
│  │      ├── 45-plex-hw-transcode-and-connected-tuner          │  │
│  │      │     scan /dev/dri + /dev/dvb,                       │  │
│  │      │     add `plex` user to device groups                │  │
│  │      └── 50-plex-update                                    │  │
│  │            (only when TAG=public|beta)                     │  │
│  │            curl plex.tv/downloads/details/5,               │  │
│  │            dpkg -i the latest .deb                         │  │
│  │                                                            │  │
│  │    services.d/plex/                                        │  │
│  │      ├── run            launch PMS as `plex` (s6-setuidgid)│  │
│  │      ├── finish         SIGTERM, then SIGKILL              │  │
│  │      └── timeout-finish 8000ms kill timeout                │  │
│  │                                                            │  │
│  │  /healthcheck.sh   curl http://localhost:32400/identity    │  │
│  │  /plex_service.sh  s6-svc -u | -d | -r wrapper             │  │
│  │  /installBinary.sh select arch, install .deb               │  │
│  │  /plex-common.sh   shared functions                        │  │
│  │                                                            │  │
│  │  Volumes:                                                  │  │
│  │    /config     PMS database + prefs (REQUIRED)             │  │
│  │    /transcode  transcoder scratch (RECOMMENDED)            │  │
│  │    /data       media bind-mount (REQUIRED)                 │  │
│  │                                                            │  │
│  │  Ports:  32400/tcp, 8324/tcp, 32469/tcp,                   │  │
│  │          1900/udp, 32410-32414/udp                         │  │
│  │                                                            │  │
│  │  Healthcheck: GET /identity, 5s interval / 2s timeout      │  │
│  └────────────────────────────────────────────────────────────┘  │
│                                                                  │
│  Optional passthrough: /dev/dri (Intel QSV) | /dev/dvb (tuner)   │
│  Optional: --gpus all + nvidia-container-toolkit (NVIDIA)        │
└──────────────────────────────────────────────────────────────────┘

Component Inventory

Component Path in repo Role
Dockerfile Dockerfile Multi-arch build (linux/amd64, linux/arm64, linux/arm/v7)
Healthcheck script root/healthcheck.sh curl http://localhost:32400/identity with 15s connect / 100s max
Install routine root/installBinary.sh Selects linux-x86_64, linux-aarch64, or linux-armv7hf_neon based on TARGETARCH/TARGETPLATFORM; downloads + installs the PMS .deb
Shared shell library root/plex-common.sh Functions: addVarToConf, readVarFromConf, getVersionInfo, installFromUrl, installFromRawUrl
Service control wrapper root/plex_service.sh s6-svc wrapper accepting -u (up), -d (down), -r (restart)
First-run init root/etc/cont-init.d/40-plex-first-run UID/GID, claim-token exchange, Preferences seeding
HW/tuner init root/etc/cont-init.d/45-plex-hw-transcode-and-connected-tuner Adds plex user to GIDs of /dev/dri and /dev/dvb devices
Update init root/etc/cont-init.d/50-plex-update On public/beta tags, refreshes the PMS binary
Service runner root/etc/services.d/plex/run Launches Plex Media Server binary under s6-setuidgid
Service finish root/etc/services.d/plex/finish Graceful + forced shutdown
Service timeout root/etc/services.d/plex/timeout-finish 8000ms kill timeout
Compose templates docker-compose-{host,bridge,macvlan}.yml.template Reference Compose stacks for each networking mode
unRAID template plex-unRAID.xml Community-app definition (host networking, PUID 99 / PGID 100, three volumes)
Helm chart charts/plex-media-server/ Chart 1.5.0, app 1.43.0, single-replica StatefulSet

Technology Stack

Layer Technology
Base image ubuntu:24.04
Process supervisor s6-overlay
Package format .deb (dpkg -i --force-confold --force-architecture)
Configuration store Preferences.xml (manipulated with xmlstarlet)
Init scripts Bash with #!/usr/bin/with-contenv bash (s6 env injection)
Healthcheck curl against /identity
Kubernetes packaging Helm v3 chart
Optional cloud media rclone sidecar (Helm only)

Note

The image deliberately does not bundle any orchestration logic beyond s6 supervision. There is no built-in metrics endpoint, no Prometheus exporter, no log shipper — by design. The image leaves those concerns to the host or the Kubernetes platform.


2. System Requirements

Resource Docker (single host) Kubernetes (Helm)
CPU 1+ core (2-4 for transcoding) pms.resources.requests/limits
RAM 2 GB Configurable (4 GB+ recommended for transcoding)
/config disk 2 GB minimum, 50-200 GB typical pms.configStorage (default 2Gi, almost certainly too small)
/transcode disk 10 GB+ on fast local storage emptyDir (ephemeral) or extra PVC
/data disk Sized to your media library Bind-mount or NFS PV
Container runtime Docker 20.10+ containerd / CRI-O / Docker
Architectures linux/amd64, linux/arm64, linux/arm/v7 Same — manifest is multi-arch
Helm n/a v3.x
GPU (optional) Intel iGPU via --device=/dev/dri, NVIDIA via --gpus all NVIDIA via pms.gpu.nvidia.enabled=true
Network reach Outbound HTTPS to plex.tv (claim, updates) Same

Important

The PMS database is SQLite. SQLite requires byte-range POSIX locking. The /config filesystem must be local block storage — ext4, XFS, ZFS, btrfs are all fine. NFS, SMB/CIFS, 9PFS, FUSE, and Windows Docker Desktop bind mounts are not. This is a hard correctness requirement, not a preference.


3. Configuration Reference

Dockerfile Build Args

Arg Default What it controls
TAG beta Which PMS release to install. Use public, beta, or an explicit version (1.43.0.10492-121068a07).
URL (unset) Direct URL to a .deb. When set, overrides TAG resolution entirely.
PLEX_DISTRO debian Distribution identifier sent to Plex's download API.
S6_OVERLAY_VERSION (set in Dockerfile) s6-overlay release pinned at build time.
TARGETARCH (BuildKit-injected) amd64 or arm64.
TARGETPLATFORM (BuildKit-injected) Full platform string; linux/arm/v7 selects linux-armv7hf_neon.

Runtime Environment Variables

This is the complete set the container's init scripts and service runner honor. Anything outside this table is not consumed by this image.

Variable When Default What it does
TZ Every start (unset) Container timezone.
PLEX_CLAIM First run (unset) Claim token from plex.tv/claim. Exchanged for a permanent server token via POST https://plex.tv/api/claim/exchange?token={…}.
PLEX_CLAIM_FILE First run (unset) Path inside the container to a file holding the claim token (alternative to PLEX_CLAIM).
ADVERTISE_IP First run (unset) URL(s) PMS should advertise in Preferences.xml as customConnections. Required for bridge networking.
ALLOWED_NETWORKS First run (unset) CIDRs allowed to access PMS without authentication. Written as allowedNetworks.
PLEX_UID Every start (image default) Numeric UID applied to the in-container plex user via usermod -o -u.
PLEX_GID Every start (image default) Numeric GID applied to the plex group via groupmod -o -g.
CHANGE_CONFIG_DIR_OWNERSHIP Every start true When true, runs chown -h plex:plex over /config and /transcode.
HOSTNAME Every start (auto) Container hostname. Most users set this with -h instead.
PLEX_UPDATE_CHANNEL Every start (auto) Override the channel for public/beta updates: 8 = beta, 16 = public.
DEBUG Every start false When true, init scripts run with set -x for shell tracing.

Important

The "First run" variables (PLEX_CLAIM, PLEX_CLAIM_FILE, ADVERTISE_IP, ALLOWED_NETWORKS) are consumed by cont-init.d/40-plex-first-run only on the first boot (or before the corresponding Preferences.xml keys are set). Leaving them in your compose file after first run is harmless but ineffective — to change advertised IP or allowed networks later, edit Preferences.xml directly or use the web UI.

Volumes

Mount Required Purpose
/config Yes PMS database, preferences, plugins, metadata. Path inside is Library/Application Support/Plex Media Server/.
/transcode Recommended Transcoder scratch space. Mount on fast local disk or tmpfs.
/data Yes Where you bind-mount your media. Multiple mounts under /data/movies, /data/tv, etc. are common.

Exposed Ports

TCP   32400  Web UI / API (REQUIRED)
TCP    8324  Roku Plex Companion control
TCP   32469  DLNA server
UDP    1900  DLNA / SSDP discovery
UDP   32410  GDM (Plex auto-discovery)
UDP   32412  GDM
UDP   32413  GDM
UDP   32414  GDM

Helm Chart Values Reference

The chart at charts/plex-media-server is the supported way to run on Kubernetes. Below is the complete reference, keyed by the file's documented sections.

Image

Key Type Default Description
image.registry string index.docker.io Docker registry
image.repository string plexinc/pms-docker Image repository
image.tag string 1.43.0.10492-121068a07 Image tag
image.sha string "" Optional digest pin
image.pullPolicy string IfNotPresent Pull policy
global.imageRegistry string "" Override for parent-chart embedding
imagePullSecrets list [] Image pull secrets

PMS

Key Type Default Description
pms.storageClassName string null StorageClass for the config PVC; null uses cluster default
pms.configStorage string 2Gi Size of the config PVC
pms.configExistingClaim string "" Use an existing PVC (skips the chart-managed PVC)
pms.claimSecret.name string "" Secret holding the PLEX_CLAIM token
pms.claimSecret.key string "" Key inside the secret
pms.resources object {} CPU/memory requests + limits
pms.securityContext object {} Pod security context
pms.shareProcessNamespace bool false Share process namespace across containers in the pod
pms.livenessProbe object {} Liveness probe (recommended: httpGet /identity on 32400)
pms.readinessProbe object {} Readiness probe (same shape)

GPU (NVIDIA)

Key Type Default Description
pms.gpu.nvidia.enabled bool false Enable NVIDIA GPU support
pms.gpu.nvidia.devices string "all" "all", comma list of indices, or "GPU-uuid"
pms.gpu.nvidia.capabilities string "compute,video,utility" Driver capabilities

When enabled=true, the chart adds nvidia.com/gpu: 1 to resource limits and sets NVIDIA_VISIBLE_DEVICES and NVIDIA_DRIVER_CAPABILITIES.

Networking

Key Type Default Description
hostNetwork bool false Run pod in the host network namespace
dnsConfig object {} Custom DNS config
dnsPolicy string "" ClusterFirst, ClusterFirstWithHostNet, etc.
service.type string ClusterIP ClusterIP, NodePort, LoadBalancer
service.port int 32400 Service port
service.annotations object {} Service annotations

Ingress

Key Type Default Description
ingress.enabled bool false Create an Ingress
ingress.ingressClassName string "ingress-nginx" Ingress class
ingress.url string "" Hostname for the Ingress rule
ingress.tls list [] TLS hosts/secretName
ingress.annotations object {} Ingress annotations

HTTPRoute (Gateway API)

Key Type Default Description
httpRoute.enabled bool false Create an HTTPRoute
httpRoute.parentRefs list [] Gateway parent references
httpRoute.hostnames list [] Hostnames to match
httpRoute.annotations object {} HTTPRoute annotations

rclone sidecar

Key Type Default Description
rclone.enabled bool false Enable the rclone sidecar
rclone.image.repository string rclone/rclone Image
rclone.image.tag string 1.70.3 Tag
rclone.configSecret string "" Secret holding rclone.conf and any keys
rclone.remotes list [] List of name:[/path] mounts at /data/{name}
rclone.readOnly bool true Mount remotes read-only
rclone.additionalArgs list [] Extra rclone mount flags

Init Container

Key Type Default Description
initContainer.image.repository string alpine Init container image
initContainer.image.tag string "3.22" Init container image tag
initContainer.script string "" Custom init script (mounted via ConfigMap)

Scheduling, Service Account, Workload Metadata

Key Type Default Description
nodeSelector object {} Pod node selector
tolerations list [] Tolerations
affinity object {} Affinity rules
priorityClassName string "" Priority class
runtimeClassName string "" Runtime class (e.g., for GPU)
commonLabels object {} Labels applied to every chart resource
nameOverride string "" Override chart name
fullnameOverride string "" Override fullname
extraEnv object {} Extra environment variables
extraVolumes list [] Extra pod volumes
extraVolumeMounts list [] Extra mounts in PMS + init containers
extraContainers list [] Extra sidecar containers
extraInitContainers object {} Extra init containers
serviceAccount.create bool true Create a ServiceAccount
serviceAccount.automountServiceAccountToken bool false Auto-mount the API token
serviceAccount.annotations object {} Service account annotations
serviceAccount.name string "" Override service account name
statefulSet.annotations object {} StatefulSet annotations
statefulSet.podAnnotations object {} Pod annotations

4. Deployment & Scaling

Single-Host Docker Deployment

The repo's three Compose templates are the supported deployment shape on a single Docker host. Pick one based on your networking constraint (see User Guide section 4 for the trade-offs) and edit the placeholders.

cp docker-compose-host.yml.template docker-compose.yml
# fill in PLEX_CLAIM, paths, TZ
docker compose up -d

Multi-Arch Image Builds

The Dockerfile is BuildKit-aware. To rebuild for ARM:

docker buildx build \
  --platform linux/arm64 \
  --build-arg TAG=public \
  -t plex-arm64:custom .

To install from a specific .deb URL (skipping the API lookup entirely):

docker buildx build \
  --build-arg URL=https://downloads.plex.tv/.../plexmediaserver_..._amd64.deb \
  -t plex-pinned:custom .

Kubernetes via Helm

A production-shape values file:

# values-production.yaml
image:
  tag: "1.43.0.10492-121068a07"

pms:
  storageClassName: "fast-ssd"
  configStorage: 50Gi
  resources:
    requests: { cpu: "1",   memory: 2Gi }
    limits:   { cpu: "4",   memory: 8Gi }
  livenessProbe:
    httpGet: { path: /identity, port: 32400 }
    initialDelaySeconds: 60
    periodSeconds: 60
    timeoutSeconds: 1
    failureThreshold: 3
  readinessProbe:
    httpGet: { path: /identity, port: 32400 }
    initialDelaySeconds: 60
    periodSeconds: 60

ingress:
  enabled: true
  ingressClassName: "nginx"
  url: "plex.example.com"
  tls:
    - hosts: ["plex.example.com"]
      secretName: plex-tls

extraEnv:
  TZ: "America/New_York"
  PLEX_UID: "1000"
  PLEX_GID: "1000"
helm upgrade --install plex plex/plex-media-server \
  --namespace plex --create-namespace \
  --values values-production.yaml

Gateway API (HTTPRoute) Alternative

If your cluster runs a Gateway API implementation (Cilium Gateway, Istio Gateway, Envoy Gateway) instead of an Ingress controller:

httpRoute:
  enabled: true
  parentRefs:
    - name: my-gateway
      namespace: gateway-system
      sectionName: https
  hostnames:
    - plex.example.com

Database Migration via initContainer.script

The chart's init-container hook is the supported way to seed an existing PMS database into a fresh PVC. Because it runs on every pod start, the script must be idempotent.

initContainer:
  script: |-
    #!/bin/sh
    if [ -d "/config/Library" ]; then
      echo "PMS library already exists, exiting."
      exit 0
    fi
    apk --no-cache add curl
    curl -fSL http://internal-storage.example.com/pms-backup.tgz \
      -o /tmp/pms.tgz
    tar -xzf /tmp/pms.tgz -C /config
    rm /tmp/pms.tgz

Manual upload (no internal HTTP server available):

initContainer:
  script: |-
    #!/bin/sh
    if [ -d "/config/Library" ]; then
      echo "PMS library already exists, exiting."
      exit 0
    fi
    while [ ! -f /pms.tgz ]; do sleep 2; done
    tar -xzf /pms.tgz -C /config
    rm /pms.tgz
kubectl cp pms.tgz plex/<pod>:/pms.tgz.up -c <release>-pms-init
kubectl exec -n plex <pod> -c <release>-pms-init -- mv /pms.tgz.up /pms.tgz

Helm Chart Upgrade Notes

  • The chart is single-replica by design (StatefulSet with 1 replica). Do not scale to 2+ — SQLite database corruption is the near-immediate consequence.
  • Older chart versions used a certificateSecret field; newer ones use the tls block under ingress. See UPGRADE.md in the chart for the migration diff.

Note

Cluster-level scaling for Plex is vertical only. To handle more concurrent streams, give the pod more CPU/memory and faster transcode storage — never add replicas.


5. Operations

Container Lifecycle

The boot sequence inside the container, in order:

  1. /init (s6-overlay) becomes PID 1.
  2. cont-init.d/ scripts run sequentially:
    • 40-plex-first-run — apply PLEX_UID/PLEX_GID, optionally re-chown /config//transcode, seed Preferences.xml, exchange the claim token.
    • 45-plex-hw-transcode-and-connected-tuner — for each /dev/dri and /dev/dvb character device, ensure plex is in the device's group.
    • 50-plex-update — only when the image was built with TAG=public or TAG=beta; queries plex.tv and dpkg -i the latest .deb.
  3. services.d/plex/run starts the Plex Media Server binary under s6-setuidgid plex.
  4. The healthcheck begins polling /identity.
  5. On stop, services.d/plex/finish issues SIGTERM, then SIGKILL after the 8000ms timeout in timeout-finish.

Healthcheck

HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
  CMD /healthcheck.sh || exit 1

/healthcheck.sh:

#!/bin/sh -e
TARGET=localhost
CURL_OPTS="--connect-timeout 15 --max-time 100 --silent --show-error --fail"
curl ${CURL_OPTS} "http://${TARGET}:32400/identity" >/dev/null

Service Control

The /plex_service.sh wrapper around s6-svc is intended for in-container manual control:

docker exec plex /plex_service.sh -u   # up (start)
docker exec plex /plex_service.sh -d   # down (stop)
docker exec plex /plex_service.sh -r   # restart

Source:

if [ "$#" -eq 1 ]; then
  s6-svc "$1" /var/run/s6/services/plex
else
  echo "No argument supplied; must be -u, -d, or -r."
fi

Logging

PMS logs are streamed through s6's logging pipeline to the container's stdout. Read with the standard tooling:

docker logs -f plex                                      # single host
kubectl logs -n plex -l app.kubernetes.io/name=plex-media-server -f

Key log lines to watch:

Line Source script Meaning
Attempting to obtain server token from claim token 40-plex-first-run Claim handshake started
Token obtained successfully 40-plex-first-run Claim succeeded
Plex Media Server first run setup complete 40-plex-first-run First-run init done
Attempting to install: <ver> installBinary.sh Install in progress (build-time or update)
Could not get install version installBinary.sh Update API failed
Failed to fetch update installBinary.sh Download truncated or curl error
Starting Plex Media Server. services.d/plex/run Daemon launched

Backup and Restore

# backup
docker stop plex
tar -czpf "plex-config-$(date +%F).tar.gz" -C /opt/plex/config .
docker start plex
# restore
docker stop plex
rm -rf /opt/plex/config/*
tar -xzpf plex-config-2026-04-29.tar.gz -C /opt/plex/config
docker start plex

Caution

Always stop the container before tarring /config. SQLite WAL files copied while in use can produce backups that appear to restore correctly but expose silent corruption days later.

Kubernetes Operations

# pod health
kubectl get pods -n plex -l app.kubernetes.io/name=plex-media-server

# logs
kubectl logs -n plex -l app.kubernetes.io/name=plex-media-server -f

# shell
kubectl exec -it -n plex <pod> -c <release>-pms -- /bin/bash

# restart (rollout)
kubectl rollout restart statefulset/<release>-plex-media-server -n plex

Maintenance Checklist

  • [ ] /config lives on local block storage (ext4/XFS/ZFS), not NFS/SMB
  • [ ] /transcode mounted (host disk or tmpfs)
  • [ ] PLEX_UID/PLEX_GID match the host owner of /data
  • [ ] TZ set to the host's local timezone
  • [ ] Backup automation in place (cron job that stops, tars, starts)
  • [ ] Healthcheck observed (healthy) after first start
  • [ ] If on bridge networking: ADVERTISE_IP set, 32400 forwarded
  • [ ] If on the beta/public tag: outbound HTTPS to plex.tv reliably reachable
  • [ ] If on Kubernetes: PVC sized realistically (not the 2Gi default)

6. Performance

Transcoding

Workload CPU/GPU Notes
Direct play (no transcode) Near-zero The vast majority of Plex traffic on capable clients
Software transcode, 1080p ~1 core per stream Plex CPU costs scale roughly linearly per concurrent stream
Software transcode, 4K HEVC 2-4 cores per stream Most CPUs cannot software-transcode 4K HEVC at all
Intel QSV (/dev/dri) Negligible CPU A modest 8th-gen+ Intel iGPU handles 5-10 concurrent 4K transcodes
NVIDIA NVENC (--gpus all) Negligible CPU Higher-end GPUs hit the unpatched session limit (driver-side)

/transcode Storage

/transcode sees high write throughput while transcoding. Mount it on:

  • Local SSD or NVMe (best general choice)
  • tmpfs (--tmpfs /transcode:size=8g) for very high concurrency on systems with abundant RAM
  • A dedicated partition; do not put it on the same disk as /config if you can avoid it (transcode I/O contends with database I/O)

/config Storage

The Plex database is SQLite. The /config volume must:

  • Support file locking (excludes most network filesystems)
  • Provide low-latency random I/O (SSD-class for libraries with 100k+ items)
  • Sustain bursty writes during scans and metadata refresh

Warning

Putting /config on NFS/SMB/9PFS will corrupt the database. This is the single most common operational failure mode reported by the Plex community.

Kubernetes Resource Sizing

Workload CPU req CPU lim Mem req Mem lim
Small library, no transcode 250m 1 512Mi 2Gi
Medium library, occasional transcode 1 4 1Gi 4Gi
Large library, multi-stream 2 8 2Gi 8Gi
HW-transcode (NVIDIA) 500m 2 1Gi 4Gi

Tip

Memory limits matter for libraries with many items: PMS holds the active analyzer/scanner state in memory. If you observe OOM kills during scans, double the limit before chasing other causes.


7. Security

Process Isolation

  • PMS runs as the in-container plex user (non-root) via s6-setuidgid plex in services.d/plex/run.
  • PLEX_UID/PLEX_GID let you align that user to the host owner of your media — no need to give Plex root.
  • The Helm chart sets serviceAccount.automountServiceAccountToken: false by default.

Port Surface

The image exposes nine ports. Only 32400/tcp is required for operation; the rest are LAN-discovery and DLNA. Trim what you publish:

# bridge with only the essentials
docker run ... \
  -p 32400:32400/tcp \
  plexinc/pms-docker

Warning

--network=host exposes every port the container binds on the host — not only the documented Plex ports. If your host has other services binding ephemeral ports, host networking can collide with them. Use bridge networking with explicit -p mappings on shared-purpose hosts.

Claim Token Handling

The first-run claim handshake is:

POST https://plex.tv/api/claim/exchange?token={PLEX_CLAIM}
Headers:
  X-Plex-Client-Identifier: {ProcessedMachineIdentifier}
  X-Plex-Product: Plex Media Server
  X-Plex-Provides: server

The returned authentication-token is written to Preferences.xml as PlexOnlineToken. The claim token itself is not persisted.

Important

Treat PLEX_CLAIM as a short-lived secret. Don't bake it into a Docker image, don't commit it to git, and don't echo it in CI logs. Once the server is claimed, it's a no-op — but a logged token is still a logged credential.

Network-Level Auth Bypass

ALLOWED_NETWORKS instructs PMS to accept unauthenticated connections from the listed CIDRs.

Caution

Only set ALLOWED_NETWORKS for trusted local networks. Setting 0.0.0.0/0 (as some bad guides recommend) disables Plex authentication entirely and exposes your library publicly. If you need that, you almost certainly want a real reverse proxy + auth layer instead.

Bridge-Mode Source IP

Under bridge networking, the Docker gateway IP (typically 172.17.0.1) appears as the source for all client connections. Set Settings > Server > Network > LAN Networks to your real LAN subnet so PMS applies LAN bandwidth rules to clients on it. If you do nothing, every client is treated as remote.

GPU Device Permissions

cont-init.d/45-plex-hw-transcode-and-connected-tuner enumerates character devices in /dev/dri and /dev/dvb, reads each device's GID, creates a matching group inside the container if one isn't present, and adds the plex user to that group via usermod -a -G. PMS therefore gets device access without running as root.

rclone Sidecar (Kubernetes)

When rclone.enabled=true, the rclone sidecar runs with securityContext.privileged: true and SYS_ADMIN capability — both required for FUSE mounts. This is non-trivial; restrict the namespace's pod-security policy accordingly and review whether read-only mounts (rclone.readOnly: true, the default) meet your needs before granting a writable cloud mount to the container.

Image Provenance

plexinc/pms-docker is published by Plex, Inc. directly. To verify the image you're pulling matches a known good digest, pin to a SHA digest in your compose:

image: plexinc/pms-docker@sha256:<digest>

Or in Helm:

image:
  tag: "1.43.0.10492-121068a07"
  sha: "sha256:<digest>"

8. Developer Internals

This is a deployment repository, not an application repository. The "developer internals" worth understanding are the image build, the init scripts, and the Helm chart structure.

Repository Layout

pms-docker/
├── Dockerfile                              # Multi-arch build
├── Makefile                                # Linting + chart docs
├── README.md                               # User-facing Docker usage doc
├── docker-compose-host.yml.template        # Host-network reference compose
├── docker-compose-bridge.yml.template      # Bridge-network reference compose
├── docker-compose-macvlan.yml.template     # Macvlan-network reference compose
├── plex-unRAID.xml                         # unRAID community-app template
├── root/                                   # Files copied to / in image
│   ├── healthcheck.sh                      # `curl /identity`
│   ├── installBinary.sh                    # Arch-aware .deb installer
│   ├── plex-common.sh                      # Shared shell functions
│   ├── plex_service.sh                     # s6-svc wrapper
│   └── etc/
│       ├── cont-init.d/
│       │   ├── 40-plex-first-run
│       │   ├── 45-plex-hw-transcode-and-connected-tuner
│       │   └── 50-plex-update
│       └── services.d/plex/
│           ├── run
│           ├── finish
│           └── timeout-finish
├── charts/plex-media-server/               # Helm chart
│   ├── Chart.yaml                          # v1.5.0, appVersion 1.43.0
│   ├── values.yaml                         # The full values surface
│   ├── README.md                           # helm-docs output
│   ├── README.md.gotmpl                    # helm-docs template
│   ├── UPGRADE.md                          # Chart upgrade notes
│   ├── LICENSE                             # GPLv3
│   ├── .helmignore                         # Helm packaging exclusions
│   ├── ci/ci-values.yaml                   # CI test values
│   └── templates/
│       ├── _helpers.tpl
│       ├── statefulset.yaml
│       ├── service.yaml
│       ├── ingress.yaml
│       ├── httproute.yaml
│       ├── serviceaccount.yaml
│       ├── configmap-init-script.yaml
│       └── NOTES.txt
└── img/plex-server.png                     # Plex logo

Shell Library: plex-common.sh

addVarToConf(variable, value) : Appends variable=value to /version.txt. Used at image build time to record the architecture (plex_build), distribution (plex_distro), and version (version) so the runtime update script can reproduce the same query.

readVarFromConf(variable, ref) : Reads a value from /version.txt. Uses bash namerefs (local -n) so the caller can pass an out-variable name.

getVersionInfo(version, token, remoteVersion_ref, remoteFile_ref) : Hits https://plex.tv/downloads/details/5 with build, channel (8 = beta, 16 = public), and distro parameters. Parses the returned XML with sed to extract version="..." and file="..." attributes. Channel selection logic:

  • If PLEX_UPDATE_CHANNEL is set and > 0, use it.
  • Else if the requested version is "beta" (case-insensitive), channel=8.
  • Else if "public", channel=16 (no token needed).
  • Else default to 8.

installFromUrl(relPath) and installFromRawUrl(fullUrl) : Download a .deb to /tmp/plexmediaserver.deb, validate it's at least 10 KB, then dpkg -i --force-confold --force-architecture. installFromUrl is the convenience wrapper that prepends https://plex.tv/.

Architecture Selection in installBinary.sh

if [ "${TARGETPLATFORM}" = 'linux/arm/v7' ]; then
  PLEX_BUILD='linux-armv7hf_neon'
elif [ "${TARGETARCH}" = 'amd64' ]; then
  PLEX_BUILD='linux-x86_64';
elif [ "${TARGETARCH}" = 'arm64' ]; then
  PLEX_BUILD='linux-aarch64' ;
fi

The PLEX_BUILD value is what the Plex download API returns binaries keyed against. Build-time decision; baked into /version.txt.

Helm Template Helpers (_helpers.tpl)

Helper Purpose
pms-chart.name Chart name, truncated to 63 chars (k8s name limit)
pms-chart.fullname Release-qualified resource name
pms-chart.chart name-version label
pms-chart.image Full PMS image reference, honoring image.sha and global.imageRegistry
pms-chart.init_image Init-container image reference
pms-chart.rclone_image rclone image reference
pms-chart.labels Standard Kubernetes labels (app, chart, version, managed-by) plus commonLabels
pms-chart.selectorLabels Immutable selector labels (name + instance)
pms-chart.serviceAccountName Effective service account name

What the Chart Creates

Resource Always? Notes
StatefulSet Yes Single replica
Service Yes Default ClusterIP; NodePort/LoadBalancer opt-in
PersistentVolumeClaim Unless pms.configExistingClaim set For /config
ServiceAccount If serviceAccount.create=true Auto-mount disabled
Ingress If ingress.enabled=true nginx by default
HTTPRoute If httpRoute.enabled=true Gateway API v1
ConfigMap If initContainer.script set Holds the init script

The StatefulSet has terminationGracePeriodSeconds: 120, leaving room for PMS to flush before s6's 8s timeout-finish kills the daemon.

Makefile Targets

Target Command What it does
lint ct lint + yamllint Run all linters
lint-charts ct lint --config .github/linters/ct.yaml Helm chart-testing lint
lint-docs helm-docs --documentation-strict-mode Validate chart README freshness
lint-yaml yamllint --config-file .yamllint YAML syntax
lint-kubeconform helm template … | kubeconform Validate rendered manifests against k8s schemas
docs helm-docs charts/plex-media-server Regenerate chart README from values.yaml comments

Contributing

The chart README is auto-generated from values.yaml comments via helm-docs. Do not edit charts/plex-media-server/README.md directly — edit the comments above each value in values.yaml, then run make docs. Chart and image source live under GPLv3. Contributors must read CODE_OF_CONDUCT.md before opening a PR.

Note

The image's primary "internal" surface is shell. Anything beyond minor init-script tweaks usually belongs upstream in the Plex Media Server .deb itself, which is closed-source and lives outside this repo.


9. Integration Points

Plex.tv (Outbound, Required)

The container reaches out to Plex's infrastructure at two well-defined points:

Endpoint When Purpose
POST https://plex.tv/api/claim/exchange?token=… First run, when PLEX_CLAIM/PLEX_CLAIM_FILE is set Exchange short-lived claim token for permanent server token
GET https://plex.tv/downloads/details/5?build=…&channel=…&distro=… At image build time and at every container start when TAG=public or TAG=beta Look up the latest .deb URL for the configured architecture and channel

If your environment requires explicit egress allowlists, allow HTTPS to plex.tv and *.plex.tv.

rclone Sidecar (Helm Only)

When rclone.enabled=true:

  • The chart copies rclone.conf (and any extra keys/secrets) from the named secret into an emptyDir at /etc/rclone/.
  • For each entry in rclone.remotes, an additional sidecar runs rclone mount {remote} /data/{name} --config=/etc/rclone/rclone.conf --allow-non-empty --allow-other plus any rclone.additionalArgs.
  • The shared /data volume uses mountPropagation: Bidirectional on the rclone side and HostToContainer on the PMS side, so PMS sees the FUSE mount.
  • Each rclone container has a preStop lifecycle hook that runs fusermount3 -uz /data/{name} to unmount cleanly on pod shutdown.

unRAID Integration

plex-unRAID.xml is the official Community-Apps definition. It:

  • Sets Network: host
  • Maps /config, /transcode, /data (default unRAID share paths)
  • Pre-populates PLEX_CLAIM, PLEX_UID=99, PLEX_GID=100 (the unRAID nobody/users ids), and VERSION
  • Categorizes the app under MediaServer:Video, MediaServer:Music, MediaServer:Photos
  • Points the WebUI link at http://[IP]:[PORT:32400]/web

The chart leaves livenessProbe and readinessProbe empty by default because PMS first-run can take longer than typical probe defaults (claim handshake + database init). The recommended pattern in production:

pms:
  livenessProbe:
    httpGet: { path: /identity, port: 32400 }
    initialDelaySeconds: 60
    periodSeconds: 60
    timeoutSeconds: 5
    failureThreshold: 3
  readinessProbe:
    httpGet: { path: /identity, port: 32400 }
    initialDelaySeconds: 60
    periodSeconds: 60
    timeoutSeconds: 5
    failureThreshold: 3

This mirrors the Docker-side HEALTHCHECK semantics while giving the container 60 seconds for first-run setup before probes start failing.

Reverse Proxy Front Ends

The Helm chart's ingress and httpRoute blocks are the supported hooks for putting a reverse proxy in front. Common patterns:

  • nginx Ingress (default class ingress-nginx): just set ingress.enabled: true and ingress.url.
  • Cloudflare Tunnel / Tailscale Funnel: run a sidecar / daemonset and point the cluster's external DNS at the tunnel endpoint instead of the Service.
  • Tailscale Operator: create a Service of type LoadBalancer with a tailscale.com/expose: "true" annotation (managed at the Service-annotation layer, set via service.annotations).

Note

Plex strongly prefers handling its own TLS for direct client traffic via plex.tv-issued certificates (*.<hash>.plex.direct). Putting your own reverse proxy in front works for the web UI but may cause direct-connect clients to fall back to relayed connections. Most home setups still benefit from leaving the 32400 LAN path direct and only proxying for external WAN access.