Operational reference for the official
plexinc/pms-dockerimage. 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.debpackage 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
Dockerfilethat 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(chart1.5.0, app1.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
/configfilesystem 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 bycont-init.d/40-plex-first-runonly on the first boot (or before the correspondingPreferences.xmlkeys are set). Leaving them in your compose file after first run is harmless but ineffective — to change advertised IP or allowed networks later, editPreferences.xmldirectly 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
certificateSecretfield; newer ones use thetlsblock underingress. SeeUPGRADE.mdin 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:
/init(s6-overlay) becomes PID 1.cont-init.d/scripts run sequentially:40-plex-first-run— applyPLEX_UID/PLEX_GID, optionally re-chown/config//transcode, seedPreferences.xml, exchange the claim token.45-plex-hw-transcode-and-connected-tuner— for each/dev/driand/dev/dvbcharacter device, ensureplexis in the device's group.50-plex-update— only when the image was built withTAG=publicorTAG=beta; queriesplex.tvanddpkg -ithe latest.deb.
services.d/plex/runstarts thePlex Media Serverbinary unders6-setuidgid plex.- The healthcheck begins polling
/identity. - On stop,
services.d/plex/finishissues SIGTERM, then SIGKILL after the8000mstimeout intimeout-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
- [ ]
/configlives on local block storage (ext4/XFS/ZFS), not NFS/SMB - [ ]
/transcodemounted (host disk ortmpfs) - [ ]
PLEX_UID/PLEX_GIDmatch the host owner of/data - [ ]
TZset 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_IPset,32400forwarded - [ ] If on the
beta/publictag: outbound HTTPS toplex.tvreliably reachable - [ ] If on Kubernetes: PVC sized realistically (not the
2Gidefault)
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
/configif 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
/configon 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
plexuser (non-root) vias6-setuidgid plexinservices.d/plex/run. PLEX_UID/PLEX_GIDlet you align that user to the host owner of your media — no need to give Plex root.- The Helm chart sets
serviceAccount.automountServiceAccountToken: falseby 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=hostexposes 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-pmappings 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_CLAIMas 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_NETWORKSfor trusted local networks. Setting0.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_CHANNELis 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
.debitself, 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 anemptyDirat/etc/rclone/. - For each entry in
rclone.remotes, an additional sidecar runsrclone mount {remote} /data/{name} --config=/etc/rclone/rclone.conf --allow-non-empty --allow-otherplus anyrclone.additionalArgs. - The shared
/datavolume usesmountPropagation: Bidirectionalon the rclone side andHostToContaineron the PMS side, so PMS sees the FUSE mount. - Each rclone container has a
preStoplifecycle hook that runsfusermount3 -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 unRAIDnobody/usersids), andVERSION - Categorizes the app under MediaServer:Video, MediaServer:Music, MediaServer:Photos
- Points the WebUI link at
http://[IP]:[PORT:32400]/web
Kubernetes Probes (Recommended Pattern)
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 setingress.enabled: trueandingress.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
Serviceof typeLoadBalancerwith atailscale.com/expose: "true"annotation (managed at the Service-annotation layer, set viaservice.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.