The goal: quiet across the whole network
Ads on the internet are exhausting, on phones, on smart TVs, everywhere. For anyone running a homeserver the obvious answer is a network-wide DNS blocker. Pi-hole was the default choice for years, but I went with AdGuard Home. It feels more modern, does DNS-over-HTTPS and DNS-over-TLS out of the box, and I like the interface more.
Sounds easy enough, right? Start the container, done. That was optimistic.
The final boss: port 53
My first attempt was to run the container on the host network. That failed immediately because of port 53.
Port 53 is the standard port for DNS requests, and on almost every modern Linux server, my Ubuntu host included, it is already taken by systemd-resolved. The container could not start.
I could have disabled systemd-resolved, but poking around in the host system goes against how I want to run this machine. The Docker host should stay as vanilla as possible. So, plan B.
The fix: Macvlan
A Docker Macvlan network is made for cases like this. The container behaves like a real physical device on the network: it gets its own MAC address and, more importantly, its own IP address from the router, independent of the host server. No more port conflict on port 53.
My network is dual-stack, so I did it properly. The container gets a dedicated IPv4 address (192.168.50.250) and a dedicated IPv6 address, a stable ULA starting with fd....
The next problem: Traefik
The DNS blocker worked now, but I wanted the web dashboard on my own domain (adguard.dieck-labs.de) behind Traefik as a reverse proxy, instead of typing IP addresses into the browser. This is where it got annoying.
Network isolation
For Traefik to reach the container, both services need to share the same internal Docker network, in my case apps. But a container attached to Macvlan is isolated from the normal Docker world. So the container has to join both networks: Macvlan for DNS traffic from the LAN, apps for Traefik.
The 502 Bad Gateway
After wiring it into Traefik I got nothing but 502 Bad Gateway. Hours of debugging.
The cause was subtle: if you do the initial AdGuard Home setup via the Macvlan IP, it often configures itself to listen only on that one interface, so requests coming from Traefik through the internal apps network are ignored. In AdGuardHome.yaml I had to bind the web UI to 0.0.0.0 so it listens on all interfaces, and move the admin UI to an uncommon port like 8087. Then Traefik can reach it while DNS keeps using port 53.
The architecture
This is what the traffic flow looks like now:
graph TD
subgraph "Home Network (192.168.50.0/24)"
Phone[📱 Smartphone] -- DNS Query --> AGH
TV[📺 Smart TV] -- DNS Query --> AGH
Laptop[💻 Laptop] -- DNS Query --> AGH
FB[🌐 Fritz!Box<br/>DHCP Server] -. distributes DNS .-> Phone & TV & Laptop
end
subgraph "Lenovo M920q (Docker)"
AGH[AdGuard Home<br/>192.168.50.250]
TF[Traefik<br/>Reverse Proxy]
AGH -- apps network --> TF
end
subgraph "Internet"
User((👤 Me)) -- HTTPS --> CF[Cloudflare]
CF -- Tunnel --> TF
TF -- Port 8087 --> AGH
AGH -- DNS Upstream --> DNS[Cloudflare DNS<br/>1.1.1.1]
end
style AGH fill:#4CAF50,stroke:#2E7D32,color:#fff
The config: two networks, one container
Here is the final Docker Compose file (adguard.yml in the repo), the result of too many evenings staring at terminal output:
version: "3.9"
services:
adguardhome:
image: adguard/adguardhome:latest
container_name: adguardhome
restart: unless-stopped
networks:
# Network 1: DNS traffic from the home network
macvlan_net:
ipv4_address: 192.168.50.250
ipv6_address: fd15:d91c:273f:0::250
# Network 2: Communication with Traefik
apps:
volumes:
# Docker-managed working data (logs etc.)
- adguard_work:/opt/adguardhome/work
# IMPORTANT: local bind mount for writable config
- ./adguard:/opt/adguardhome/conf
labels:
- "traefik.enable=true"
- "traefik.docker.network=apps"
- "traefik.http.routers.adguard.rule=Host(`adguard.dieck-labs.de`)"
- "traefik.http.routers.adguard.entrypoints=websecure"
- "traefik.http.routers.adguard.tls.certresolver=myresolver"
# Traefik talks to the admin UI on this custom port
- "traefik.http.services.adguard.loadbalancer.server.port=8087"
networks:
macvlan_net:
driver: macvlan
enable_ipv6: true
driver_opts:
parent: ens18 # Physical host interface
ipam:
config:
- subnet: 192.168.50.0/24
gateway: 192.168.50.1
- subnet: fd15:d91c:273f:0::/64
gateway: fd15:d91c:273f:0::1
apps:
external: true # Existing Traefik network
volumes:
adguard_work:
external: true
The config trick
One detail matters: AdGuardHome.yaml in the local ./adguard directory needs a small manual change:
http:
address: 0.0.0.0:8087 # Listen on all interfaces
dns:
bind_hosts:
- 192.168.50.250
- "fd15:d91c:273f:0::250"
port: 53
I first tried mounting the config via Docker configs. Clean, and satisfying on paper. But it also made the file read-only, so the AdGuard dashboard could no longer save changes. The old-fashioned bind mount turned out to be the practical choice.
Feeding the Fritz!Box
A network-wide ad blocker is useless if no device uses it. Instead of changing DNS settings on every phone, laptop, and TV by hand, I let the Fritz!Box hand out AdGuard as the default DNS server over DHCP. In the Fritz!Box UI, go to http://fritz.box → Internet → Account Information → DNS Server, enable "Use other DNSv4 servers", and enter 192.168.50.250 as the preferred DNS server. For IPv6 DNS, add fd15:d91c:273f:0::250 as well. Then briefly toggle Wi-Fi off and on so devices pick up the new settings.
The result
Open the AdGuard query log and you can watch tracking domains get dropped in real time.
The YouTube app on the smart TV feels snappier because it spends less time on ad requests, and every device in the house sees less tracking.
A few things I took away: Macvlan is the cleanest way around a host-level port conflict like port 53; a two-network Docker setup only works reliably if the service binding is right; and a pragmatic bind mount sometimes beats a cleaner read-only config. The 35-watt roommate now doubles as the bouncer for the home network.
This post was originally published on www.slashgordon.link.

Top comments (0)