Why every device in my house was called 172.18.0.1
2026-09-20
I run AdGuard Home in Docker to filter DNS queries on my home network. It blocks ads and trackers for every device in my house. The AdGuard dashboard shows a nice query log, so you can see which device is asking for which domain and set per-client rules for the smart TV that really doesn't need to talk to seventeenthousand tracking servers (looking at you, LG).
This worked for a while, until it didn't. In my query log, every single client was listed as 172.18.0.1.
It shows up as one client, no matter how many devices are making requests. Although it did not make the DNS filtering any worse, it did make any sort of client analysis or statistics useless. Over a 2-day debugging session (using the GLM 5.3 model on the side) I finally looked into this.
Docker rewrites your IPs
My AdGuard container ran on a user-defined bridge network, which is shared with my reverse proxy. When a device on my network sends a DNS query to my server, Docker's NAT rewrites the packet before handing it to the container. The source address becomes the Docker bridge gateway (172.18.0.1). This is well-known docker networking behaviour. This means that AdGuard only ever sees this IP, and it cannot resolve the source.
This is a well-known issue, just use network_mode: host. I'm sure this work well, except that I run rootless Docker. With rootless, even host networking happens inside RootlessKit's own network namespace. The container never actually shares the host's real network stack, so this wasn't going to work.
RootlessKit V3
Then I found something interesting. Docker 29.5 bundles RootlessKit v3, which adds support for propagating the original source IP of incoming connections and I was on 29.7.2.
This did not work either:
- The propagation is TCP only, whereas DNS is UDP.
- Even for TCP, it doesn't work with the default
userland-proxyenabled.
Pasta
The actual documented fix (on the same page) for source IPs in rootless Docker is switching the daemon to the pasta network driver with the implicit port driver. Pasta (from the passt project) was specifically designed to solve source IP preservation in rootless environments by forwarding traffic in a way that preserves real source addresses for both TCP and UDP.
Configuring it was pretty simple:
# ~/.config/systemd/user/docker.service.d/override.conf
[Service]
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_NET=pasta"
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=implicit"
Installing passt and restarting docker:
sudo apt install passt
systemctl --user daemon-reload
systemctl --user restart docker
docker compose down && docker compose up -d
Unfortunately, DNS stopped working entirely afterwards. The dashboard wasn't reachable either.
Privileged ports
To debug what was going on, GLM 5.3 kept suggesting ss commands. I had never heard of this, but apparently it's "another utility to investigate sockets".
sudo ss -tlnp | grep -E ':53|:3000'
This showed that port 3000 was bound by pasta.avx2 now, instead of rootlesskit. But it showed nothing for port 53? Nothing. Port 53 is a privileged port (< 1024). Therefore, I run this on start-up so that AdGuard (as a Docker container) gets forwarded DNS requests:
sudo setcap cap_net_bind_service=ep $(which rootlesskit)
This means that rootlesskit could bind port 53 as my unprivileged user. However, because Pasta replaces RootlessKit as the network stack handler, these capabilities don't apply to it. Since file capabilities don't propagate to child processes, Pasta tried to bind port 53 without the necessary permissions, whereas port 3000 doesn't require any special configuration.
Although I could change the command to give pasta access to privireleged ports, at this point I had hit two experimental edge cases, and I don't want to overconfigure my server.
The practical solution: Run it on the host
At the same time I realized that AdGuard home ships a single binary to deploy on my server. This is the recommended installation path. Running a UDP DNS server on a privileged port inside rootless Docker means fighting the kernel and port forwarding.
So I decided to ditch Docker and run AdGuard Home with systemd. It's a single static binary, and it installs itself as a service:
curl -LO https://github.com/AdguardTeam/AdGuardHome/releases/latest/download/AdGuardHome_linux_amd64.tar.gz
tar -xzf AdGuardHome_linux_amd64.tar.gz
I could re-use my existing config and query data by passing the work dir and config file:
sudo ./AdGuardHome -s install -c ~/docker/adguard/conf/adguardhome.yaml -w ~/docker/adguard/work
This worked pretty much out of the box. Running ss confirmed that this works:
$ sudo ss -ulpn | grep ':53 '
UNCONN 0 0 *:53 *:* users:(("AdGuardHome",pid=432113,fd=8))
AdGuard itself, on port 53, no extra configuration, no sysctls, no port forwarders. And the query log started filling with real client IPs 😅
A bad gateway
Caddy still runs in Docker and proxies the dashboard, so I updated the Caddyfile:
http://adguard.home.arpa {
reverse_proxy host.docker.internal:3000
}
Firefox answered with a 502 Bad Gateway. It turned out that host.docker.internal is injected automatically in rootful Docker. In rootless mode, it often exists but points to the loopback of the user namespace rather than the host network, making it unreachable from the container. I could fix this by just using my server's LAN IP:
http://adguard.home.arpa {
reverse_proxy 192.168.0.123:3000
}
I reloaded Caddy, hit refresh, and everything worked as expected.
Final setup
When I set up this server years ago, my goal was to learn Docker. So I did everything in Docker. This works well for complex systems such as home assistant. However, my focus on Docker got in the way here. AdGuard's setup and deployment with systemd is so simple. Static binaries are great.
My final setup:
- AdGuard Home runs natively on the host via systemd, sees every client's real IP, per-client filtering works
- Caddy (still in Docker) proxies the dashboard
- DNS runs directly to the host on port 53.
TL;DR: To preserve source IPs in rootless Docker, use the pasta network driver. However, for simple binaries like AdGuard Home, running natively via systemd is often much simpler and avoids configuration and privilege management.