# Plex Media Server Docker Administrator & Developer Guide

> **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

```text
┌──────────────────────────────────────────────────────────────────┐
│  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

```text
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.

```bash
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:

```bash
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):

```bash
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:

```yaml
# 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"
```

```bash
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:

```yaml
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.

```yaml
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):

```yaml
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
```

```bash
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

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

`/healthcheck.sh`:

```bash
#!/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:

```bash
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:

```bash
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:

```bash
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

```bash
# backup
docker stop plex
tar -czpf "plex-config-$(date +%F).tar.gz" -C /opt/plex/config .
docker start plex
```

```bash
# 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

```bash
# 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:

```bash
# 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:

```text
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:

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

Or in Helm:

```yaml
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

```text
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`

```bash
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`

### 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:

```yaml
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.

---

_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. Unofficial; not affiliated with or reviewed by the project._
