Moving to Proxmox, one service per container.

Part 2 · the hypervisor

2026-09 home lab series · part 2 of 9

The audit at the end of part 1 came down to one recommendation: stop treating the machine as a desktop that happens to run containers, and make it a hypervisor. Each service becomes its own guest, with its own snapshots, its own backups and its own resource limits, and the host underneath gets boring. The tool for that is Proxmox VE.

WHY PROXMOX, AND WHY LXC

Proxmox runs two kinds of guest: full virtual machines under KVM, and LXC system containers that share the host's kernel. With four cores at the time, the choice was easy. LXC first: a container costs almost nothing at idle, starts in seconds, and still gets its own filesystem, network identity, CPU and memory limits, snapshots and backups. A VM is kept for the one thing that genuinely needs its own kernel, a full Linux desktop.

The GPU decision followed from the same reasoning. Passing a card through to a VM with VFIO hands the whole device to one guest. Sharing the host's driver into a container lets the host keep the card and any container use it. That is part 6.

INSTALLING IT

Proxmox VE installs from a USB stick. This machine has no remote management card, so the install needed a monitor and keyboard attached; after that, everything is done from a browser or SSH.

ON A MAC: WRITE THE INSTALLER TO A USB STICK

# find the stick (check the size; the wrong disk here is a bad day)
diskutil list external

diskutil unmountDisk /dev/diskN
sudo dd if=proxmox-ve_9.x.iso of=/dev/rdiskN bs=4m status=progress
diskutil eject /dev/diskN

In the installer: target the SATA SSD, ext4 (the installer then puts guests on LVM-thin), a static address, the gateway, and the management interface. On first boot, switch from the enterprise repository, which needs a subscription, to the no-subscription one. In the web UI that is node → Updates → Repositories: disable the two enterprise entries and add No-Subscription. Then:

ON THE NEW HOST: FIRST BOOT

apt update && apt full-upgrade -y
pveversion                    # pve-manager/9.x

# remote hands before anything else: Tailscale on the host
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up --ssh

A MIGRATION THAT COULD BE UNDONE

The rule for the move was that nothing gets destroyed until its replacement has proved itself. The machine had two drives, so Proxmox went onto the 512 GB SATA SSD, and the 1 TB NVMe holding the whole Ubuntu install was left exactly as it was and mounted read-only:

ON THE NEW PROXMOX HOST: KEEP THE OLD SYSTEM, READ-ONLY

mkdir -p /mnt/oldroot
mount -o ro /dev/nvme0n1p2 /mnt/oldroot
ls /mnt/oldroot/home/<user>/Docker      # every compose project, intact

# survive reboots, and do not hang the boot if the disk is missing
echo '/dev/nvme0n1p2 /mnt/oldroot ext4 ro,nofail 0 2' >> /etc/fstab

At that point Proxmox was running and every byte of the old system was still on disk and readable, so services could move at whatever pace suited.

The runbook's one red warning was for the installer itself: it lists both disks, so check the model string before confirming, not the device name. Choosing the wrong one of two disks is the one mistake in this whole project that could not have been undone.

A BRIDGE INSTEAD OF A SHIM

The single biggest simplification was the network. Proxmox puts a Linux bridge, vmbr0, on the physical interface, and every guest attaches to it as a full member of the LAN. Guests get real addresses, the host can talk to all of them, and the macvlan shim and its hand-kept routes from part 1 simply disappear.

/ETC/NETWORK/INTERFACES ON THE HOST

auto vmbr0
iface vmbr0 inet static
    address 192.168.1.10/24
    gateway 192.168.1.1
    bridge-ports nic1
    bridge-stp off
    bridge-fd 0

The interface is nic1, not the enp11s0 of the Ubuntu days: on this host Proxmox names the ports nic0 and nic1. That detail caused the lab's one real outage. After the machine was physically moved in September, the cable went back into the port that is now nic0, and the bridge, which is on nic1, had no link. Nothing was wrong with the configuration; the cable was in the wrong hole.

ONE SERVICE PER GUEST

A container is one command. This is the real one used for the file browser that was added later, with the address changed:

CREATE AN UNPRIVILEGED DEBIAN 13 CONTAINER

pveam update
pveam download local debian-13-standard_13.6-1_amd64.tar.zst

pct create 110 local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst \
  --hostname filestash \
  --unprivileged 1 \
  --features nesting=1,keyctl=1 \
  --cores 4 --memory 2048 --swap 512 \
  --rootfs local-lvm:8 \
  --net0 name=eth0,bridge=vmbr0,ip=192.168.1.50/24,gw=192.168.1.1 \
  --nameserver 192.168.1.53 \
  --onboot 1

pct start 110
pct enter 110

Eleven containers and one VM run there now:

