Decomposing the Monolith: Per-Service LXCs on Proxmox

By Anas Semesmieh · June 8, 2026 · Homelab, Proxmox, Networking, Self-Hosting

Twelve hours into the migration, the NIC dropped. SSH sessions froze, containers went dark, and dmesg filled with e1000e: Detected Hardware Unit Hang. Not the best welcome for a freshly decomposed architecture — but I'm getting ahead of myself.

The previous post covered moving the bare-metal homelab into a Proxmox VM via direct SSD passthrough. That got everything running under a hypervisor, but the monolith pattern remained: one VM, one SSD, one blast radius. This post covers the next step — extracting four services into dedicated LXC containers on NVMe storage, right-sizing the legacy VM, and hardening a flaky Intel NIC that tried to take the whole stack down on day one.

Why Decompose

The P2V migration preserved uptime, but it also preserved every problem the bare-metal setup had:

The goal: extract each non-Docker service into its own LXC container on NVMe, with independent lifecycle, independent snapshots, and right-sized resources. The Docker stacks stay on VM100 for now — they're a future phase.

The Target Architecture

IP Service Deployment Storage
192.168.20.70 AdGuard Home LXC CT 101 NVMe (local-lvm)
192.168.20.71 Caddy + cloudflared LXC CT 102 NVMe (local-lvm)
192.168.20.72 Plex Media Server LXC CT 103 NVMe (local-lvm)
(host) Tailscale subnet router Proxmox host native NVMe (host root)
192.168.20.69 Docker stacks (interim) VM 100 Samsung SSD 870

LXC containers share the host kernel, start in under a second, and use a fraction of the memory a full VM would need. On Proxmox's LVM-thin storage, snapshots are copy-on-write — they cost nearly zero space until blocks change. This makes per-service containers the right tool for services that don't need their own kernel.

Phase 1 — AdGuard Home (CT 101)

DNS is the most critical service in the stack. Everything depends on it, nothing depends on everything else. That makes it the natural first candidate.

Fresh Debian 12 LXC: 1024 MB RAM, 1 vCPU, 4 GB disk, static IP 192.168.20.70. AdGuard Home installed via the official one-liner, then the YAML config imported from VM100.

# Key sections from AdGuardHome.yaml
dns:
  bind_hosts:
    - 0.0.0.0
  port: 53
  upstream_dns:
    - https://dns.quad9.net/dns-query
  bootstrap_dns:
    - 9.9.9.10

http:
  address: 0.0.0.0:8888

Verification from another host:

dig @192.168.20.70 google.com +short
# 142.250.80.46
The LXC was originally provisioned with 512 MB. That worked for about ten minutes — then I noticed heavy swapping. AdGuard loads 3.5 million filter rules at startup, which takes real memory and ~45–60 seconds of CPU-bound parsing before DNS port 53 is ready. Bumped to 1024 MB and the problem disappeared.

After confirming resolution on .70, the router's DHCP was updated to advertise the new DNS. The old AdGuard instance on VM100 was stopped and disabled.

Phase 2 — Tailscale on the Proxmox Host

Tailscale provides remote access to the LAN and NAS networks via subnet routing. It runs on the Proxmox host directly rather than in an LXC — subnet routing needs the cleanest possible L3 path without an extra network namespace in the way.

# Enable IP forwarding
echo 'net.ipv4.ip_forward=1' >> /etc/sysctl.d/99-tailscale.conf
sysctl -p /etc/sysctl.d/99-tailscale.conf

# Start with subnet routes, don't override host DNS
tailscale up --advertise-routes=192.168.20.0/24,10.0.0.0/24 --accept-dns=false

This is a hard cutover — two Tailscale nodes advertising the same subnet routes will conflict. The old Tailscale on VM100 has to be stopped before the new one starts. No parallel-run here.

The subtle gotcha: Tailscale's admin console had the global DNS resolver pointed at VM100's tailnet IP (100.108.11.76). After stopping Tailscale on VM100, every tailnet device lost DNS resolution. The fix: update the Tailscale admin console to use 192.168.20.70 (the new AdGuard LXC) as the global nameserver, with split DNS for semesmieh.com.

Phase 3 — Caddy + cloudflared (CT 102)

Caddy handles TLS termination and reverse proxying for all *.semesmieh.com subdomains. Cloudflared runs a Cloudflare Tunnel alongside it, providing external access without opening firewall ports.

Fresh Debian 12 LXC: 512 MB RAM, 1 vCPU, 8 GB disk, static IP 192.168.20.71. Caddy was built from source with the Cloudflare DNS module using xcaddy:

xcaddy build --with github.com/caddy-dns/cloudflare --output /usr/local/bin/caddy

