Moving to Proxmox, one service per container.
Part 2 · the hypervisor
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
--unprivileged 1: root inside the container is an unprivileged user on the host. It is the default for a reason.nesting=1,keyctl=1: needed for Docker inside the container. The plan said no Docker in LXC; in practice three guests run Docker, because that is how those apps ship.--nameserver: set it explicitly. Without it,pct createcopies the host's resolver, which on a host running Tailscale is Tailscale's resolver, and the guest inherits a DNS setup it should not have.
Eleven containers and one VM run there now:
| GUEST | KIND | ROLE |
|---|---|---|
| tailscale-gw | LXC | remote access: a Tailscale subnet router for the LAN |
| dns | LXC | AdGuard Home, the network's DNS |
| edge-proxy | LXC | Nginx Proxy Manager, TLS and host routing |
| pbs | LXC | Proxmox Backup Server (part 4) |
| services | LXC | Uptime Kuma, log viewer, small tools, the app portal |
| homebridge | LXC | home automation bridge |
| labdocs | LXC | the lab's own documentation |
| gpu-workbench | LXC | the GPU, Ollama and notebooks (part 6) |
| browser | LXC | a browser for automation |
| filestash | LXC | a read-only web file browser |
| authentik | LXC | single sign-on for the apps |
| omarchy-desktop | VM | a 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
- Storage: the plan said ZFS. Guests actually live on LVM-thin on the SATA SSD, which is the installer's default and was enough for a single disk.
- Docker in LXC: planned out, used anyway where it was the sane way to run an app.
- A dev-stack VM: planned, never built. The side project it was for did not need to come along.
- Addresses: the plan's address map changed once in contact with reality, when a container landed on an address another project had already claimed.
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
- It started with a Raspberry Pi
- One Ubuntu box and a macvlan network
- Moving to Proxmox, one service per container
- Four cores to sixteen
- Sixteen days of backups that weren't
- Choosing a 70-watt GPU
- Twelve cores and most of a GPU
- Building the lab with an agent in the loop
- One GPU, three containers, one owner
- From a browser kiosk to a 3 MB TUI