The 35-Watt Roommate (Part 6): Ads? Never Heard of Them.
In Part 1 I set up the Lenovo M920q as my compute node. Part 2 was about connecting the homelab to the internet securely. Now it is time for the little homeserver to improve daily life in a more direct way: kill ads.
The Goal: Peace and Quiet Across the 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 used to be the default choice for a long time, but I went with AdGuard Home instead. It feels more modern, supports DNS-over-HTTPS and DNS-over-TLS out of the box, and I simply like the interface more.
Sounds easy enough, right? Start the container, done. That was optimistic.
The Final Boss: Port 53
My first naive attempt was to run the container in the host network. That failed immediately. The reason: port 53.
This is the standard port for DNS requests. On almost every modern Linux server, including my Ubuntu host, that port is already occupied by the systemd-resolved service. The container could not start.
Sure, 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 Elegant Fix: Macvlan
A Docker Macvlan network is great for cases like this. It lets the container behave like a real physical device on the network. It gets its own MAC address and, more importantly, its own IP address from the router, completely independent of the host server.
That means: no more port conflict on port 53.
Since my network is dual-stack capable, I wanted to do it properly. The container gets both a dedicated IPv4 address (192.168.50.250) and a dedicated IPv6 address, using a stable ULA starting with fd.... Future-proof.
The Next Problem: Traefik
At that point the DNS blocker worked, but I wanted access to the web dashboard through my secure domain (adguard.dieck-labs.de) via Traefik as a reverse proxy. I did not want to keep typing IP addresses into the browser like a caveman.
This is where it became annoying.
Problem 1: 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.
The fix: the container has to join both networks. Macvlan for DNS traffic from the LAN, apps for Traefik.
Problem 2: The 502 Bad Gateway Drama
After wiring it into Traefik, I got nothing but 502 Bad Gateway errors. Hours of debugging.
The root cause was subtle: if you do the initial AdGuard Home setup via the Macvlan IP, it often configures itself to listen only on that exact interface. Requests coming from Traefik through the internal apps network are then ignored.
The fix: 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. That way Traefik can reach it cleanly while DNS continues to use port 53.
The Architecture
This is what the traffic flow looks like now:
The Config: Two Networks, One Container
Here is the final Docker Compose file (adguard.yml in the repo). The result of too many evenings spent 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 important detail: AdGuardHome.yaml in the local ./adguard directory needs a small manual adjustment:
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. Very clean, very Infrastructure as Code, very satisfying on paper. But it also made the file read-only, which meant the AdGuard dashboard could no longer save changes. In the end, the old-fashioned bind mount was simply the more practical solution.
Feeding the Fritz!Box
A network-wide ad blocker is useless if no device actually uses it. Instead of manually changing DNS settings on every phone, laptop, and TV, I let the Fritz!Box hand out AdGuard as the default DNS server through DHCP.
- Open the Fritz!Box UI:
http://fritz.box→ Internet → Account Information → DNS Server - Set DNSv4: enable “Use other DNSv4 servers” and enter
192.168.50.250as the preferred DNS server - Set DNSv6: if you want IPv6 DNS as well, enter
fd15:d91c:273f:0::250 - Reconnect devices: the easiest way is to briefly disable and re-enable Wi-Fi so devices pull the new DNS settings

The Result
Now when I open the AdGuard query log, I can watch tracking domains get sent straight into digital oblivion.
The YouTube app on the smart TV feels snappier because it spends less time on ad-related requests, and the privacy side is even better: less tracking for every device in the house.
What I learned:
- Macvlan is the cleanest solution for host-level port conflicts like port 53
- Two-network Docker setups only work reliably if the service binding is configured correctly
- Sometimes a pragmatic bind mount is better than a theoretically cleaner read-only config
The little 35-watt roommate now has another job: bouncer for the home network.