5a4da57457
Plex has run as a container on the Docker host since the 2026-09-16 migration. The old Debian 11 VM (10.100.0.24) is now retired per plex-docker-migration.md section 9, steps 1, 2 and 4-7: - removed from the Veeam "Backup VMs" job - guest shut down gracefully, pre-migration snapshot removed and consolidated back to Plex.vmdk - Guacamole "Plex SSH" connection deleted - .24 / 10.50.0.31 DHCP leases released on the UDM The VMDKs are deliberately kept until ~2026-10-15 so the VM remains a one-command rollback; step 3 stays open until then. Before powering the VM off, every integration was re-verified as pointing at 10.100.0.35: Sonarr, Radarr, Tautulli, seerr, Uptime Kuma, Homepage, Traefik and the UDM PLEX port-forward. Section 9 gains a decommission log recording two behaviours that cost time to work out: Veeam turns a removed job include into an explicit Exclude tombstone rather than deleting it, and a plain sqlite3 integrity_check on a Plex library DB fails with "unknown tokenizer: collating" because the stock binary lacks Plex's FTS collation - that is not corruption. ESXi port-group counts corrected (LAN_100 7->6, DATA_50 4->3), verified by counting NICs on powered-on VMs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
598 lines
36 KiB
Markdown
598 lines
36 KiB
Markdown
# 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 steps 1, 2 and 4-7 are **done** (see the log at the end of §9); step 3 — deleting the VMDKs — is deliberately **deferred to ~2026-10-15**.
|
||
**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 | **Deferred to ~2026-10-15**, per plan. |
|
||
| 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`.
|
||
|
||
**Before deleting the VMDKs**, confirm no Veeam restore point is still needed, then:
|
||
`govc vm.destroy Plex` (removes the VM and its `[NVME_1] Plex/` folder).
|
||
|
||
---
|
||
|
||
## 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.
|