Plex ran on a dedicated Debian 11 VM at 10.100.0.24 until that OS reached LTS EOL on 2026-08-31 and apt security updates started 404ing. It now runs as a host-networked container on the Docker host at 10.100.0.35. Adds plex-docker-migration.md: the decision record and the executed procedure. The two choices worth knowing about are that media is bind mounted at the paths the library DB already stores (/mnt/nfs/*) so the migration rewrote none of its 32,262 absolute paths, and that the container runs on host networking so Plex still sees real client source IPs and does not throttle LAN playback as remote. Preferences.xml was carried across, so the server kept machine identifier 7aa65063599874eac3d9a2f62df74c1816400c88 and Tautulli, seerr and existing clients continued working without reconfiguration. Verified after cutover: libraries 1959/421/146 and 11,481 history entries, all matching the pre-migration baseline exactly. Rewrites plex.md for the container, adds plex to docker.md, and updates the component list in CLAUDE.md. The old VM is retained powered on with plexmediaserver stopped and disabled as rollback, so docs for ESXi and Veeam are deliberately left describing it until decommission. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
33 KiB
Plex Migration: ESXi VM → Docker Container
Status: EXECUTED 2026-09-16. Decisions 1 & 2 ratified as Option A / Option A. Cutover completed with ~22 min of Plex downtime (transfer itself 5m38s). All verification in §8 passed except the two items needing a real client, noted there. The old VM is powered on with plexmediaserver stopped+disabled; §9 decommission is not done.
Created: 2026-09-16
Source: plex VM, 10.100.0.24, Debian 11 (EOL)
Target: DOCKER host, 10.100.0.35, Debian 13, containerised + Ansible-managed
1. Why
- Debian 11 hit LTS EOL on 2026-08-31.
security.debian.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.
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.