Files
network/plex-docker-migration.md
T
dohertj2 14cc4faa28 Delete the Plex VM disks
Completes section 9 step 3. govc vm.destroy Plex removed the inventory
entry and the [NVME_1] Plex/ folder; datastore free space went
1,619.8 -> 1,756.1 GB (+136.3 GB). 10.100.0.24 / 10.50.0.31 and MACs
00:0c:29:4a:ef:3d / 00:0c:29:4a:ef:47 are now free for reuse.

Done on 2026-09-17 rather than the planned ~2026-10-15. Checking the
plan's own Veeam precondition inverted its assumption: the Backup VMs
job has EnableDeletedVmDataRetention set to False with Simple retention
of 7 restore points, so a VM removed from the job has its points pruned
as the remaining VMs roll the chain - Plex's are due to age out around
2026-09-24, about three weeks before the disks were scheduled for
deletion. Deleting while a good restore point still existed (2026-09-17
03:01, consistent) was safer than deleting later with none.

Recovery position is now: Veeam image restore until ~2026-09-24, then
only the pre-migration library DB at
/mnt/share/backups/plex-migration-2026-09-16/, which rebuilds a Plex
install but not the VM.

veeam.md gains the retention gotcha, since it applies to any future
decommission and the 30-day DeletedVmsDataRetentionPeriodDays value is
misleading while the toggle is off.

Also flags in esxi.md that its VM tables are incomplete: the host has 10
VMs, WW_DEV_VM / Rockwell_Win10 / TCBSD are absent, and DevVM is powered
off rather than on. Left the tables themselves alone as out of scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:12:20 -04:00

617 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plex Migration: ESXi VM → Docker Container
**Status: EXECUTED 2026-09-16. DECOMMISSIONED 2026-09-17.** 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. §9 decommission is **complete** — all steps including 3 (VMDK deletion) were done on 2026-09-17. See the log at the end of §9.
**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.
### Decommission log — executed 2026-09-17
Run one day after cutover rather than the planned week, on the owner's call that the container was behaving. Every step
below is reversible; the VMDKs were **not** touched.
**Pre-flight** — re-verified that nothing still pointed at `.24` before powering the VM off: Sonarr and Radarr Plex
connections, Tautulli `pms_ip`/`pms_url`, seerr `plex.ip` (with `machineId` intact), Uptime Kuma monitor 17, Homepage,
the Traefik file-provider service and the UDM `PLEX` port-forward all resolve to `10.100.0.35`. A `grep` of `/opt` found
`.24` only in rotated logs, a stale `seerr/settings.old.json`, and the previously-rendered
`uptime_kuma/configure_monitors.py` (the role default is already `.35`, so the next role run overwrites it).
| # | Step | Result |
|---|---|---|
| 1 | Veeam `Backup VMs` | Plex removed via `Remove-VBRJobObject`. **Veeam converts a removed include into an explicit `Exclude` rather than deleting the entry** — re-running the removal leaves it as `Exclude`. That is the same shape as the pre-existing `Deployarr` exclusion and is the expected end state, not a failure. |
| 2 | Power off + snapshot | Guest shut down gracefully via `govc vm.power -s Plex` (VMware Tools `toolsOk`); confirmed `plexmediaserver` was still `inactive`/`disabled` first. Snapshot `pre-docker-migration 2026-09-16` then removed **while powered off** — safer consolidation than the plan's on-line ordering. Disk collapsed `Plex-000001.vmdk` → `Plex.vmdk`, `runtime.consolidationNeeded = false`. |
| 3 | Delete VMDKs | **Done 2026-09-17**, ahead of the planned ~2026-10-15 — see "Early deletion" below. `govc vm.destroy Plex` removed the inventory entry and the `[NVME_1] Plex/` folder; datastore free space went 1,619.8 → 1,756.1 GB (+136.3 GB). |
| 4 | Guacamole "Plex SSH" | Deleted via the Guacamole API (connection id 3, `HTTP 204`). **The `guacamole` role is create-only** (`when: item.name not in guac_existing_names`), so it cannot remove a connection — the entry was also deleted from `roles/guacamole/defaults/main.yml` so a rebuild does not recreate it. |
| 5 | Addresses | Neither MAC had a Unifi fixed reservation, so `.24`/`10.50.0.31` were plain leases; both clients (`00:0c:29:4a:ef:3d`, `00:0c:29:4a:ef:47`) were forgotten via `cmd/stamgr`. |
| 6 | Docs | [plex.md](plex.md), [CLAUDE.md](CLAUDE.md), [esxi.md](esxi.md), [veeam.md](veeam.md). `docker.md` already listed `plex`. ESXi port-group counts corrected LAN_100 7→6 and DATA_50 4→3, verified by counting NICs on powered-on VMs. |
| 7 | Memory | `plex-docker-migration` and `debian-11-eol-2026-08-31` updated. |
**Safety net still in place:** the powered-off VM with intact disks, plus the verified off-box library DB at
`/mnt/share/backups/plex-migration-2026-09-16/` (429 MB, WAL checkpointed to 0 bytes; reads clean — 70,761 `media_parts`,
48,395 `metadata_items`, 3 `section_locations`).
> Note: a plain `sqlite3` `integrity_check`/`quick_check` on a Plex library DB fails with
> `unknown tokenizer: collating`. That is the stock binary lacking Plex's custom collation on the FTS virtual tables, **not**
> corruption — use `Plex SQLite` for a real whole-DB check. Ordinary table queries work fine with stock `sqlite3`.
### Early deletion of the VMDKs — 2026-09-17
The disks were destroyed the same day rather than at the planned ~2026-10-15. Checking the Veeam precondition first turned
up something that inverted the plan's assumption and is worth recording:
- A **consistent, uncorrupted restore point exists at 2026-09-17 03:01**, captured after the cutover and before Plex was
removed from the job that morning. The chain runs 2026-09-05 → 2026-09-17 with a Full on 2026-09-12.
- But the `Backup VMs` job has **`EnableDeletedVmDataRetention: False`** with Simple retention of 7 restore points. A VM
removed from the job stops generating points, so its data is pruned as the remaining VMs roll the chain — Plex's points
are expected to disappear around **2026-09-24**.
So the plan's "keep the disks until mid-October as the rollback" was resting on a Veeam safety net that expires roughly
three weeks *earlier* than the disks were due to be deleted. Deleting on 2026-09-17, while a good restore point still
existed, was strictly safer than deleting on 2026-10-15 with none.
**Current recovery position:** no VM to restore. Until ~2026-09-24 a Veeam image restore is possible; after that the only
artifact is the pre-migration library DB at `/mnt/share/backups/plex-migration-2026-09-16/`, which rebuilds a Plex install
(libraries, watch history, `MachineIdentifier` via `Preferences.xml`) but not the VM.
**If a longer-lived VM-level copy is ever wanted for another decommission**, export the restore point to a standalone full
backup *before* removing the VM from its job — job retention does not protect it otherwise.
---
## 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.