The Caddyfile was imported from VM100 with backend IPs adjusted. A few representative blocks:

{
    acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}

home.semesmieh.com {
    reverse_proxy 192.168.20.69:3000
}

plex.semesmieh.com {
    reverse_proxy http://192.168.20.72:32400 {
        header_down Location http:// https://
    }
}

pve.semesmieh.com {
    reverse_proxy https://192.168.20.99:8006 {
        transport http {
            tls_insecure_skip_verify
        }
    }
}

The Cloudflare API token is stored in /etc/caddy/environment (mode 0600, owned by the caddy user) and loaded via the systemd unit's EnvironmentFile directive. Cloudflared runs as a separate systemd service with the tunnel token passed on the command line.

One gotcha: building Caddy with Go required more disk than the original 4 GB allocation. I expanded the container to 8 GB during the build, then cleaned up Go and xcaddy afterwards — the container settled at 756 MB used.

Phase 4 — Plex with iGPU Passthrough (CT 103)

Plex is the most resource-intensive migration: large metadata directory, hardware transcoding via Intel iGPU, and dual-network access to both LAN clients and NAS storage.

Fresh Debian 12 LXC: 8 GB RAM, 4 vCPUs, 100 GB disk, static IP 192.168.20.72 on LAN, 10.0.0.40 on the NAS network. Privileged container to simplify /dev/dri passthrough.

iGPU Passthrough

Two lines in /etc/pve/lxc/103.conf:

lxc.cgroup2.devices.allow: c 226:* rwm
lxc.mount.entry: /dev/dri dev/dri none bind,optional,create=dir

After starting the container, vainfo confirms the Intel iHD driver with full encode/decode profile support:

vainfo
# libva info: VA-API version 1.20.0
# libva info: va_openDriver() returns 0
# vainfo: Driver version: Intel iHD driver - 24.1.0

NFS Media Mounts

The container's second NIC (eth1) connects to the NAS bridge (vmbr1) with a static IP of 10.0.0.40. NFS shares are mounted via /etc/fstab:

10.0.0.10:/volume1/media   /mnt/media   nfs defaults,nofail 0 0
10.0.0.10:/volume1/movies  /mnt/movies  nfs defaults,nofail 0 0
10.0.0.10:/volume1/series  /mnt/series  nfs defaults,nofail 0 0

Data Migration

Plex metadata (watch history, posters, thumbnails, database) is 19 GB. Rather than tarring and transferring, I used rsync directly from the Proxmox host into the container's rootfs:

# Stop Plex on VM100 first — two servers with the same identity will conflict
ssh anas@192.168.20.69 "sudo systemctl disable --now plexmediaserver"

# Rsync metadata from VM100 into CT 103's rootfs
rsync -aP --numeric-ids anas@192.168.20.69:/var/lib/plexmediaserver/ \
  /var/lib/lxc/103/rootfs/var/lib/plexmediaserver/

# Fix ownership inside the container
pct exec 103 -- chown -R plex:plex /var/lib/plexmediaserver/

After starting Plex, the web UI shows the setup wizard with claimed="0". This is expected — the server IP changed, so Plex needs a manual re-claim via the web UI. Libraries and metadata appear intact once claimed.

The Coexistence Dance

Not every service can run in parallel during migration. I found two patterns:

