Files
network/plex-docker-migration.md
T
dohertj2 91ec7d1762 Migrate Plex from ESXi VM to Docker container
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>
2026-09-16 08:55:43 -04:00

33 KiB
Raw Blame History

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.org bullseye-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.yml handles image updates, Semaphore runs it.
  • Reclaims an 8-vCPU / 7.8 GB / 125 GB VM from the ESXi host.
  • Plex has no Plex Pass (PlexOnlineHome="0", no HardwareAcceleratedCodecs pref) so all transcoding is software. Moving from 8 vCPUs to the docker host's 22 vCPUs is a transcode capacity gain, and neither host has usable VAAPI anyway — both are VMware SVGA (vmwgfx), so there is nothing to lose.

2. Measured current state

Data to migrate — 39 GB total, ~32 GB after pruning

Source: /var/lib/plexmediaserver/Library/Application Support/Plex Media Server/

Directory Size Files Migrate? Contents
Metadata/ 22 G 77,187 ✅ Posters, art, season/episode metadata (TV 13 G, Movies 9.3 G)
Plug-in Support/ 9.4 G 2,692 ⚠️ partial Databases (see below) + plugin prefs
Media/ 7.7 G 236,131 ✅ Video preview thumbnails (index-sd.bif) + media bundles
Cache/ 369 M — ⚠️ partial PhotoTranscoder/ (web-UI thumbnails) ✅; Transcode/ ❌
Scanners/ 93 M 3 ✅ Custom scanners
Codecs/ 17 M 26 ❌ Rebuilt by the container for its own arch/build
Logs/, Crash Reports/, Updates/, Diagnostics/ 52 M — ❌ Disposable

Databases (Plug-in Support/Databases/)

com.plexapp.plugins.library.db          449 M   ← main library DB
com.plexapp.plugins.library.db-wal      459 M   ← HOT: must be checkpointed
com.plexapp.plugins.library.blobs.db    1.2 G   ← blobs (image/bundle data)
com.plexapp.plugins.library.blobs.db-wal 1.2 G  ← HOT: must be checkpointed
*.db-2026-09-04 / -09-07 / -09-10 / -09-13     ← 6.6 G of scheduled backups — SKIP

Those two WAL files total 1.7 GB of uncommitted data. Copying the DB without a clean shutdown + checkpoint is the single most likely way to corrupt this migration. Section 6 handles it.

Library section roots (from com.plexapp.plugins.library.db)

ID Name Type Root path
1 Movies movie /mnt/nfs/Movies
2 TV Shows show /mnt/nfs/TV
4 Anime show /mnt/nfs/Anime

Every row in media_parts.file is prefixed with one of those three paths.

Server identity (Preferences.xml)

MachineIdentifier            = bfed40ab-04ac-4f33-9e45-f8ba9f6416e9
ProcessedMachineIdentifier   = 7aa65063599874eac3d9a2f62df74c1816400c88
PlexOnlineToken              = dz2m…
PlexOnlineHome               = 0    (no Plex Pass)

ProcessedMachineIdentifier is what Tautulli, seerr and plex.tv key off. Carrying Preferences.xml across preserves it — clients, shares, watch history and Tautulli's history all follow automatically.

Target host readiness — all green

Check Result
Free space on / 443 G free of 999 G (need ~32 G)
CPU / RAM 22 vCPU, 31 G RAM (17 G available), load avg ~2–3
Ports 32400, 3005, 8324, 32469, 1900/udp, 5353/udp, 32410–32414/udp all free; avahi-daemon inactive
/mnt/share NFS mounted, vers=4.2 nconnect=8 from 10.50.0.25, 57 T free
Media present /mnt/share/Media/{Movies,TV,Anime} ✅
traefik docker network exists, external
DATA_50 link docker host 25 Gb/s, plex VM 10 Gb/s — transfer runs over this

3. Design decisions

3.1 Media path strategy — Decision 1

The Plex DB stores absolute media paths. The container's mount layout must account for them. Measured extent of the problem:

Table Column Rows containing /mnt/nfs/…
media_parts file 31,623
media_streams url 636 ← external subtitle/artwork sidecars
section_locations root_path 3
directories path 0 — stores relative paths (2 Fast 2 Furious (2003)), unaffected
total 32,262

