One Ubuntu box and a macvlan network.
Part 1 · the Docker era
Before it was a hypervisor, the lab was a desktop. One Dell Precision tower ran Ubuntu Desktop, Docker ran everything else, and every container sat on the home network with an address of its own. It was quick to build and easy to change, and by the time it was audited it was running twenty-five containers on a single disk with no backups. This post is how it was put together, with the commands, and what it cost.
THE MACHINE
- Dell Precision Tower 7810: dual-socket board, one socket filled with a 4-core, 8-thread Xeon E5-2623 v3. The other socket was empty.
- 64 GB of DDR4 ECC as four 16 GB sticks.
- RTX 3060 Ti, 8 GB. The board has no integrated graphics, so the one GPU was also the display.
- 1 TB NVMe for the operating system and everything else, plus a 512 GB SATA SSD for cold storage. No redundancy.
- Ubuntu 24.04 Desktop, Docker and Compose, and the NVIDIA driver for GPU work.
A desktop OS on a server is convenient early on: when something breaks you plug in a monitor and open a browser. It is also a lot of moving parts for a machine whose only job is to keep running.
WHY MACVLAN
Docker's default bridge network puts containers behind the host's address
and publishes them on ports: host:8080,
host:8081, and a growing list to remember. I wanted each
service to look like a real machine on the LAN, with its own IP, reachable
on its normal ports, and addressable from a phone or another laptop with no
port mapping at all.
Docker's macvlan driver does exactly that. It gives every container its own MAC address on the physical interface, so the router and every other device see it as one more host on the network.
THE NETWORK
One Docker network, created once, with the LAN's own subnet and gateway and the host's physical interface as its parent. The addresses below are examples; substitute your own.
CREATE THE MACVLAN NETWORK
# find the physical interface the LAN is on (here: enp11s0) ip -br link # a LAN of 192.168.1.0/24 with the router at .1 docker network create -d macvlan \ --subnet 192.168.1.0/24 \ --gateway 192.168.1.1 \ -o parent=enp11s0 \ lan-macvlan docker network inspect lan-macvlan
My notes record the network's settings rather than the command that made
it, and no --ip-range among them: every container was given a
fixed address by hand instead. If I did it again I would add
--ip-range to fence off a block the router's DHCP never hands
out, because the day a laptop and a container claim the same address is
not a fun day.
A CONTAINER ON IT
The reverse proxy, Nginx Proxy Manager, was the first container on the
network and the one everything else went through. It ran from a plain
docker run, pinned to a fixed address:
THE REVERSE PROXY, PINNED TO ONE ADDRESS
docker run -d \ --name=lab_proxy \ --hostname=lab-proxy \ --restart=always \ --cap-add=NET_ADMIN \ --cap-add=NET_BIND_SERVICE \ --network=lan-macvlan \ --ip=192.168.1.200 \ -e TZ=America/Los_Angeles \ -e DISABLE_IPV6="true" \ -v ~/Docker/nginx-proxy-manager/data:/data \ -v ~/Docker/nginx-proxy-manager/letsencrypt:/etc/letsencrypt \ --memory="512m" \ jc21/nginx-proxy-manager:latest
Services with a Compose file joined the same network by declaring it external, so Compose uses the existing network instead of creating its own, and asking for an address on it:
DOCKER-COMPOSE.YML: JOINING THE EXISTING NETWORK
services:
docs:
image: squidfunk/mkdocs-material:9
networks:
lab:
ipv4_address: 192.168.1.210
networks:
lab:
external: true
name: lan-macvlan
Each service lived in its own folder under ~/Docker/<name>/
with its compose file and data beside it. Allocation was a convention, not
a system: monitoring in one block of addresses, a side project's dev stack
in another, one-offs wherever there was room.
THE SHIM
Macvlan has one well-known catch: the host cannot talk to its own macvlan containers. The kernel deliberately keeps a parent interface and its macvlan children apart. Every other device on the LAN reaches the proxy fine; the machine it runs on cannot.
The standard fix is a second macvlan interface on the host, a shim, with its own address and an explicit route through it for each container the host needs to reach. To survive reboots it lived in a systemd unit:
/ETC/SYSTEMD/SYSTEM/MACVLAN-SHIM.SERVICE
[Unit]
Description=Macvlan Shim for Host-to-Container Routing
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStartPre=-/sbin/ip link del macvlan-shim
ExecStart=/sbin/ip link add macvlan-shim link enp11s0 type macvlan mode bridge
ExecStart=/sbin/ip addr add 192.168.1.254/32 dev macvlan-shim
ExecStart=/sbin/ip link set macvlan-shim up
# one /32 route per container the host has to reach
ExecStart=/sbin/ip route add 192.168.1.200/32 dev macvlan-shim
ExecStart=/sbin/ip route add 192.168.1.210/32 dev macvlan-shim
ExecStop=/sbin/ip link del macvlan-shim
[Install]
WantedBy=multi-user.target
ENABLE IT AND CHECK THE ROUTES
sudo systemctl daemon-reload sudo systemctl enable --now macvlan-shim.service ip route | grep macvlan-shim
It works. It is also bookkeeping: every new container the host needs to reach means another route line, and it is silently wrong the day you forget one.
NAMES INSTEAD OF NUMBERS
Addresses are not something you want to type, so dnsmasq on the host
answered for a private .lab domain, and a wildcard sent every
*.lab name to the proxy, which routed by hostname to the right
container. Adding a service meant adding one proxy host, with no DNS change.
/ETC/DNSMASQ.D/LAB.CONF
interface=tailscale0 interface=enp11s0 bind-interfaces # the host itself address=/tower.lab/192.168.1.10 # everything else goes to the reverse proxy address=/lab/192.168.1.200
ON EACH MAC: SEND .LAB QUERIES TO THE HOST
sudo mkdir -p /etc/resolver echo "nameserver 192.168.1.10" | sudo tee /etc/resolver/lab
Listening on the Tailscale interface as well as the LAN meant the same names worked away from home.
WHAT IT COST
In July 2026 I had an AI agent audit the machine end to end before changing anything (more on that in part 7). What it found was the honest review of the Docker era:
- No backups, ever. One disk held the OS, every container and all their data. The first backup the machine had ever had was taken during that audit.
- Failures nobody saw. Two API containers had been unhealthy for seven weeks, and an mDNS publisher had been dead for two months. Docker restarted what it could and nothing told anyone about the rest.
- One blast radius. A host update, a full disk or a driver change touched every service at once, and there was no snapshot to roll back to.
- Hand-kept state. The shim's routes, the address plan and the proxy's targets were three lists that had to agree, maintained by memory.
- A thin CPU. Four cores served twenty-five containers and a GPU workload.
The same audit cleared out 135 GB of image-generation tools that were no longer used. What it recommended next was not more containers but a different foundation: a hypervisor, where each service is its own guest with its own snapshots and backups. That is part 2.
ASK AN AGENT
Before changing a host like this, have an agent tell you what is actually on it. This is close to the brief that produced the audit above:
PROMPT: AUDIT A DOCKER HOST BEFORE TOUCHING IT
Audit this Docker host end to end. Read only: change nothing, restart nothing. - Hardware: CPU, memory, disks and their health, GPU and driver. - Every container: image and age, status and health, restart policy, network, fixed IP, volumes and how big they are. - Networking: Docker networks (macvlan parent, subnet, any ip-range), host routes, any shim interfaces, DNS config. - Anything unhealthy, exited, or restarting, and for how long. - What has no backup. Then write it up as a single document: what exists, what is broken, what is at risk, and what you would change first. Quote the commands you ran.
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