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>
38 KiB
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.orgbullseye-security now 404s; the VM receives no further OS security patches (see 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.ymlhandles 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", noHardwareAcceleratedCodecspref) 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_100portgroup: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 acrosssection_locations,media_parts, andmedia_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
---
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
---
- 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
---
- name: Restart plex stack
ansible.builtin.shell: docker compose up -d --force-recreate
args:
chdir: "{{ plex_dir }}"
roles/plex/templates/docker-compose.yml.j2
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/driis passed through for forward-compatibility only — it is currentlyvmwgfx(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:
- 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)
- Write and push the role — role files,
site.yml,update.yml. Push to GitHub. - Verify Plex Pass status is understood — no Plex Pass, so no hardware transcode and no
plexpassimage tag. Confirmed fromPlexOnlineHome="0". - Confirm the plex.tv token still works before touching anything; a dead token means the server would need re-claiming after the move (
curlis not installed on the Plex VM — run this from the docker host):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" - Back up the library DB via Plex's own SQLite (safe while running, and the only supported way to get a consistent copy):
Keep this off-box. It is the last-resort artifact if both the container and the VM go wrong.
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'" - Snapshot the VM via
govc(see esxi.md) — the real rollback. Note the constraint from Veeam: the VM must have no PCI passthrough for snapshots to work; the Plex VM has none, so this is fine. - Pre-create the target tree so the cold copy writes into an existing, correctly-owned directory:
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:
rsyncis not installed on the Plex VM, andaptis broken there (Debian 11 EOL, security mirror 404s), so installing it is not straightforward. Both hosts havetarandnc. At 10 Gb/s a single coldtarstream is fast enough that a warm pre-seed buys nothing.
6.1 Stop Plex and checkpoint the WAL
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:
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:
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.
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:
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 |
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:
# 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. |
| 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. |
Note on C1: the Plex VM has no DHCP reservation — it is a plain lease, which is exactly how it drifted
.23 → .24and 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.comresolves to208.161.27.232(the WAN IP) via a wildcard*.lan.dohertylan.comrecord, not a local A record pointing at.24. The UDM holds zero static DNS records. (plex.mdpresents this FQDN as if it were a real host record; it is a wildcard artifact.) - TrueNAS NFS exports —
/mnt/mypool/sharehas emptyhostsandnetworksallow-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
machineIdentifierfrom/identitymatchesbfed40ab-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/*.biffiles) - 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/transcodeon 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.comloads 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 plexcomes back clean;rebootof the docker host brings Plex back automaticallyansible-playbook update.yml -e only=plexpulls 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)
cd /opt/plex && docker compose down- On the VM:
sudo systemctl enable --now plexmediaserver - Revert the UDM port-forward to
10.100.0.24and 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)
- Remove Plex from the Veeam
Backup VMsjob (C2) — do this first, or the next run fails on a missing VM. govcsnapshot removal, then power off the VM.- Keep the powered-off VM for another 2–4 weeks before deleting the VMDKs.
- Remove the Guacamole "Plex SSH" entry (A6).
- Release the
.24LAN_100 lease and the10.50.0.31DATA_50 address. - Update docs: plex.md (rewrite for the container; also drop the misleading
plex.lan.dohertylan.comFQDN line — it is a wildcard, not a host record), docker.md (addplexto Media Management), CLAUDE.md line 24, esxi.md lines 71/109/110, veeam.md line 58. - Update memory: the port-forward now targets
.35(superseding the 2026-09-16.24fix), anddebian-11-eol-2026-08-31no 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, CLAUDE.md, esxi.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
sqlite3integrity_check/quick_checkon a Plex library DB fails withunknown tokenizer: collating. That is the stock binary lacking Plex's custom collation on the FTS virtual tables, not corruption — usePlex SQLitefor a real whole-DB check. Ordinary table queries work fine with stocksqlite3.
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 VMsjob hasEnableDeletedVmDataRetention: Falsewith 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
- Back up
/opt/plex/config? Partly answered: the VeeamBackup VMsjob already covers both Plex and DOCKER, so after the move/opt/plexis 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 periodicPlex SQLite .backupof 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. - Media mounts read-write or read-only? The plan keeps
rwto match current VM behaviour (needed for "Delete media" from the Plex UI and for local media assets).rois a safe hardening step if that is never used. - Retire the
plex.dohertylan.comTraefik route? It exists but Plex never advertises it (customConnectionsunset), so it is a convenience bookmark, not a client path. Keeping it costs nothing.