GUESTKINDROLE
tailscale-gwLXCremote access: a Tailscale subnet router for the LAN
dnsLXCAdGuard Home, the network's DNS
edge-proxyLXCNginx Proxy Manager, TLS and host routing
pbsLXCProxmox Backup Server (part 4)
servicesLXCUptime Kuma, log viewer, small tools, the app portal
homebridgeLXChome automation bridge
labdocsLXCthe lab's own documentation
gpu-workbenchLXCthe GPU, Ollama and notebooks (part 6)
browserLXCa browser for automation
filestashLXCa read-only web file browser
authentikLXCsingle sign-on for the apps
omarchy-desktopVMa full Linux desktop, reachable remotely

MOVING A SERVICE ACROSS

Because the old disk was mounted on the new host, moving a Docker service was a copy, not a download. The pattern, for any compose project:

ON THE HOST: COPY A COMPOSE PROJECT INTO A CONTAINER

# stream the project folder from the old disk straight into container 104
tar -C /mnt/oldroot/home/<user>/Docker -cf - uptime-kuma \
  | pct exec 104 -- tar -C /opt -xf -

# Docker inside the container (it was created with nesting=1,keyctl=1)
pct exec 104 -- bash -c 'apt update && apt install -y docker.io docker-compose-plugin'
pct exec 104 -- bash -c 'cd /opt/uptime-kuma && docker compose up -d'

The compose file keeps working as it was with one change: its macvlan network block goes. Inside the container, Docker's ordinary bridge network is fine, because the container now has the LAN address.

Data that should stay on the host rather than inside a guest is shared in with a bind mount. The file browser, for example, sees a folder on the host's data disk, read-only:

ON THE HOST: SHARE A HOST FOLDER INTO A CONTAINER, READ-ONLY

pct set 110 -mp0 /mnt/data/shared,mp=/data,ro=1
pct reboot 110

THE PAYOFF: SNAPSHOTS

The reason for all of this, in two commands. Before any risky change to a guest, a snapshot; if the change goes wrong, roll back to it in seconds.

ON THE HOST

pct snapshot 104 before-upgrade --description "before the Kuma upgrade"
pct listsnapshot 104

# it went wrong
pct rollback 104 before-upgrade

# it went fine
pct delsnapshot 104 before-upgrade

Snapshots are not backups: they live on the same disk as the guest. Backups are part 4.

REMOTE ACCESS, AS A GUEST

Tailscale on the host is the way in if something breaks. Day to day, one small container is a Tailscale subnet router: any device on my tailnet reaches the whole LAN through it, with nothing exposed to the internet. An unprivileged container needs the TUN device passed in:

/ETC/PVE/LXC/100.CONF (ADDED LINES)

lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file

INSIDE THE CONTAINER

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

curl -fsSL https://tailscale.com/install.sh | sh
tailscale up --advertise-routes=192.168.1.0/24
# then approve the route in the Tailscale admin console

DNS, MOVED TWICE

DNS left the host along with everything else: AdGuard Home in its own container answers for the network, and the reverse proxy is its own container behind it. The private .lab domain from part 1 was later retired altogether. A wildcard record for the lab's subdomain now lives in public DNS, DNS-only and pointing at the proxy's private address. It resolves everywhere and connects only from inside the network or over Tailscale, and a wildcard Let's Encrypt certificate issued by DNS challenge means every service gets real TLS with no per-device resolver files.

PLAN VERSUS WHAT WAS BUILT

Proxmox went on in September 2026 and, apart from that misplaced cable, has stayed up since. The old Ubuntu disk, still mounted, ended up earning its keep as fast storage for model files. The whole result is drawn on one page in the lab, as built. The next limit was not isolation but compute: twelve guests on four cores. That is part 3.

ASK AN AGENT

The migration plan came out of a conversation like this one. Point an agent with shell access at your own Docker host and give it:

PROMPT: PLAN A DOCKER-TO-PROXMOX MIGRATION

I want to move this Docker host to Proxmox VE, one service per LXC container. Do not change anything yet.

1. Inventory the host: hardware (dmidecode, lspci, lsblk), every container with its image, volumes, networks, fixed IPs and restart policy, and every compose project on disk.
2. For each service, say whether it should be an LXC container, a VM, or retired, and why.
3. Propose a disk layout that keeps the current install intact and readable until the migration is finished.
4. Propose an IP plan, and list every place an old address is referenced: proxy targets, DNS records, app configs and app databases.
5. Write a phased runbook with a rollback for every phase, and the backup to take before phase one.
Show me the commands you ran and their output, not just conclusions.

THE SERIES

  1. It started with a Raspberry Pi
  2. One Ubuntu box and a macvlan network
  3. Moving to Proxmox, one service per container
  4. Four cores to sixteen
  5. Sixteen days of backups that weren't
  6. Choosing a 70-watt GPU
  7. Twelve cores and most of a GPU
  8. Building the lab with an agent in the loop
  9. One GPU, three containers, one owner
  10. From a browser kiosk to a 3 MB TUI
← One Ubuntu box and a macvlan network Four cores to sixteen → press / for a prompt