The migration order matters because of this. DNS goes first (everything depends on it, and it's parallel-safe). Tailscale goes second (remote access to manage remaining steps). Caddy goes third (ingress depends on backends being reachable). Plex goes last (largest data transfer, most complex setup).

Day-One Surprise — Intel e1000e NIC Drops

Midway through the Plex migration, the Proxmox host's NIC started dropping. SSH sessions hung, all containers became unreachable, and dmesg showed the telltale error:

e1000e 0000:00:1f.6 lan: Detected Hardware Unit Hang:
  TDH                  <0>
  TDT                  <1>
  next_to_use          <1>
  next_to_clean        <0>

The root cause: EEE (Energy Efficient Ethernet) negotiation between the Intel I219-LM NIC and an unmanaged switch. The NIC enters low-power states that the switch can't properly handle, causing PHY resets that cascade into full network loss. Since the switch is unmanaged, all tuning has to be host-side.

The Hardening Stack

Three layers address the problem at different points in the boot sequence:

1. Kernel module options — applied at driver load via /etc/modprobe.d/e1000e.conf:

# Disable Energy Efficient Ethernet at the driver level
options e1000e SmartPowerDownEnable=0

# Adaptive interrupt throttle rate (balances latency vs CPU)
options e1000e InterruptThrottleRate=3

2. Boot-time tuning — a systemd oneshot service runs ethtool before networking comes up:

#!/bin/bash
# /usr/local/bin/disable-eee.sh
IFACE=lan

# Disable EEE
ethtool --set-eee "$IFACE" eee off 2>/dev/null || true

# Disable flow control (can interact with EEE to cause hangs)
ethtool -A "$IFACE" rx off tx off 2>/dev/null || true

# Disable TSO/GSO/GRO (known to cause issues with e1000e under load)
ethtool -K "$IFACE" tso off gso off gro off 2>/dev/null || true

3. Runtime health check — a systemd timer runs every 60 seconds, pinging the gateway and watching for hang messages in dmesg. Three consecutive failures trigger an automatic NIC restart with EEE re-disable:

# /etc/systemd/system/e1000e-health-check.timer
[Timer]
OnBootSec=30
OnUnitActiveSec=60
AccuracySec=5
Persistent=true

The health check script monitors link state, carrier, gateway reachability, and dmesg for Hardware Unit Hang messages. If the NIC locks up, it brings the interface down and back up, re-applies all EEE and offload disables, and verifies recovery — all without human intervention.

Deploying It

The hardening files are packaged as a self-contained directory with a deploy script:

# Copy to host and run
scp -r e1000e-hardening/ root@192.168.20.99:/tmp/
ssh root@192.168.20.99 "bash /tmp/e1000e-hardening/deploy-e1000e-hardening.sh"

Verify:

# EEE should show "disabled"
ethtool --show-eee lan

# Timer should be active
systemctl status e1000e-health-check.timer

# Watch health checks in real time
journalctl -t e1000e-health -f

Since deploying this stack, the NIC has been stable with zero hangs.

Resource Rightsizing

With AdGuard and Plex extracted from VM100, the legacy VM no longer needs its original allocation. The results after reducing VM100 from 14 GB / 8 cores to 8 GB / 4 cores:

Metric Before After
VM100 RAM 14 GB 8 GB
VM100 cores 8 4
Host RAM free 430 MB 12 GB
Host swap used 4.2 GB 0

The monolith was hoarding resources its remaining services didn't need. After decomposition, VM100's Docker stacks run comfortably in 8 GB — with the host finally having headroom for the three new LXCs and future workloads.

Backup Strategy

Two tiers protect the new containers:

Local LVM-thin snapshots — instant, copy-on-write, named zero-day for each container. These cost nearly zero space and provide immediate rollback if a configuration change goes wrong.

Compressed backups to NAS — full vzdump archives with zstd compression, stored on the Synology NAS via NFS:

Container Service Archive Size
CT 101 AdGuard Home 229 MB
CT 102 Caddy + cloudflared 245 MB
CT 103 Plex Media Server 16.6 GB

A scheduled vzdump job runs weekly on Wednesday at 2 AM, retaining the last three backups. Between scheduled runs, the local thin snapshots provide instant protection.

What's Still on VM100

The Docker stacks remain on VM100 until a future migration phase:

These are all containerized via Docker Compose and can be migrated to a new VM or set of LXCs independently. The SSD stays in the system until they're moved.

Lessons Learned

Lesson Detail
DNS is always the last thing you check Tailscale admin console DNS, AdGuard DNS rewrites, and router DHCP all needed updating separately. Each pointed to a different IP from a different era of the setup.
Filter lists have a memory cost 3.5 million AdGuard filter rules need real RAM. The 512 MB default was fine for the binary but not for the dataset it loads at startup.
iGPU passthrough is trivial in privileged LXCs Two lines in the LXC config and an intel-media-driver install. No IOMMU group wrangling, no VFIO stubbing. Privileged containers just bind-mount /dev/dri.
EEE + unmanaged switch = silent killer The Intel I219-LM will negotiate EEE by default. If the switch can't handle the low-power transitions, the NIC hangs silently. Disable EEE proactively on e1000e NICs behind unmanaged switches.
Snapshot before you touch anything Local thin snapshots are instant and free. Taking a zero-day snapshot of every container before the first production workload means you never have to care about "what if the initial setup was wrong."
Right-size after decomposition The monolith VM was allocated for peak load across all services. After extracting 40% of its workload, it was over-provisioned by 6 GB of RAM and 4 cores. The host was swapping for no reason.

Closing Thought

Each container is now independently snapshotable, restorable, and upgradeable. AdGuard can be rolled back without touching Plex. Caddy can be rebuilt from scratch without losing a single DNS record. The NIC hardening means the host survives hardware quirks without waiting for someone to notice and SSH in.

The SSD is still in the chassis, serving Docker stacks that haven't moved yet. Once they do, it comes out — and the entire homelab runs on a single NVMe drive, decomposed into purpose-built containers with backup coverage at every layer.