Finishing the Decomposition: Every Service Gets Its Own Container

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

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:

  1. Create privileged LXC — right-sized resources, static IP on vmbr0 (and vmbr1 for NFS-dependent services). Privileged because /dev/dri and /dev/net/tun passthrough is trivial in privileged mode.
  2. Install Docker + Compose — standard Docker CE install from the official repo. Took ~90 seconds per container.
  3. 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.
  4. Deploy Compose stack — adapted docker-compose.yml for the new environment (updated IPs, switched named volumes to bind mounts, removed cross-stack network dependencies).
  5. Update Caddy routes and reloadsed the 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.

Lesson learned: named volumes are convenient for development but a liability for migration. If you can’t 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.

Port 2375 is unauthenticated Docker API access. This is acceptable on a trusted LAN with no internet exposure — the containers sit behind Caddy and Cloudflare Tunnel, never directly reachable from outside. For anything beyond a homelab, use mutual TLS or a socket proxy.

Cleaning Up the Monolith

With all services migrated, the cleanup phase was satisfying:

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

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:

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.