Finishing the Decomposition: Every Service Gets Its Own Container
The previous post ended with a promise: “Docker stacks stay on VM100 for now — they’re a future phase.” That future phase happened the next day. Twelve services, five new LXC containers, one decommissioned VM. The Samsung SSD is cold storage now, and the entire homelab runs on a single NVMe drive at 31% memory utilization.
The Remaining Targets
After extracting AdGuard, Caddy, and Plex into their own LXCs, VM100 still hosted ten Docker containers across five logical stacks. Each needed a new home:
| Service | CT | IP | Resources | Key Requirement |
|---|---|---|---|---|
| Vaultwarden | 104 | .73 | 1 core, 512 MB, 4 GB | Minimal footprint, standalone |
| Jellyfin + Tautulli + Jellystat | 103 | .72 | 4 cores, 8 GB, 100 GB | iGPU shared with Plex, NFS media, dual NIC |
| Scrypted | 106 | .76 | 2 cores, 2 GB, 8 GB | iGPU for video analysis, host networking |
| Arrstack (8 containers) | 107 | .77 | 2 cores, 4 GB, 20 GB | /dev/net/tun for VPN, dual NIC for NFS |
| Homepage + Dockhand | 108 | .78 | 1 core, 1 GB, 4 GB | Docker socket access to all hosts |
CT 105 was intentionally left empty — reserved for future use. The numbering isn’t sequential because good planning accounts for growth.
The Migration Pattern
Every migration followed the same five-step pattern. Once it worked for Vaultwarden, the rest were variations on a theme:
- Create privileged LXC — right-sized resources, static IP on vmbr0 (and vmbr1 for NFS-dependent services). Privileged because
/dev/driand/dev/net/tunpassthrough is trivial in privileged mode. - Install Docker + Compose — standard Docker CE install from the official repo. Took ~90 seconds per container.
- Stage data from Proxmox host — all VM100 data was pre-staged to
/root/migration/on the Proxmox host during the P2V phase. SCP from host into the new CT. - Deploy Compose stack — adapted
docker-compose.ymlfor the new environment (updated IPs, switched named volumes to bind mounts, removed cross-stack network dependencies). - Update Caddy routes and reload —
sedthe backend IP in the Caddyfile,systemctl reload caddy. Zero-downtime cutover.
Total time from empty LXC to verified service: ~5 minutes for simple stacks (Vaultwarden), ~15 minutes for complex ones (Arrstack with 8 containers and VPN tunneling).
Sharing an iGPU Across Three Containers
CT 103 runs native Plex and Dockerized Jellyfin. CT 106 runs Scrypted for camera analytics. All three services need hardware video encode/decode via the Intel iGPU. The common misconception: you need one GPU per consumer. Intel iGPUs support concurrent access — multiple processes (even across containers) can use VA-API simultaneously without exclusive locks.
The LXC config is identical for both containers:
lxc.cgroup2.devices.allow: c 226:* rwm
lxc.mount.entry: /dev/dri dev/dri none bind,optional,create=dir
For Jellyfin running inside Docker on CT 103, the Compose file passes the device through one more layer:
devices:
- /dev/dri:/dev/dri
Scrypted uses host networking and the same device passthrough. Three concurrent iGPU consumers, zero contention in normal operation. The Intel i5’s integrated UHD graphics handles it without breaking a sweat.
VPN-Tunneled Downloads in an LXC
The Arrstack is the most architecturally interesting container. Eight services form a dependency chain, with Gluetun at the base providing a WireGuard tunnel to NordVPN. Every service that needs anonymized traffic routes through Gluetun’s network namespace via network_mode: service:gluetun.
The challenge: Gluetun needs /dev/net/tun, which doesn’t exist in LXC containers by default. Two lines in /etc/pve/lxc/107.conf fix that:
lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file
The dependency chain matters for startup order:
gluetun (WireGuard tunnel, owns the network namespace)
← qbittorrent (network_mode: service:gluetun)
← prowlarr (network_mode: service:gluetun)
← flaresolverr (network_mode: service:gluetun)
radarr, sonarr, seerr (own network, connect to gluetun-hosted ports)
deunhealth (restarts unhealthy containers)
If Gluetun goes down, everything behind it loses network. The deunhealth container watches for this and triggers automatic restarts. It’s a self-healing loop.
The container also gets a second NIC on vmbr1 (10.0.0.x) for direct NFS access to the NAS — download traffic goes through the VPN, but media file access goes over the dedicated 10GbE-capable NAS bridge at MTU 9000.
What Couldn’t Be Migrated
Two services lost their state during migration: Seerr and Dockhand. Both used Docker named volumes — the kind declared in the volumes: section of a Compose file. Named volumes live inside Docker’s internal storage driver, not on the filesystem. You can’t scp them. You can’t even see them without docker volume inspect.
When VM100 shuts down, those volumes become inaccessible.
The fix going forward: every new Compose file uses bind mounts exclusively. ./seerr:/app/config instead of seerr_data:/app/config. The data lives on the filesystem where it can be backed up, staged, and migrated like everything else.
ls the data directory from the host, you can’t migrate it without the running Docker daemon.
Connecting the Dashboard
Homepage shows container status (running, stopped, health) for services across all hosts. But it runs on CT 108 — it only has access to its own Docker socket. To see containers on CT 103, 106, and 107, those hosts need to expose their Docker API.
A systemd drop-in override on each CT enables Docker’s TCP listener:
# /etc/systemd/system/docker.service.d/tcp.conf
[Service]
ExecStart=
ExecStart=/usr/bin/dockerd -H fd:// -H tcp://0.0.0.0:2375 --containerd=/run/containerd/containerd.sock
Homepage’s docker.yaml then connects to all four hosts:
local:
socket: /var/run/docker.sock
media:
host: 192.168.20.72
port: 2375
scrypted:
host: 192.168.20.76
port: 2375
arrstack:
host: 192.168.20.77
port: 2375
Each service in services.yaml references its server: and container: name, giving Homepage real-time status indicators across the entire fleet.
Cleaning Up the Monolith
With all services migrated, the cleanup phase was satisfying:
- Dead Caddy routes removed — code-server, Home Assistant, Cockpit, Tunarr, and Portainer were all services that died long before the migration. Their ghost routes were still in the Caddyfile, proxying to an IP that no longer responds. Purged.
- Day-One snapshots — every LXC got a named snapshot (
Day-One) marking the verified baseline. Instant rollback points, copy-on-write, zero space cost until blocks diverge. - Scheduled backups — a
vzdumpjob runs every Wednesday at noon, compressing all seven containers with zstd and shipping them to the Synology NAS via NFS. Keep-last-3 retention. - VM100 SSD — registered as
vm100-ssdcold storage in Proxmox. It stays in the chassis as insurance until confidence is absolute, then it comes out.
# /etc/pve/jobs.cfg
vzdump: backup-homelab
enabled 1
schedule 0 12 * * 3
storage nas-backups
vmid 101,102,103,104,106,107,108
mode snapshot
compress zstd
prune-backups keep-last=3
The Final Architecture
┌──────────────────────────── Proxmox VE (.99) ─────────────────────────────────┐
│ Intel i5 · 8 cores · 15.4 GiB RAM · 512 GB NVMe · Tailscale subnet router │
│ │
│ ┌─CT 101──┐ ┌─CT 102───────────┐ ┌─CT 103─────────────────┐ ┌─CT 104──┐ │
│ │ AdGuard │ │ Caddy+cloudflared │ │ Plex (native) │ │ Vault- │ │
│ │ DNS │ │ TLS termination │ │ Jellyfin+Tautulli │ │ warden │ │
│ │ .70 │ │ CF Tunnel → WAN │ │ Jellystat (iGPU) │ │ .73 │ │
│ └─────────┘ │ .71 │ │ .72 │ └─────────┘ │
│ └───────────────────┘ └────────────────────────┘ │
│ │
│ ┌─CT 106────────┐ ┌─CT 107──────────────────┐ ┌─CT 108───────────┐ │
│ │ Scrypted │ │ Gluetun → qBittorrent │ │ Homepage │ │
│ │ NVR (iGPU) │ │ Radarr, Sonarr, Prowlarr │ │ Dockhand │ │
│ │ host network │ │ Seerr, FlareSolverr │ │ multi-host Docker │ │
│ │ .76 │ │ .77 │ │ .78 │ │
│ └────────────────┘ └──────────────────────────┘ └───────────────────┘ │
│ │
│ ═══ vmbr0 (LAN 192.168.20.x) ═══════ vmbr1 (NAS 10.0.0.x, MTU 9000) ═════ │
└────────────────────────────────────────────────────────────────────────────────┘
│ │
Cloudflare Tunnel Synology NAS (10.0.0.10)
→ *.semesmieh.com Media + Downloads via NFS
Weekly vzdump backups (zstd)
| CT | Service | Cores | RAM | Disk | Special |
|---|---|---|---|---|---|
| 101 | AdGuard Home | 1 | 1 GB | 4 GB | — |
| 102 | Caddy + cloudflared | 1 | 512 MB | 8 GB | — |
| 103 | Plex + Jellyfin + Tautulli + Jellystat | 4 | 8 GB | 100 GB | iGPU, dual NIC, NFS |
| 104 | Vaultwarden | 1 | 512 MB | 4 GB | — |
| 106 | Scrypted | 2 | 2 GB | 8 GB | iGPU, host networking |
| 107 | Arrstack (8 containers) | 2 | 4 GB | 20 GB | /dev/net/tun, dual NIC, NFS |
| 108 | Homepage + Dockhand | 1 | 1 GB | 4 GB | Docker TCP to all hosts |
Lighter Than the Monolith
The Proxmox dashboard tells the story better than any argument could. With all services running simultaneously across seven containers:
- CPU: 6% of 8 cores
- Memory: 4.83 GiB of 15.40 GiB (31%)
For context: VM100 alone was allocated 14 GB of RAM and the host was swapping 4.2 GB to disk. Now the entire decomposed architecture — every service, every container, the Proxmox host overhead included — uses less than a third of available memory. The host has 10 GB of free RAM with nothing to do.
Right-sized containers don’t hoard headroom. A password manager doesn’t need 8 GB of RAM just because it shares a VM with a media transcoder. Decomposition didn’t just improve isolation — it halved total resource consumption.
What’s Next
The decomposition wasn’t just about isolation and resource efficiency. It was prerequisite architecture for the next evolution: a second Proxmox node for high availability.
You can’t live-migrate a monolith VM with a passthrough SSD — the disk is physically attached to one host. But you can live-migrate 512 MB LXC containers on shared storage in under a second. With everything decomposed into independent containers on LVM-thin, adding a second node means:
- Zero-downtime maintenance — drain containers off a node, update it, migrate them back
- Automatic failover — if a node dies, containers restart on the surviving node
- Rolling upgrades — Proxmox version bumps without a maintenance window
That’s the next post. For now, the single node is more than enough — and the architecture is ready for when it isn’t.
Closing Thought
There’s a particular kind of peace that comes from knowing your infrastructure can fail gracefully. Every container has a Day-One snapshot — an instant rollback to a verified-good state that costs zero space until blocks actually change. Every Wednesday at noon, vzdump compresses the lot and ships it to the NAS — physically decoupled storage that survives an NVMe failure, a host fire, or a spectacularly bad apt upgrade.
Two independent recovery paths. Local snapshots for the “oops, I broke the config” moments — roll back in seconds, try again. NAS backups for the disaster scenarios — rebuild from scratch on new hardware, restore, and be running within the hour. The old anxiety of “one bad command takes everything down” is replaced by the confidence to experiment freely.
The monolith is gone. The SSD is cold. Every service owns its own lifecycle. And the whole stack — all twelve containers, all seven LXCs — uses less memory than the single VM did alone. That’s not just an architectural win. That’s the kind of result that makes you excited to keep building.