monk97.mewritingdocker-macvlan

One Ubuntu box and a macvlan network.

Part 1 · the Docker era

2026-05 → 2026-07 home lab series · part 1 of 9

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

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:

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

  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
← It started with a Raspberry Pi Moving to Proxmox, one service per container → press / for a prompt