# 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/` 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= 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.