Earlier drafts of this plan said ~236k rows. That was wrong — 236,131 is the file count in the Media/ thumbnail directory, a different thing. The real figure is 32,262 rows, which makes Option B below genuinely viable rather than absurd.

Options

Option Rows touched Container paths Risk
A ✅ Bind-mount at the DB's existing paths — /mnt/share/Media/Movies:/mnt/nfs/Movies etc. 0 /mnt/nfs/* (legacy) None. DB never opened.
B Rewrite the DB offline, then use clean paths (/data/movies) matching the arr-stack convention 32,262 across 3 tables /data/* (consistent) Moderate — see below
C Re-path each library in the Plex UI — /data/* Rejected. Plex treats a changed root as new content: items whose old paths vanish get removed and re-added, losing watch state, ratings and collections unless the full "Plex Dance" is performed per library.
D Host symlinks /mnt/nfs/X -> /mnt/share/Media/X, mount /mnt/nfs in 0 /mnt/nfs/* Works, but adds host state outside Ansible for no gain over A.

Selected: A — bind-mount at the DB's existing paths (ratified 2026-09-16)

The deciding argument is not that B is hard — with the server stopped it is three UPDATE … replace() statements through Plex SQLite — it is that A is free and does not foreclose B. Nothing about bind-mounting at legacy paths prevents doing the rewrite later, at leisure, with no cutover clock running and a container that can be trivially rolled back. Doing it during the migration stacks a schema-editing risk on top of a data-copy risk on the one evening where both must work.

The honest cost of A: /mnt/nfs/* inside the Plex container is a fossil of the old VM and reads oddly next to Sonarr's /data/tv. That is a cosmetic wart on a single compose file, documented in the role.

The specific trap in B, if it is ever done: media_streams.url (636 rows) is the row set everyone forgets. A rewrite that only covers section_locations and media_parts looks completely successful — libraries scan, playback works — and silently breaks every external subtitle file.

3.2 Container networking — Decision 2

What the evidence actually supports

Checked against the live server rather than assumed:

  • DlnaEnabled="0" — DLNA is off. Earlier drafts listed DLNA (1900/udp, 32469/tcp) as a reason for host networking. It is not one. Struck.
  • ManualPortMappingMode="1", LastAutomaticMappedPort="0" — Plex is not doing UPnP/NAT-PMP; the static UDM forward is the sole remote path, as documented.
  • ESXi LAN_100 portgroup: Allow promiscuous mode: No, Allow forged transmits: No, Allow MAC changes: No. This is decisive for Option C below.

Options

Option Client source IPs Local discovery Traefik route Blocker
A ✅ network_mode: host Real ✅ GDM works ✅ via existing file provider None — all 9 ports verified free, avahi-daemon inactive
B Bridge + published ports ❌ all collapse to the docker gateway ❌ no GDM ✅ Needs ADVERTISE_IP=http://10.100.0.35:32400/ or Plex advertises a useless container IP to plex.tv
C macvlan, reusing 10.100.0.24 Real ✅ ❌ breaks Hard blocker — see below

Why C is blocked, despite being the most attractive on paper

macvlan is genuinely tempting: keeping 10.100.0.24 would make all of Section 7 unnecessary — no port-forward edit, no Sonarr/Radarr connection surgery, no Tautulli/seerr/Kuma re-pointing. The single most error-prone part of this migration would simply evaporate.

It does not survive contact with the hypervisor. macvlan puts a second MAC address behind the VM's vNIC, which ESXi drops unless the portgroup permits it — and LAN_100 currently sets promiscuous mode, forged transmits and MAC changes all to No. Enabling them is not a Plex-scoped change: it is a security-posture change applied to every VM on VLAN 100, to make one container's networking tidier.

Secondary, and independent of ESXi: a bridge-network container (Traefik) cannot reach a macvlan container — its traffic is SNAT'd to the host IP, and host↔macvlan is exactly the path macvlan isolates. The plex.dohertylan.com route would need a macvlan shim interface to survive.

Why not B

The client-IP collapse is the real problem, and it is worse than it first looks. With every client arriving from the docker gateway, Plex classifies all of them — LAN and internet alike — as remote, and applies remote streaming bitrate limits to local playback. The apparent fix, declaring the bridge subnet under Plex's LAN Networks setting, inverts the bug into a worse one: it would classify genuinely remote clients as local, removing remote bandwidth limits entirely on an internet-exposed server. Tautulli's per-client statistics degrade the same way.

Selected: A — network_mode: host (ratified 2026-09-16)

Even with the DLNA argument withdrawn, the two remaining reasons stand on their own: real client source IPs (correct local/remote classification, correct Tautulli stats) and GDM discovery on 32410–32414/udp.

The cost is honest and worth stating: host networking gives the container the host's full network namespace, so it is weaker isolation for a process that is deliberately exposed to the internet on 32400. Bridge mode would confine a compromised Plex to the bridge. That is a real argument for B — it is simply outweighed by B breaking local/remote classification on a server whose main job is streaming.

3.2a Consequences carried by these two choices

Both selections are the low-risk path, but each leaves one thing on the books:

  • From 3.1 A — container media paths remain /mnt/nfs/*, inconsistent with the arr stack's /data/*. This is cosmetic and documented in the role. If it ever becomes worth fixing, Option B (32,262 rows across section_locations, media_parts, and media_streams) can be done later against a container that rolls back trivially. It is not a migration-night task.
  • From 3.2 A — Plex runs in the host network namespace while being deliberately internet-exposed on 32400. Worth pairing with the hygiene that already applies to any internet-facing service: keep the image current via update.yml (§4), and note that a Plex compromise now reaches the docker host's network directly rather than a bridge.

3.3 Image: lscr.io/linuxserver/plex

Consistent with sonarr/radarr/prowlarr/whisparr already on lscr.io, same PUID/PGID/TZ convention, same /config layout as the current VM tree.

VERSION=docker is mandatory. It disables the container's built-in self-updater so the image tag is the sole source of version truth — which is what makes update.yml (docker compose pull → up -d) the real update mechanism. Leaving VERSION=latest would have Plex silently upgrade itself inside the container, drifting from the image and losing the change on every recreate.

Do not set PLEX_CLAIM. It is for first-time claiming only; on a migrated Preferences.xml it would re-claim the server and change its identity.

3.4 Transcode scratch on local disk

/opt/plex/transcode on /dev/sda1 (443 G free), mounted at /transcode. Never on NFS (kills transcode throughput and hammers the storage VLAN). tmpfs was considered but rejected — only 17 G RAM is available and a 4K transcode buffer can spike hard.

3.5 Directory layout

/opt/plex/
├── docker-compose.yml          ← rendered by Ansible; what update.yml operates on
├── config/                     ← ~32 GB, bind-mounted to /config
│   └── Library/Application Support/Plex Media Server/
└── transcode/                  ← scratch, mounted at /transcode

Slight deviation from the /opt/<svc> convention (config in a subdir rather than at the root) so 32 GB of library data doesn't sit alongside the compose file. update.yml only requires /opt/plex/docker-compose.yml, which is satisfied.


4. Ansible role — roles/plex/

Repo: /home/dohertj2/playbook → GitHub dohejw01/ansiblearr. Committed and pushed 2026-09-16 (04cdb9d).

Scope: deploy and update only. The role creates directories, renders the compose file and runs the stack. It deliberately does not touch /opt/plex/config contents — the library is one-time migrated data, not rendered config, and nothing about copying 32 GB across once belongs in a playbook that reruns.

That makes the ordering load-bearing: §6 copies the data, then §6.4 runs the role. Reversed, the container starts empty, generates a fresh Preferences.xml with a new MachineIdentifier, and the copy then overwrites it — recoverable, but it defeats the identity-preservation the whole plan is built on. Do not run --tags plex before §6.3 passes.

roles/plex/defaults/main.yml

---
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/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:

    - 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):
    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):
    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) — 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.
  6. 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: 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

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 → .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 (rewrite for the container; also drop the misleading plex.lan.dohertylan.com FQDN line — it is a wildcard, not a host record), docker.md (add plex to Media Management), CLAUDE.md line 24, esxi.md lines 71/109/110, 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.

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.