
Docker Compose for Your Home Lab, Part 3: Give Your Containers a Real Address on Your LAN
Container networking demystified for network engineers. What Docker's default bridge actually does to your traffic, why Pi-hole needs to be a real device on your LAN, and how macvlan gets it there. Which mode to pick for which service.
In Part 2 we deployed Pi-hole as a Compose stack and pointed a phone at it. It works. But Pi-hole is living on a private island inside your Docker host. You reach it through a port you mapped, it’s fighting the host for port 53, and to the rest of your network it isn’t a device at all. The best your router can do is advertise your Docker host as the LAN’s DNS server, and DHCP can’t reach it without a relay. Doing the job right means giving Pi-hole its own address on your network as a real box, and that means understanding how Docker networking works by default.
By the end of this article, your Pi-hole container is a real device on your LAN with its own IP, reachable like any other machine, ready to be the DNS server your router hands out (and DHCP, if you want to go that far). You’ll also understand the networking Part 2 set up for you, because every container you stand up after this runs on the same plumbing.
Bring some patience… there are a lot of moving parts before the payoff at the bottom.
Bridge mode, the default network
When you run a container with no networking options specified, Docker drops it into a Linux bridge interface called docker0. On a fresh Docker install, docker0 lives at 172.17.0.1/16. Run ip addr show docker0 on your Docker host and you’ll see something like…
$ ip addr show docker0
4: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 ...
inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
...That’s a private subnet, entirely inside your host, that Docker uses for containers to talk to each other and reach the outside world. Each container that joins this bridge gets an IP from that subnet, typically 172.17.0.2, 172.17.0.3, and so on.
Compose makes it slightly nicer by giving each stack its own private bridge instead of dumping everything onto docker0. The Pi-hole stack from Part 2 created pihole_default automatically, on a separate subnet like 172.18.0.0/16. The principle is identical. It’s a private network, inside the host, that the outside world doesn’t know about.
When the container needs to talk to the internet, the Linux kernel performs source NAT on the way out, rewriting the source address from the container’s 172.18.0.2 to the host’s real IP. Replies come back to the host, the host figures out which container the connection belonged to, and forwards the packet onto the right bridge. It’s the same NAT your home router does for the devices on your LAN. Your Docker host is, in effect, a tiny NAT gateway sitting between the container subnet and the rest of the world.
For traffic coming the other direction (your phone hitting the Pi-hole admin UI), Docker does port forwarding. When you wrote ports: - "8081:80" in your Compose file, you asked Docker to listen on port 8081 on the host’s real IP, and forward incoming connections into port 80 of the container. The kernel implements that with iptables (or nftables) rules that DNAT inbound packets to the container’s bridge address.
Port forwarding works fine for most services, DNS included. What it can’t forward is a broadcast, which is how DHCP starts.
What bridge mode costs you
A web app behind port forwarding doesn’t care about any of this. It logs a request and serves a page. Pi-hole can limp along on a bridge too. Point your phone at the host’s IP on port 53 and queries resolve, ads get blocked. So what’s wrong with leaving it there?
The problem is everything you can’t do while Pi-hole is stuck behind NAT.
Start with port 53. Your Docker host almost certainly already wants it. systemd-resolved binds 127.0.0.53:53 out of the box, so publishing the container’s 53:53 on the host turns DNS ownership on that machine into a fight you have to referee. It’s solvable, but it exists only because the container borrows the host’s IP.
Then there’s the source IP question, and most of what you’ve read about it is out of date. The folklore says a bridged Pi-hole logs every query as coming from the Docker gateway (172.x.x.1), blurring every device into one client. That was true on older Docker, and it still shows up for the host’s own queries and a couple of docker-proxy hairpin paths. On current Docker it mostly isn’t. The earlier draft of this post leaned on it, so I put it on the bench… a container on the default bridge publishing 53/udp, Docker 29.8.2, userland proxy left at its default. A DNS packet from a real machine across the LAN reached the container carrying its own source address. The iptables DNAT path holds the client IP through conntrack. So if per client visibility is the only thing you’re chasing, a modern bridged Pi-hole with a published port will usually hand it to you. Don’t reach for macvlan on the strength of the old scare alone.
What the bridge can’t do, on any Docker version, is make Pi-hole a device on your LAN. A bridged Pi-hole has no address of its own, nothing the other machines can ARP for, and no way to hear a broadcast. DHCP opens with a client shouting a DISCOVER at the broadcast address before it owns an IP at all, and a broadcast doesn’t survive the trip across a NAT into a private container subnet. You can bridge that gap with a DHCP relay, an ip helper-address on your router or a tiny relay like dhcp-helper on the LAN, unicasting each request to the host’s published 67/udp. Pi-hole’s own Docker docs list it as an option. Most home routers can’t relay, though, so it usually means one more container to babysit. The day you want Pi-hole to be the one box running DNS and DHCP for the whole house, with no relay propping it up, it needs to live on the LAN as a real device, and bridge mode can’t put it there.
There are three ways out of this.
Host networking (
network_mode: hostin Compose). The container shares the host’s network namespace entirely. No NAT, no bridge. The container sees the world exactly like the host does. This works but has consequences. The container’s ports collide directly with the host’s ports (you can’t run two host mode containers both listening on 53), and the container effectively has full access to all your host network interfaces. It’s a sledgehammer.Macvlan. The container gets its own MAC address and its own IP on your LAN. From every other device’s perspective it’s a separate physical device on the network. Routers, DHCP servers, and other containers see the container’s own address and it sees theirs.
Ipvlan. Similar to macvlan but shares one MAC address among containers and the host. Slightly different switch side behavior. Good to have heard of, but for DNS and most home lab cases, macvlan is what you want.
We’re going with macvlan, obviously… it’s the whole reason you’re here.
What macvlan does
A macvlan network connects directly to one of your host’s physical network interfaces (usually eth0 or enp4s0). Docker uses that interface as the parent, then creates virtual sub-interfaces with their own MAC addresses and their own IP addresses. To everything else on your LAN, each macvlan container looks like a separate device.
A few things about macvlan you’ll want in your head before we touch the Compose file.
- The container gets a real IP on your home LAN. If your LAN is
192.168.1.0/24and you assign192.168.1.53to Pi-hole, that’s a real address your router and your phone can reach directly, and other devices can ARP for. - Macvlan containers can talk to other devices on the LAN without going through any NAT.
- The container’s MAC address is distinct. If your network has any MAC based access control (some enterprise gear, some captive portals), the container will be visible there as a new device.
- The macvlan container can’t talk to the host it runs on. Linux blocks the path from the host to a macvlan on the same NIC on purpose. We’ll work around it below.
- You don’t have to touch promiscuous mode on the Docker host. The kernel registers each container’s MAC with the parent NIC, and only flips the NIC into promiscuous mode itself if the card can’t filter on more than one address. A virtualized host is where it bites. VMware’s vSwitch drops frames for any MAC it didn’t assign the VM until you allow promiscuous mode and forged transmits on the port group, while a Proxmox Linux bridge passes them through as is, unless the Proxmox firewall is on for that NIC (its MAC filter drops them).
That host to container restriction reaches further than it sounds. Every bridged container on the same host sends its traffic out through the host, so it can’t reach Pi-hole either. We’ll fix it properly, with a shim that survives a reboot, once Pi-hole is up.
Pi-hole on macvlan
We’re going to create a macvlan network on the Docker host, move the Pi-hole stack from Part 2 onto it, give it a LAN IP, and make sure the whole thing comes back on its own after a reboot.
First, figure out three things about your home network…
The name of your LAN interface on the Docker host. Run
ip route show defaultand look at thedevfield.$ ip route show default default via 192.168.1.1 dev enp4s0 proto staticMine is
enp4s0. Yours might beeth0,eno1,enp0s3, etc.Your LAN subnet and gateway. From the same output, the gateway is
192.168.1.1. The subnet is likely192.168.1.0/24(check withip addr show enp4s0).A small block of unused addresses for Docker to own, outside your DHCP pool and away from anything you’ve statically assigned. If your router’s DHCP serves
192.168.1.100through192.168.1.250, then192.168.1.48/28(.48through.63) works. Pi-hole gets192.168.1.53(cute and easy to remember for DNS), the host’s shim gets.54, and Docker hands out.56through.63to anything else you put on macvlan later.
Create the network once
The macvlan network gets created by hand, outside of any Compose file. If you define it inside Pi-hole’s compose.yaml, it belongs to that stack. docker compose down deletes it, and anything else you hung off it goes down with Pi-hole. Created on its own, it’s part of the host, every stack can use it, and Docker keeps it across reboots.
$ docker network create -d macvlan \
--subnet=192.168.1.0/24 \
--gateway=192.168.1.1 \
--ip-range=192.168.1.56/29 \
--aux-address="host-shim=192.168.1.54" \
-o parent=enp4s0 \
lan-d macvlan picks the driver, and lan on the last line is the network’s name, the one the Compose file refers to. --subnet and --gateway describe your real LAN. --ip-range is the important one. Without it, Docker assumes it owns the whole /24 and will happily hand a container an address your printer is already using. It also stops short of Pi-hole’s .53 on purpose. Docker’s docs say to pin static addresses outside the range, and I watched why on the bench… with Pi-hole stopped, the sixth container to start on a .48/28 range took .53, and Pi-hole refused to come back up. --aux-address reserves 192.168.1.54 so Docker never gives it to a container, even one asking for it by name. The host’s shim takes that address.
Now update the Compose file. Open ~/lab/pihole/compose.yaml and change it to this…
services:
pihole:
image: pihole/pihole:latest
container_name: pihole
hostname: pihole
networks:
lan:
ipv4_address: 192.168.1.53
environment:
TZ: "America/Chicago"
FTLCONF_webserver_api_password: "changeme"
FTLCONF_dns_listeningMode: "all"
FTLCONF_dns_upstreams: "1.1.1.1;9.9.9.9"
volumes:
- ./etc-pihole:/etc/pihole
restart: unless-stopped
cap_add:
- NET_ADMIN
healthcheck:
test: ["CMD", "dig", "+short", "+norecurse", "+retry=0", "@127.0.0.1", "pi.hole"]
interval: 30s
timeout: 5s
retries: 3
networks:
lan:
external: trueThree things changed.
The ports: block is gone. We don’t need port forwarding anymore. The container has its own IP, so clients can hit 192.168.1.53:53 and 192.168.1.53:80 directly without the host getting involved.
The networks: section under the service tells Compose to attach the container to the lan network and assign it a specific address.
The top level networks: section marks lan as external. Compose uses the network you already created and never tries to create or delete it.
Take the old stack down and bring up the new one…
$ docker compose down
[+] down 1/1
✔ Container pihole Removed
$ docker compose up -d
[+] up 1/1
✔ Container pihole StartedVerify it from any device that isn’t the Docker host. Your laptop, your phone, anything. A ping 192.168.1.53 from there should answer. The same ping from the Docker host itself will fail because of the macvlan restriction. The shim below fixes it.
Open http://192.168.1.53/admin from a browser on any other device. The Pi-hole admin UI comes up at port 80 directly (we no longer need to map to 8081). Run dig @192.168.1.53 example.com +short from a few devices, then check the Query Log in the admin UI. Each device shows up under its own LAN IP, your phone and your laptop as separate entries, talking to Pi-hole directly with no host in the middle.
Point your router’s DHCP to hand out 192.168.1.53 as the DNS server for the LAN, and every device on your network resolves through Pi-hole. Open the Query Log the next morning and see what your house has been asking for while you slept. Smart TVs are chatty…
Letting the host reach its own containers
The host can’t talk to a macvlan container through the parent NIC, but it can talk through a macvlan interface of its own. So we give the host one, a shim on the same parent with the 192.168.1.54 address we reserved, and route the container block through it. Linux sends anything bound for 192.168.1.48/28 out the shim, and the shim sits on the same macvlan bridge as the containers.
You can type the ip commands by hand and it works until the next reboot, then the shim is gone and Pi-hole is unreachable from the host again. Stuff like this breaks months later, when you’ve forgotten it was ever there. So it goes in a script, and systemd runs the script at boot.
Save this as /usr/local/sbin/macvlan-shim.sh…
#!/bin/bash
set -euo pipefail
PARENT="enp4s0" # the Docker host's LAN interface
SHIM="lan-shim" # the host's own macvlan interface
SHIM_IP="192.168.1.54/32" # reserved with --aux-address
BLOCK="192.168.1.48/28" # the whole block set aside for Docker
# Wait up to 30 seconds for the parent link to come up
for i in {1..30}; do
[ "$(cat /sys/class/net/$PARENT/operstate 2>/dev/null)" = "up" ] && break
sleep 1
done
ip link show "$SHIM" >/dev/null 2>&1 || \
ip link add "$SHIM" link "$PARENT" type macvlan mode bridge
ip addr replace "$SHIM_IP" dev "$SHIM"
ip link set "$SHIM" up
ip route replace "$BLOCK" dev "$SHIM"Every step checks before it changes anything, or uses replace, so running it twice does no harm. One route covers the whole /28, which is safe because nothing else on your LAN lives in that block. Anything you add to the lan network later is reachable from the host without touching the script.
Make it executable, then give it a systemd unit at /etc/systemd/system/macvlan-shim.service…
$ sudo chmod 755 /usr/local/sbin/macvlan-shim.sh[Unit]
Description=Host macvlan shim for Docker containers
Wants=network-online.target
After=network-online.target docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/macvlan-shim.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.targetEnable it and start it now…
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now macvlan-shim.serviceThe host can ping 192.168.1.53 now, and so can every bridged container on it. Reboot the box once to prove it. The lan network, the shim, and Pi-hole (thanks to restart: unless-stopped) should all come back without you touching anything. Check with ip addr show lan-shim and ip route | grep lan-shim if something looks off.
Internal networks
We’ve now seen two kinds of Compose managed networks…
- Default bridge (what Part 1 and Part 2 used). Each stack gets its own private network. Containers in the same stack can find each other by service name. The host forwards inbound traffic in through published ports. The outside sees containers as the host’s IP.
- Macvlan (this post). The container joins the LAN directly with its own MAC and IP, a real device other machines can reach and that can serve broadcast protocols like DHCP. No host involvement on the data path.
There’s a third pattern too, a private internal network that lives only between containers in the same stack and isn’t reachable from outside. That’s how you put a database behind a web app. The web container talks to the database container on an internal network, while only the web container is exposed (via macvlan or via the host) to the outside world. The database never has a route off the host at all.
Where that leaves Pi-hole
Pi-hole is a device on your network now. Your router can hand it out as the DNS server, and if you want to put it in charge of DHCP too, nothing is in the way anymore.
Running macvlan, ipvlan, or living with host mode on your own Docker box? Tell me how you’ve got it wired up in the comments.
Get the rest of Docker Compose for Your Home Lab in your inbox.



Comments