Migrate Plex from ESXi VM to Docker container

Plex ran on a dedicated Debian 11 VM at 10.100.0.24 until that OS reached
LTS EOL on 2026-08-31 and apt security updates started 404ing. It now runs
as a host-networked container on the Docker host at 10.100.0.35.

Adds plex-docker-migration.md: the decision record and the executed
procedure. The two choices worth knowing about are that media is bind
mounted at the paths the library DB already stores (/mnt/nfs/*) so the
migration rewrote none of its 32,262 absolute paths, and that the
container runs on host networking so Plex still sees real client source
IPs and does not throttle LAN playback as remote.

Preferences.xml was carried across, so the server kept machine identifier
7aa65063599874eac3d9a2f62df74c1816400c88 and Tautulli, seerr and existing
clients continued working without reconfiguration. Verified after cutover:
libraries 1959/421/146 and 11,481 history entries, all matching the
pre-migration baseline exactly.

Rewrites plex.md for the container, adds plex to docker.md, and updates
the component list in CLAUDE.md. The old VM is retained powered on with
plexmediaserver stopped and disabled as rollback, so docs for ESXi and
Veeam are deliberately left describing it until decommission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
dohertj2
2026-09-16 08:55:43 -04:00
parent 8069f21240
commit 91ec7d1762
4 changed files with 609 additions and 50 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ Credentials live inline in each component's `.md` file under its **Access** sect
- [TrueNAS Server](truenas.md) — NAS at 10.100.0.25 (management) / 10.50.0.x (storage), serves SMB and NFS - [TrueNAS Server](truenas.md) — NAS at 10.100.0.25 (management) / 10.50.0.x (storage), serves SMB and NFS
- [ESXi Host](esxi.md) — HP server at 10.2.0.12, ESXi 8.0.3, 8 VMs (7 powered on), access via `govc` only (not SSH). **Read esxi.md for credentials and connection details.** - [ESXi Host](esxi.md) — HP server at 10.2.0.12, ESXi 8.0.3, 8 VMs (7 powered on), access via `govc` only (not SSH). **Read esxi.md for credentials and connection details.**
- [Docker Server](docker.md) — Debian 13 at 10.100.0.35, Docker host for 21 containers (arr stack, Traefik, Semaphore/Ansible). **This is the local machine.** - [Docker Server](docker.md) — Debian 13 at 10.100.0.35, Docker host for 21 containers (arr stack, Traefik, Semaphore/Ansible). **This is the local machine.**
- [Plex Server](plex.md) — Debian 11 at 10.100.0.24, Plex Media Server 1.43, NFS media from TrueNAS - [Plex Server](plex.md) — **container on the Docker host (10.100.0.35)** since 2026-09-16, Plex Media Server 1.43, NFS media from TrueNAS. Old Debian 11 VM at 10.100.0.24 retained powered-on as rollback.
- [Home Assistant](ha.md) — HAOS at 10.100.0.40, HA 2026.2.2, 2,411 entities, smart home automation. API access only (no SSH). - [Home Assistant](ha.md) — HAOS at 10.100.0.40, HA 2026.2.2, 2,411 entities, smart home automation. API access only (no SSH).
- [Ignition Gateway](ignition.md) — Debian 11 at 10.100.0.90, Ignition 8.3.3 (Maker), SCADA/IIoT platform with PostgreSQL 13 - [Ignition Gateway](ignition.md) — Debian 11 at 10.100.0.90, Ignition 8.3.3 (Maker), SCADA/IIoT platform with PostgreSQL 13
- [Veeam Server](veeam.md) — Windows Server 2025 at 10.100.0.30, Veeam B&R 12.3, backup server. **Read veeam.md for credentials and connection details.** - [Veeam Server](veeam.md) — Windows Server 2025 at 10.100.0.30, Veeam B&R 12.3, backup server. **Read veeam.md for credentials and connection details.**
+2
View File
@@ -75,6 +75,7 @@ All 21 containers running. Most are on the `traefik` network.
| prowlarr | lscr.io/linuxserver/prowlarr:latest | prowlarr.dohertylan.com | 9696 | Indexer manager | | prowlarr | lscr.io/linuxserver/prowlarr:latest | prowlarr.dohertylan.com | 9696 | Indexer manager |
| seerr | ghcr.io/seerr-team/seerr:latest | requests.dohertylan.com | 5055 | Media request UI | | seerr | ghcr.io/seerr-team/seerr:latest | requests.dohertylan.com | 5055 | Media request UI |
| profilarr | santiagosayshey/profilarr:latest | profilarr.dohertylan.com | 6868 | Quality profile sync | | profilarr | santiagosayshey/profilarr:latest | profilarr.dohertylan.com | 6868 | Quality profile sync |
| plex | lscr.io/linuxserver/plex:latest | plex.dohertylan.com | 32400 (host net) | Media server — see [plex.md](plex.md) |
| tautulli | ghcr.io/tautulli/tautulli:latest | tautulli.dohertylan.com | 8181 | Plex analytics | | tautulli | ghcr.io/tautulli/tautulli:latest | tautulli.dohertylan.com | 8181 | Plex analytics |
### Downloads ### Downloads
@@ -137,6 +138,7 @@ All compose files are under `/opt/<service>/docker-compose.yml`:
/opt/tautulli/ /opt/nzbget/ /opt/vpn_downloads/ /opt/tautulli/ /opt/nzbget/ /opt/vpn_downloads/
/opt/uptime_kuma/ /opt/stash/ /opt/utilities/ /opt/uptime_kuma/ /opt/stash/ /opt/utilities/
/opt/semaphore/ /opt/ilo_fan/ /opt/semaphore/ /opt/ilo_fan/
/opt/plex/
/opt/otopcua-mssql/ /opt/otopcua-modbus/ /opt/otopcua-abcip/ /opt/otopcua-mssql/ /opt/otopcua-modbus/ /opt/otopcua-abcip/
/opt/otopcua-s7/ /opt/otopcua-opcuaclient/ /opt/otopcua-s7/ /opt/otopcua-opcuaclient/
``` ```
+565
View File
@@ -0,0 +1,565 @@
# Plex Migration: ESXi VM → Docker Container
**Status: EXECUTED 2026-09-16.** Decisions 1 & 2 ratified as Option A / Option A. Cutover completed with ~22 min of Plex downtime (transfer itself 5m38s). All verification in §8 passed except the two items needing a real client, noted there. The old VM is powered on with `plexmediaserver` stopped+disabled; §9 decommission is **not** done.
**Created:** 2026-09-16
**Source:** `plex` VM, 10.100.0.24, Debian 11 (EOL)
**Target:** `DOCKER` host, 10.100.0.35, Debian 13, containerised + Ansible-managed
---
## 1. Why
- **Debian 11 hit LTS EOL on 2026-08-31.** `security.debian.org` bullseye-security now 404s; the VM receives no further OS security patches (see [plex.md](plex.md)). Fixing that means either a Bookworm release-upgrade or a move off the VM.
- Containerising puts Plex under the same lifecycle as the other 21 services: one Ansible role, `update.yml` handles image updates, Semaphore runs it.
- Reclaims an 8-vCPU / 7.8 GB / 125 GB VM from the ESXi host.
- Plex has no Plex Pass (`PlexOnlineHome="0"`, no `HardwareAcceleratedCodecs` pref) so all transcoding is software. Moving from 8 vCPUs to the docker host's 22 vCPUs is a **transcode capacity gain**, and neither host has usable VAAPI anyway — both are VMware SVGA (`vmwgfx`), so there is nothing to lose.
---
## 2. Measured current state
### Data to migrate — 39 GB total, ~32 GB after pruning
Source: `/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/`
| Directory | Size | Files | Migrate? | Contents |
|---|---:|---:|:--:|---|
| `Metadata/` | 22 G | 77,187 | ✅ | Posters, art, season/episode metadata (TV 13 G, Movies 9.3 G) |
| `Plug-in Support/` | 9.4 G | 2,692 | ⚠️ partial | Databases (see below) + plugin prefs |
| `Media/` | 7.7 G | 236,131 | ✅ | **Video preview thumbnails** (`index-sd.bif`) + media bundles |
| `Cache/` | 369 M | — | ⚠️ partial | `PhotoTranscoder/` (web-UI thumbnails) ✅; `Transcode/` ❌ |
| `Scanners/` | 93 M | 3 | ✅ | Custom scanners |
| `Codecs/` | 17 M | 26 | ❌ | Rebuilt by the container for its own arch/build |
| `Logs/`, `Crash Reports/`, `Updates/`, `Diagnostics/` | 52 M | — | ❌ | Disposable |
### Databases (`Plug-in Support/Databases/`)
```
com.plexapp.plugins.library.db 449 M ← main library DB
com.plexapp.plugins.library.db-wal 459 M ← HOT: must be checkpointed
com.plexapp.plugins.library.blobs.db 1.2 G ← blobs (image/bundle data)
com.plexapp.plugins.library.blobs.db-wal 1.2 G ← HOT: must be checkpointed
*.db-2026-09-04 / -09-07 / -09-10 / -09-13 ← 6.6 G of scheduled backups — SKIP
```
Those two WAL files total **1.7 GB of uncommitted data**. Copying the DB without a clean shutdown + checkpoint is the single most likely way to corrupt this migration. Section 6 handles it.
### Library section roots (from `com.plexapp.plugins.library.db`)
| ID | Name | Type | Root path |
|---|---|---|---|
| 1 | Movies | movie | `/mnt/nfs/Movies` |
| 2 | TV Shows | show | `/mnt/nfs/TV` |
| 4 | Anime | show | `/mnt/nfs/Anime` |
Every row in `media_parts.file` is prefixed with one of those three paths.
### Server identity (`Preferences.xml`)
```
MachineIdentifier = bfed40ab-04ac-4f33-9e45-f8ba9f6416e9
ProcessedMachineIdentifier = 7aa65063599874eac3d9a2f62df74c1816400c88
PlexOnlineToken = dz2m…
PlexOnlineHome = 0 (no Plex Pass)
```
`ProcessedMachineIdentifier` is what Tautulli, seerr and plex.tv key off. Carrying `Preferences.xml` across preserves it — clients, shares, watch history and Tautulli's history all follow automatically.
### Target host readiness — all green
| Check | Result |
|---|---|
| Free space on `/` | 443 G free of 999 G (need ~32 G) |
| CPU / RAM | 22 vCPU, 31 G RAM (17 G available), load avg ~2–3 |
| Ports 32400, 3005, 8324, 32469, 1900/udp, 5353/udp, 32410–32414/udp | **all free**; `avahi-daemon` inactive |
| `/mnt/share` NFS | mounted, `vers=4.2 nconnect=8` from 10.50.0.25, 57 T free |
| Media present | `/mnt/share/Media/{Movies,TV,Anime}` ✅ |
| `traefik` docker network | exists, external |
| DATA_50 link | docker host 25 Gb/s, plex VM 10 Gb/s — transfer runs over this |
---
## 3. Design decisions
### 3.1 Media path strategy — **Decision 1**
The Plex DB stores absolute media paths. The container's mount layout must account for them. Measured extent of the problem:
| Table | Column | Rows containing `/mnt/nfs/…` |
|---|---|---:|
| `media_parts` | `file` | **31,623** |
| `media_streams` | `url` | **636** ← external subtitle/artwork sidecars |
| `section_locations` | `root_path` | 3 |
| `directories` | `path` | 0 — stores *relative* paths (`2 Fast 2 Furious (2003)`), unaffected |
| | **total** | **32,262** |
> Earlier drafts of this plan said ~236k rows. That was wrong — 236,131 is the *file count* in the `Media/` thumbnail directory, a different thing. The real figure is 32,262 rows, which makes Option B below genuinely viable rather than absurd.
#### Options
| | Option | Rows touched | Container paths | Risk |
|---|---|---:|---|---|
| **A** ✅ | **Bind-mount at the DB's existing paths** — `/mnt/share/Media/Movies:/mnt/nfs/Movies` etc. | **0** | `/mnt/nfs/*` (legacy) | None. DB never opened. |
| **B** | **Rewrite the DB offline**, then use clean paths (`/data/movies`) matching the arr-stack convention | 32,262 across 3 tables | `/data/*` (consistent) | Moderate — see below |
| **C** | **Re-path each library in the Plex UI** | — | `/data/*` | **Rejected.** Plex treats a changed root as new content: items whose old paths vanish get removed and re-added, losing watch state, ratings and collections unless the full "Plex Dance" is performed per library. |
| **D** | **Host symlinks** `/mnt/nfs/X -> /mnt/share/Media/X`, mount `/mnt/nfs` in | 0 | `/mnt/nfs/*` | Works, but adds host state outside Ansible for no gain over A. |
#### Selected: **A** — bind-mount at the DB's existing paths *(ratified 2026-09-16)*
The deciding argument is not that B is hard — with the server stopped it is three `UPDATE … replace()` statements through Plex SQLite — it is that **A is free and does not foreclose B.** Nothing about bind-mounting at legacy paths prevents doing the rewrite later, at leisure, with no cutover clock running and a container that can be trivially rolled back. Doing it *during* the migration stacks a schema-editing risk on top of a data-copy risk on the one evening where both must work.
The honest cost of A: `/mnt/nfs/*` inside the Plex container is a fossil of the old VM and reads oddly next to Sonarr's `/data/tv`. That is a cosmetic wart on a single compose file, documented in the role.
The specific trap in B, if it is ever done: **`media_streams.url` (636 rows) is the row set everyone forgets.** A rewrite that only covers `section_locations` and `media_parts` looks completely successful — libraries scan, playback works — and silently breaks every external subtitle file.
### 3.2 Container networking — **Decision 2**
#### What the evidence actually supports
Checked against the live server rather than assumed:
- `DlnaEnabled="0"` — **DLNA is off.** Earlier drafts listed DLNA (1900/udp, 32469/tcp) as a reason for host networking. It is not one. Struck.
- `ManualPortMappingMode="1"`, `LastAutomaticMappedPort="0"` — Plex is not doing UPnP/NAT-PMP; the static UDM forward is the sole remote path, as documented.
- **ESXi `LAN_100` portgroup: `Allow promiscuous mode: No`, `Allow forged transmits: No`, `Allow MAC changes: No`.** This is decisive for Option C below.
#### Options
| | Option | Client source IPs | Local discovery | Traefik route | Blocker |
|---|---|---|---|---|---|
| **A** ✅ | **`network_mode: host`** | Real | ✅ GDM works | ✅ via existing file provider | None — all 9 ports verified free, `avahi-daemon` inactive |
| **B** | **Bridge + published ports** | ❌ all collapse to the docker gateway | ❌ no GDM | ✅ | Needs `ADVERTISE_IP=http://10.100.0.35:32400/` or Plex advertises a useless container IP to plex.tv |
| **C** | **macvlan, reusing `10.100.0.24`** | Real | ✅ | ❌ breaks | **Hard blocker** — see below |
#### Why C is blocked, despite being the most attractive on paper
macvlan is genuinely tempting: keeping `10.100.0.24` would make **all of Section 7 unnecessary** — no port-forward edit, no Sonarr/Radarr connection surgery, no Tautulli/seerr/Kuma re-pointing. The single most error-prone part of this migration would simply evaporate.
It does not survive contact with the hypervisor. macvlan puts a second MAC address behind the VM's vNIC, which ESXi drops unless the portgroup permits it — and `LAN_100` currently sets promiscuous mode, forged transmits and MAC changes all to **No**. Enabling them is not a Plex-scoped change: it is a security-posture change applied to **every VM on VLAN 100**, to make one container's networking tidier.
Secondary, and independent of ESXi: a bridge-network container (Traefik) cannot reach a macvlan container — its traffic is SNAT'd to the host IP, and host↔macvlan is exactly the path macvlan isolates. The `plex.dohertylan.com` route would need a macvlan shim interface to survive.
#### Why not B
The client-IP collapse is the real problem, and it is worse than it first looks. With every client arriving from the docker gateway, Plex classifies **all** of them — LAN and internet alike — as remote, and applies remote streaming bitrate limits to local playback. The apparent fix, declaring the bridge subnet under Plex's **LAN Networks** setting, inverts the bug into a worse one: it would classify genuinely remote clients as local, removing remote bandwidth limits entirely on an internet-exposed server. Tautulli's per-client statistics degrade the same way.
#### Selected: **A** — `network_mode: host` *(ratified 2026-09-16)*
Even with the DLNA argument withdrawn, the two remaining reasons stand on their own: real client source IPs (correct local/remote classification, correct Tautulli stats) and GDM discovery on 32410–32414/udp.
The cost is honest and worth stating: host networking gives the container the host's full network namespace, so it is weaker isolation for a process that is deliberately exposed to the internet on 32400. Bridge mode would confine a compromised Plex to the bridge. That is a real argument for B — it is simply outweighed by B breaking local/remote classification on a server whose main job is streaming.
### 3.2a Consequences carried by these two choices
Both selections are the low-risk path, but each leaves one thing on the books:
- **From 3.1 A** — container media paths remain `/mnt/nfs/*`, inconsistent with the arr stack's `/data/*`. This is cosmetic and documented in the role. If it ever becomes worth fixing, Option B (32,262 rows across `section_locations`, `media_parts`, **and `media_streams`**) can be done later against a container that rolls back trivially. It is not a migration-night task.
- **From 3.2 A** — Plex runs in the host network namespace while being deliberately internet-exposed on 32400. Worth pairing with the hygiene that already applies to any internet-facing service: keep the image current via `update.yml` (§4), and note that a Plex compromise now reaches the docker host's network directly rather than a bridge.
### 3.3 Image: `lscr.io/linuxserver/plex`
Consistent with sonarr/radarr/prowlarr/whisparr already on `lscr.io`, same `PUID`/`PGID`/`TZ` convention, same `/config` layout as the current VM tree.
**`VERSION=docker` is mandatory.** It disables the container's built-in self-updater so the *image tag* is the sole source of version truth — which is what makes `update.yml` (`docker compose pull` → `up -d`) the real update mechanism. Leaving `VERSION=latest` would have Plex silently upgrade itself inside the container, drifting from the image and losing the change on every recreate.
**Do not set `PLEX_CLAIM`.** It is for first-time claiming only; on a migrated `Preferences.xml` it would re-claim the server and change its identity.
### 3.4 Transcode scratch on local disk
`/opt/plex/transcode` on `/dev/sda1` (443 G free), mounted at `/transcode`. Never on NFS (kills transcode throughput and hammers the storage VLAN). tmpfs was considered but rejected — only 17 G RAM is available and a 4K transcode buffer can spike hard.
### 3.5 Directory layout
```
/opt/plex/
├── docker-compose.yml ← rendered by Ansible; what update.yml operates on
├── config/ ← ~32 GB, bind-mounted to /config
│ └── Library/Application Support/Plex Media Server/
└── transcode/ ← scratch, mounted at /transcode
```
Slight deviation from the `/opt/<svc>` convention (config in a subdir rather than at the root) so 32 GB of library data doesn't sit alongside the compose file. `update.yml` only requires `/opt/plex/docker-compose.yml`, which is satisfied.
---
## 4. Ansible role — `roles/plex/`
Repo: `/home/dohertj2/playbook` → GitHub `dohejw01/ansiblearr`. Committed and pushed 2026-09-16 (`04cdb9d`).
**Scope: deploy and update only.** The role creates directories, renders the compose file and runs the stack. It deliberately does **not** touch `/opt/plex/config` contents — the library is one-time migrated data, not rendered config, and nothing about copying 32 GB across once belongs in a playbook that reruns.
That makes the ordering load-bearing: **§6 copies the data, then §6.4 runs the role.** Reversed, the container starts empty, generates a fresh `Preferences.xml` with a *new* `MachineIdentifier`, and the copy then overwrites it — recoverable, but it defeats the identity-preservation the whole plan is built on. Do not run `--tags plex` before §6.3 passes.
### `roles/plex/defaults/main.yml`
```yaml
---
plex_dir: /opt/plex
plex_config_dir: "{{ plex_dir }}/config"
plex_transcode_dir: "{{ plex_dir }}/transcode"
plex_image: lscr.io/linuxserver/plex:latest
plex_tz: "America/Indiana/Indianapolis"
plex_puid: "1000"
plex_pgid: "1000"
# Host source -> container target. Targets MUST match the paths already stored
# in the Plex library DB (/mnt/nfs/*) so no library re-pathing is needed.
plex_media_mounts:
- { src: /mnt/share/Media/Movies, dest: /mnt/nfs/Movies }
- { src: /mnt/share/Media/TV, dest: /mnt/nfs/TV }
- { src: /mnt/share/Media/Anime, dest: /mnt/nfs/Anime }
traefik_domain: "{{ cloudflare_dns_zone | default('dohertylan.com') }}"
```
### `roles/plex/tasks/main.yml`
```yaml
---
- name: Create Plex directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ plex_puid }}"
group: "{{ plex_pgid }}"
mode: "0755"
loop:
- "{{ plex_dir }}"
- "{{ plex_config_dir }}"
- "{{ plex_transcode_dir }}"
- name: Assert media sources are mounted
ansible.builtin.stat:
path: "{{ item.src }}"
register: plex_media_stat
loop: "{{ plex_media_mounts }}"
loop_control:
label: "{{ item.src }}"
- name: Fail if any media source is missing
ansible.builtin.fail:
msg: "{{ item.item.src }} is not present — is /mnt/share mounted?"
when: not item.stat.exists
loop: "{{ plex_media_stat.results }}"
loop_control:
label: "{{ item.item.src }}"
- name: Template docker-compose.yml
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ plex_dir }}/docker-compose.yml"
mode: "0644"
notify: Restart plex stack
- name: Start Plex stack
ansible.builtin.shell: docker compose up -d
args:
chdir: "{{ plex_dir }}"
register: plex_up
changed_when: "'Started' in plex_up.stderr or 'Created' in plex_up.stderr"
```
Note the role deliberately does **not** manage `{{ plex_config_dir }}` contents. The library is migrated data, not rendered config — Ansible creates the directory and owns the container lifecycle; the one-time data copy is Section 6.
### `roles/plex/handlers/main.yml`
```yaml
---
- name: Restart plex stack
ansible.builtin.shell: docker compose up -d --force-recreate
args:
chdir: "{{ plex_dir }}"
```
### `roles/plex/templates/docker-compose.yml.j2`
```yaml
services:
plex:
image: {{ plex_image }}
container_name: plex
restart: unless-stopped
# Host networking (§3.2 Option A): required for GDM discovery
# (32410-32414/udp) and real client source IPs, so Plex does not
# misclassify LAN clients as remote. All ports verified free on this host.
network_mode: host
environment:
PUID: "{{ plex_puid }}"
PGID: "{{ plex_pgid }}"
TZ: "{{ plex_tz }}"
# 'docker' pins the version to the image tag and disables the in-container
# self-updater — update.yml (docker compose pull) is the update mechanism.
VERSION: "docker"
volumes:
- {{ plex_config_dir }}:/config
- {{ plex_transcode_dir }}:/transcode
{% for m in plex_media_mounts %}
- {{ m.src }}:{{ m.dest }}
{% endfor %}
devices:
- /dev/dri:/dev/dri
labels:
# Routed by Traefik's file provider (external-services.yml) because a
# host-network container is not discoverable by the docker provider.
traefik.enable: "false"
```
> `/dev/dri` is passed through for forward-compatibility only — it is currently `vmwgfx` (VMware SVGA) with no VAAPI encode. Drop this line if the container logs complain about the device.
### Wire into `site.yml`
Insert after `tautulli` to keep the media block together:
```yaml
- role: plex
tags: [plex, arr]
```
### Wire into `update.yml`
Add `plex` to `compose_projects`, after `tautulli`. Then `ansible-playbook update.yml -e only=plex` updates Plex alone, and the full pass picks it up with everything else.
---
## 5. Pre-flight (do days before, no downtime)
1. **Write and push the role** — role files, `site.yml`, `update.yml`. Push to GitHub.
2. **Verify Plex Pass status is understood** — no Plex Pass, so no hardware transcode and no `plexpass` image tag. Confirmed from `PlexOnlineHome="0"`.
3. **Confirm the plex.tv token still works** before touching anything; a dead token means the server would need re-claiming after the move (`curl` is *not* installed on the Plex VM — run this from the docker host):
```bash
TOKEN=$(ssh dohertj2@10.100.0.24 'sudo grep -oP "PlexOnlineToken=\"\K[^\"]+" \
"/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Preferences.xml"')
curl -s "http://10.100.0.24:32400/identity?X-Plex-Token=$TOKEN"
```
4. **Back up the library DB via Plex's own SQLite** (safe while running, and the only supported way to get a consistent copy):
```bash
ssh dohertj2@10.100.0.24
D="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
sudo "/usr/lib/plexmediaserver/Plex SQLite" "$D/com.plexapp.plugins.library.db" \
".backup '/tmp/plex-library-preflight.db'"
```
Keep this off-box. It is the last-resort artifact if both the container and the VM go wrong.
5. **Snapshot the VM** via `govc` (see [esxi.md](esxi.md)) — the real rollback. Note the constraint from [Veeam](veeam.md): the VM must have no PCI passthrough for snapshots to work; the Plex VM has none, so this is fine.
6. **Pre-create the target tree** so the cold copy writes into an existing, correctly-owned directory:
```bash
sudo mkdir -p "/opt/plex/config/Library/Application Support"
sudo mkdir -p /opt/plex/transcode
sudo chown -R 1000:1000 /opt/plex
```
---
## 6. Cutover — single cold copy
**Estimated downtime: 15–25 minutes** (≈32 GB over a 10 Gb/s DATA_50 link; the 236k small files in `Media/` dominate, which is why this is a `tar` stream and not `rsync`).
> **Why one cold copy instead of pre-seed + delta:** `rsync` is not installed on the Plex VM, and `apt` is broken there (Debian 11 EOL, security mirror 404s), so installing it is not straightforward. Both hosts have `tar` and `nc`. At 10 Gb/s a single cold `tar` stream is fast enough that a warm pre-seed buys nothing.
### 6.1 Stop Plex and checkpoint the WAL
```bash
ssh dohertj2@10.100.0.24
sudo systemctl stop plexmediaserver
sudo systemctl disable plexmediaserver # don't let it come back on reboot
D="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
for db in com.plexapp.plugins.library.db com.plexapp.plugins.library.blobs.db; do
sudo "/usr/lib/plexmediaserver/Plex SQLite" "$D/$db" "PRAGMA wal_checkpoint(TRUNCATE);"
sudo "/usr/lib/plexmediaserver/Plex SQLite" "$D/$db" "PRAGMA integrity_check;"
done
ls -la "$D"
```
Both `integrity_check` calls must print `ok`, and the `-wal` files must be 0 bytes (or gone). **If integrity_check reports anything else, stop and run Plex's DBRepair before continuing** — do not migrate a damaged DB.
### 6.2 Stream the data across DATA_50
Run on the **Plex VM**, pushing over SSH to the docker host's storage IP:
```bash
cd "/var/lib/plexmediaserver/Library/Application Support"
sudo tar -cf - \
--exclude='Plex Media Server/Cache/Transcode' \
--exclude='Plex Media Server/Logs' \
--exclude='Plex Media Server/Crash Reports' \
--exclude='Plex Media Server/Updates' \
--exclude='Plex Media Server/Diagnostics' \
--exclude='Plex Media Server/Codecs' \
--exclude='Plex Media Server/Plug-in Support/Databases/*.db-2026-*' \
"Plex Media Server" \
| ssh dohertj2@10.50.0.35 'tar -xf - -C "/opt/plex/config/Library/Application Support"'
```
If SSH encryption caps throughput, the unencrypted `nc` variant over the isolated storage VLAN is faster — listener first on the docker host (`nc -l -p 9000 | tar -xf - -C "/opt/plex/config/Library/Application Support"`), then the same `tar -cf - … | nc 10.50.0.35 9000` on the VM.
### 6.3 Fix ownership and verify the copy
On the **docker host**:
```bash
sudo chown -R 1000:1000 /opt/plex
P="/opt/plex/config/Library/Application Support/Plex Media Server"
du -sh "$P" # expect ~32 G
du -sh "$P"/*/ | sort -rh # Metadata ~22G, Media ~7.7G, Plug-in Support ~2G
find "$P/Media" | wc -l # expect ~236131
find "$P/Metadata" | wc -l # expect ~77187
ls -la "$P/Plug-in Support/Databases/" # live .db present, no *-2026-* backups, no -wal
grep -o 'ProcessedMachineIdentifier="[^"]*"' "$P/Preferences.xml"
# must read 7aa65063599874eac3d9a2f62df74c1816400c88
```
Those two `find` counts are the direct check that **thumbnails came across**: `Media/` holds the `index-sd.bif` video preview thumbnails, `Metadata/` the posters and artwork.
### 6.4 Bring up the container
Only after §6.3's verification passes — this is the first time the role is run.
```bash
cd /home/dohertj2/playbook
ansible-playbook site.yml --tags plex
docker logs -f plex
```
Watch for `Plex Media Server SQLite database` opening cleanly and the libraries loading. Then:
```bash
TOKEN=<PlexOnlineToken>
curl -s "http://10.100.0.35:32400/identity?X-Plex-Token=$TOKEN"
curl -s "http://10.100.0.35:32400/library/sections?X-Plex-Token=$TOKEN"
```
Identity must return the **same** `machineIdentifier` as the VM. Sections must list Movies / TV Shows / Anime with the correct item counts.
### 6.5 Set the transcode directory
Once the server is up, in **Settings → Transcoder → Transcoder temporary directory**, set `/transcode`. (It is a container path, not a host path.) Plex only persists this pref from the UI or the API — it is not in the migrated `Preferences.xml` today, since the VM used the default.
---
## 7. Downstream re-pointing — 10.100.0.24 → 10.100.0.35
The trap here: **only about half of these live in Ansible.** The rest are runtime state inside service SQLite DBs that no playbook run will rewrite. Each group needs a different mechanism.
### Group A — Ansible-managed (edit in repo → push → re-run tag)
| # | What | File | Change |
|---|---|---|---|
| A1 | Traefik external service | `roles/traefik/defaults/main.yml:39` | `url: "http://10.100.0.35:32400"` |
| A2 | Tautulli template | `roles/tautulli/templates/config.ini.j2:11,14` | `pms_ip` / `pms_url` → `.35`. Leave `pms_identifier` alone — unchanged, and that is the point. |
| A3 | seerr template | `roles/seerr/templates/settings.json.j2:27` | `"ip": "10.100.0.35"` |
| A4 | Homepage widget | `roles/homepage/defaults/main.yml:129` | `http://10.100.0.35:32400` |
| A5 | Uptime Kuma role default | `roles/uptime_kuma/defaults/main.yml:44` | `http://10.100.0.35:32400/identity` |
| A6 | Guacamole "Plex SSH" | `roles/guacamole/defaults/main.yml:27` | Remove entry at decommission |
```bash
ansible-playbook site.yml --tags traefik,tautulli,seerr,homepage,uptime_kuma
```
### Group B — live state Ansible will NOT rewrite (must change via UI or API)
These are the easy ones to miss, because editing the template in Group A looks like it finished the job and does not.
| # | What | Where it actually lives | Why the playbook won't fix it |
|---|---|---|---|
| **B1** | **Sonarr → "Plex Media Server" connection** (id 2, `http://10.100.0.24:32400`, `updateLibrary: true`) | `/opt/sonarr/sonarr.db`, `Notifications` table | Not templated at all. **If missed, Plex silently stops auto-scanning on every TV import** — new episodes just never appear. |
| **B2** | **Radarr → "Plex Media Server" connection** (id 2, same shape) | `/opt/radarr/radarr.db`, `Notifications` table | Same, for movies. |
| **B3** | **Uptime Kuma monitor id 17** `Plex → http://10.100.0.24:32400/identity` | `/opt/uptime_kuma/data/kuma.db` | `configure_monitors.py` only *creates* missing monitors (`if m["name"] in existing: skip`). An existing monitor is never updated — it will just alert red forever. |
| **B4** | Tautulli live `config.ini` (`pms_ip`, `pms_url` at lines 79/82) | `/opt/tautulli/config.ini` | Role templates it with `force: false` — first deploy only. Re-point in Tautulli's own Settings → Plex Media Server. |
| **B5** | seerr live `settings.json` (`plex.ip`) | `/opt/seerr/settings.json` | Same first-deploy-only pattern. `machineId` already matches and stays correct. |
Sonarr/Radarr can be fixed over their APIs rather than by clicking:
```bash
# per service: fetch notification 2, patch host/server, PUT it back
curl -s -H "X-Api-Key: $KEY" http://10.100.0.35:8989/api/v3/notification/2
```
Whisparr has no Plex connection, and neither Sonarr nor Radarr has a Plex import list — verified, nothing to do there.
### Group C — infrastructure
| # | What | Change |
|---|---|---|
| **C1** | **UDM Pro `PLEX` port-forward** (rule id `67ebfaef41b7da3e559ab08d`, WAN TCP 32400) | `fwd` `10.100.0.24` → `10.100.0.35`. **This is the entire remote-access path** — Plex does not advertise the Traefik URL. Confirmed live via the Unifi API. See [unifi.md](unifi.md). |
| **C2** | **Veeam `Backup VMs` job** — currently includes the Plex VM | Remove Plex from the job at decommission, or it starts failing on a missing VM. See [veeam.md](veeam.md). |
> **Note on C1:** the Plex VM has **no DHCP reservation** — it is a plain lease, which is exactly how it drifted `.23 → .24` and broke remote access before. The docker host is *statically* configured in `/etc/network/interfaces` (`10.100.0.35`), so after this migration the port-forward target can no longer drift. That closes a long-standing fragility.
### Group D — checked, no action needed
Recording these so they don't get re-investigated later:
- **DNS** — there is nothing to change. `plex.lan.dohertylan.com` resolves to `208.161.27.232` (the WAN IP) via a wildcard `*.lan.dohertylan.com` record, not a local A record pointing at `.24`. The UDM holds **zero** static DNS records. (`plex.md` presents this FQDN as if it were a real host record; it is a wildcard artifact.)
- **TrueNAS NFS exports** — `/mnt/mypool/share` has empty `hosts` and `networks` allow-lists, so no per-client entry needs adding for `.35`. The docker host already has 8 established NFS connections.
- **Home Assistant** — no Plex integration exists (verified over the HA API; the only match is `switch.unifi_network_plex`, which toggles the C1 port-forward rule by ID and follows it automatically). Worth knowing that switch exists: if it is ever off, remote access dies regardless of the forward's target.
- **Cloudflare `plex.dohertylan.com`** — points at the WAN/Traefik path, which stays on `.35`. Unchanged.
---
## 8. Verification checklist
- [ ] `machineIdentifier` from `/identity` matches `bfed40ab-04ac-4f33-9e45-f8ba9f6416e9`
- [ ] All three libraries present with item counts matching the pre-migration figures
- [ ] Posters and background art render in the web UI (Metadata)
- [ ] **Video preview thumbnails scrub correctly** on the seek bar (the `Media/*.bif` files)
- [ ] Watch history and "Continue Watching" intact for at least two accounts
- [ ] Direct play works on a LAN client
- [ ] A forced transcode works and writes into `/opt/plex/transcode` on the host
- [ ] LAN clients are reported as **local**, not remote (confirms host networking is doing its job)
- [ ] plex.tv shows the server as reachable remotely (confirms the port-forward change)
- [ ] `https://plex.dohertylan.com` loads through Traefik/Authelia
- [ ] Tautulli reconnects and its existing history is continuous, not reset
- [ ] seerr can talk to Plex and run a library scan
- [ ] **Sonarr "Test" on the Plex connection passes, and a real TV import triggers a Plex scan** (B1)
- [ ] **Radarr ditto for a movie import** (B2)
- [ ] Uptime Kuma's Plex monitor is green against `.35`, not red against `.24` (B3)
- [ ] `docker restart plex` comes back clean; `reboot` of the docker host brings Plex back automatically
- [ ] `ansible-playbook update.yml -e only=plex` pulls and recreates without data loss
Let it run **at least a week** before Section 9.
---
## 9. Rollback and decommission
### Rollback (any time before the VM is deleted)
1. `cd /opt/plex && docker compose down`
2. On the VM: `sudo systemctl enable --now plexmediaserver`
3. Revert the UDM port-forward to `10.100.0.24` and the Ansible IP references; re-run the tags.
Cost is only the watch history accumulated while the container was live. To avoid even that, copy `com.plexapp.plugins.library.db` back the same way — but only with the container stopped and its WAL checkpointed.
### Decommission (after a clean week)
1. **Remove Plex from the Veeam `Backup VMs` job** (C2) — do this *first*, or the next run fails on a missing VM.
2. `govc` snapshot removal, then power off the VM.
3. Keep the powered-off VM for another 2–4 weeks before deleting the VMDKs.
4. Remove the Guacamole "Plex SSH" entry (A6).
5. Release the `.24` LAN_100 lease and the `10.50.0.31` DATA_50 address.
6. Update docs: [plex.md](plex.md) (rewrite for the container; also drop the misleading `plex.lan.dohertylan.com` FQDN line — it is a wildcard, not a host record), [docker.md](docker.md) (add `plex` to Media Management), [CLAUDE.md](CLAUDE.md) line 24, [esxi.md](esxi.md) lines 71/109/110, [veeam.md](veeam.md) line 58.
7. Update memory: the port-forward now targets `.35` (superseding the 2026-09-16 `.24` fix), and `debian-11-eol-2026-08-31` no longer applies to Plex — only Ignition.
---
## 10. Risks
| Risk | Likelihood | Mitigation |
|---|---|---|
| DB corruption from copying a hot WAL | **High if skipped** | §6.1 stop → `wal_checkpoint(TRUNCATE)` → `integrity_check` is non-negotiable |
| Library re-paths, triggering a full rematch and orphaned watch state | Low | Avoided entirely by §3.1 Option A — container paths identical to DB paths |
| **Sonarr/Radarr Plex connection left on `.24`** — imports stop triggering Plex scans, new media silently never appears | **Medium-high** | The most likely miss in the whole plan: it lives in `sonarr.db`/`radarr.db`, not in any template, and fails quietly. §7 B1/B2, verified in §8 |
| Remote access dies silently after cutover | Medium | The port-forward is the only remote path and is easy to forget. §7 item 1; verified via plex.tv reachability, not by "the web UI loads" |
| LAN clients treated as remote, capped bitrate | Medium | `network_mode: host` (§3.2 Option A); explicitly verified in §8 |
| Container self-updates and drifts from the image | Medium | `VERSION=docker` (§3.3) |
| Transcoding onto NFS | Low | Dedicated `/transcode` on local disk (§3.4), set in the UI at §6.5 |
| Tautulli history appears reset | Low | Preserved by carrying `Preferences.xml` → same `ProcessedMachineIdentifier` |
| Docker host becomes a bigger single point of failure | — | Accepted. Plex joins 21 other containers; a host outage now also takes Plex. Consider whether Veeam covers `/opt/plex`. |
---
## 11. Open decisions
1. **Back up `/opt/plex/config`?** *Partly answered:* the Veeam `Backup VMs` job already covers **both** Plex and DOCKER, so after the move `/opt/plex` is captured by the DOCKER VM backup — coverage is not lost. But it adds ~32 GB to that VM's image and the DOCKER VM is a much larger, busier backup target than the Plex VM was. Consider a periodic `Plex SQLite .backup` of the library DB to `/mnt/share`: it captures the genuinely irreplaceable part (watch history, ratings, collections) at ~450 MB instead of 32 GB, and restores far faster than pulling a VM image.
2. **Media mounts read-write or read-only?** The plan keeps `rw` to match current VM behaviour (needed for "Delete media" from the Plex UI and for local media assets). `ro` is a safe hardening step if that is never used.
3. **Retire the `plex.dohertylan.com` Traefik route?** It exists but Plex never advertises it (`customConnections` unset), so it is a convenience bookmark, not a client path. Keeping it costs nothing.
+41 -49
View File
@@ -1,66 +1,58 @@
# Plex Media Server # Plex Media Server
> **Migrated to Docker 2026-09-16.** Plex ran on a dedicated Debian 11 VM at 10.100.0.24 until that OS reached LTS EOL (2026-08-31). It now runs as a container on the [Docker host](docker.md). The old VM still exists, powered on but with `plexmediaserver` stopped and disabled, as rollback. Full procedure: [plex-docker-migration.md](plex-docker-migration.md).
## Access ## Access
- **Hostname**: plex - **Host**: DOCKER (10.100.0.35), container `plex`
- **FQDN**: plex.lan.dohertylan.com - **Web UI (LAN)**: http://10.100.0.35:32400/web
- **IP**: 10.100.0.24 (LAN_100) / 10.50.0.31 (DATA_50) - **Remote access**: WAN TCP 32400 port-forwarded on UDM Pro → **10.100.0.35**:32400 (rule `PLEX`, id `67ebfaef41b7da3e559ab08d`). Public IP 208.161.27.232. Also reachable at https://plex.dohertylan.com (Traefik + Authelia SSO), but Plex does not advertise that URL (`customConnections` unset), so clients use the `:32400` path.
- **OS**: Debian 11 (Bullseye), kernel 5.10.0-11-amd64 - **Shell**: `docker exec -it plex bash` (no separate SSH host any more)
- **SSH**: `ssh dohertj2@10.100.0.24` (passwordless)
- **Web UI**: http://10.100.0.24:32400/web
## Hardware (VM on ESXi) ## Container
- **CPU**: 8 vCPUs (Intel Xeon E5-2697 v4 @ 2.30GHz) - **Image**: `lscr.io/linuxserver/plex:latest` (running `1.43.4.10903-e5521bd8c-ls324`)
- **RAM**: 7.8 GB - **Compose**: `/opt/plex/docker-compose.yml`, rendered by the `plex` Ansible role
- **Disk**: /dev/sda1 125 GB ext4 (80 GB free) - **Networking**: `network_mode: host` — needed for GDM discovery (32410-32414/udp) and real client source IPs, so LAN clients are not misclassified as remote
- **PUID/PGID**: 1000/1000
- **`VERSION=docker`**: pins Plex to the image tag and disables the in-container self-updater
## Network ### Volumes
| Interface | MAC | IP | MTU | Port Group | | Host | Container | Notes |
|-----------|-----|-----|-----|------------| |---|---|---|
| ens192 | 00:0c:29:4a:ef:3d | 10.100.0.24/24 | 1500 | LAN_100 | | `/opt/plex/config` | `/config` | 32 GB library (metadata, thumbnails, DBs) |
| ens224 | 00:0c:29:4a:ef:47 | 10.50.0.31/24 | 9000 | DATA_50 | | `/opt/plex/transcode` | `/transcode` | Local-disk scratch, never NFS |
| `/mnt/share/Media/Movies` | `/mnt/nfs/Movies` | Legacy path — matches the library DB |
| `/mnt/share/Media/TV` | `/mnt/nfs/TV` | ″ |
| `/mnt/share/Media/Anime` | `/mnt/nfs/Anime` | ″ |
- **Default gateway**: 10.100.0.1 (via ens192) > **Why the `/mnt/nfs/*` container paths look odd:** the Plex library DB stores 32,262 absolute media paths from the old VM. Mounting the media at those exact paths inside the container meant the migration touched zero DB rows and could not trigger a library rematch. Do not "tidy" these to `/data/*` without rewriting `section_locations`, `media_parts` **and** `media_streams`.
- **DNS**: 10.100.0.1
- **Domain**: lan.dohertylan.com
## Plex Media Server ## Identity
- **Version**: 1.43.0.10492-121068a07 - **MachineIdentifier**: `7aa65063599874eac3d9a2f62df74c1816400c88` (preserved across the migration — Tautulli, seerr and all clients kept working)
- **Service**: `plexmediaserver.service` — enabled, active (running) - **No Plex Pass** (`PlexOnlineHome="0"`) — software transcoding only; there is no usable hardware encoder on this host (VMware SVGA / `vmwgfx`)
- **Run user**: plex
- **Data dir**: `/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/`
## Auto Updates ## Libraries
Plex is updated automatically via the **official Plex apt repository** and Debian's built-in `apt-daily-upgrade.timer`. | ID | Name | Root path | Items (2026-09-16) |
|---|---|---|---|
| 1 | Movies | `/mnt/nfs/Movies` | 1,959 |
| 2 | TV Shows | `/mnt/nfs/TV` | 421 |
| 4 | Anime | `/mnt/nfs/Anime` | 146 |
- **Repo**: `deb [signed-by=/etc/apt/keyrings/plexmediaserver.v2.gpg] https://repo.plex.tv/deb/ public main` ## Updates
- **Sources list**: `/etc/apt/sources.list.d/plexmediaserver.list`
- **GPG key**: `/etc/apt/keyrings/plexmediaserver.v2.gpg` (PlexSign.v2.key)
- **Update mechanism**: `apt-daily.timer` fetches package lists, `apt-daily-upgrade.timer` installs upgrades (both run daily via systemd)
Previously used the third-party [plexupdate](https://github.com/mrworf/plexupdate) script (`/opt/plexupdate/plexupdate.sh` via `/etc/cron.daily/plexupdate`), which downloaded `.deb` files directly from plex.tv. This was removed on 2026-03-13 in favor of the official apt repo after Plex announced deprecation of the old update method. Managed by Ansible, not by Plex. `ansible-playbook update.yml -e only=plex` pulls the latest image and recreates the container; the full `update.yml` pass includes it. The in-container updater is disabled via `VERSION=docker`.
## NFS Media Mounts (via TrueNAS DATA_50 network) ## Integrations pointing at this server
All mounts use NFS 4.2 with `nconnect=4,async,_netdev,nofail,x-systemd.automount`. | Service | Where configured |
|---|---|
| Mount Point | NFS Export | | Sonarr / Radarr | "Plex Media Server" connection (id 2), `updateLibrary: true` — stored in `sonarr.db`/`radarr.db`, **not** templated by Ansible |
|-------------|-----------| | Tautulli | `/opt/tautulli/config.ini` (`pms_ip`, `pms_url`) |
| /mnt/nfs/Movies | truenas-nfs:/mnt/mypool/share/Media/Movies | | seerr | `/opt/seerr/settings.json` (`plex.ip`) |
| /mnt/nfs/TV | truenas-nfs:/mnt/mypool/share/Media/TV | | Uptime Kuma | monitor id 17 in `/opt/uptime_kuma/data/kuma.db` |
| /mnt/nfs/Anime | truenas-nfs:/mnt/mypool/share/Media/Anime | | Homepage | `/opt/homepage/config/services.yaml` |
| Traefik | `/opt/traefik/traefik/rules/external-services.yml` (file provider) |
`truenas-nfs` resolves via `/etc/hosts` to multiple TrueNAS DATA_50 IPs (10.50.0.25–28) for NFS multipathing.
## Host Aliases (/etc/hosts)
| IP | Hostname |
|----|----------|
| 10.50.0.25 | nfs1, truenas-nfs |
| 10.50.0.26 | nfs2, truenas-nfs |
| 10.50.0.27 | nfs3, truenas-nfs |
| 10.50.0.28 | nfs4, truenas-nfs |