AdGuard Home on a VPS: network-wide ad blocking without an open resolver
Pi-hole and AdGuard Home are the "first app" of many homelabs: point your devices at them and ads, trackers and known malware domains stop resolving, on every device, with no browser extension. At home that is easy. On a VPS there is one trap that catches a lot of people: a DNS server that answers anyone on the internet gets abused for DDoS amplification within days. This guide sets up AdGuard Home with Docker Compose and keeps it private.
Pick the right design first
- Home network (Raspberry Pi, mini PC): plain DNS on port 53 on your LAN is fine. Never forward port 53 from your router.
- VPS, private via VPN (recommended): bind DNS only to the WireGuard interface. Your phone and laptop use it while connected. See WireGuard on a VPS.
- VPS, public encrypted DNS: DNS over HTTPS or TLS with client restrictions. Works for phones with no VPN, but needs more care.
Not sure whether you need a VPN or a mesh tool? Compare them in WireGuard vs Tailscale vs NetBird.
What it needs
Very little: roughly 100 to 200 MB of RAM and almost no CPU, even with large blocklists. It fits on the smallest VPS next to other apps. See the RAM budget post.
Docker Compose for the VPN-only setup
Assume WireGuard uses 10.8.0.1 on the server. Bind DNS to that address only, never to 0.0.0.0:
services:
adguardhome:
image: adguard/adguardhome:v0.107.57
container_name: adguardhome
restart: unless-stopped
ports:
- "10.8.0.1:53:53/tcp"
- "10.8.0.1:53:53/udp"
- "127.0.0.1:3000:3000/tcp" # first-run wizard only
volumes:
- ./work:/opt/adguardhome/work
- ./conf:/opt/adguardhome/conf
Check the current tag and pin it instead of using latest; see updating containers safely. If the WireGuard interface comes up after Docker, the bind can fail at boot; start Docker after wg-quick@wg0 with a systemd drop-in (the systemd generator helps).
docker compose up -d
The wizard on port 3000 is bound to localhost, so reach it through an SSH tunnel:
ssh -L 3000:127.0.0.1:3000 user@your-server
Open http://localhost:3000, set the admin interface to port 80 inside the container, DNS to port 53, and create a strong admin user. Afterwards the dashboard lives on port 80 in the container. Publish it on localhost or put it behind Caddy with a login (Caddy and Docker guide); never expose the admin panel unauthenticated.
Systemd-resolved owns port 53?
On Ubuntu and Debian, systemd-resolved listens on 127.0.0.53:53. Because you bind to the WireGuard address, there is normally no conflict. If you do bind 0.0.0.0 for a LAN setup, set DNSStubListener=no in /etc/systemd/resolved.conf and restart the service.
Upstream servers and blocklists
- Upstream: use DNS over HTTPS upstreams such as
https://dns.quad9.net/dns-queryor a provider you trust, and enable parallel requests. Privacy is only moved, not removed: your upstream still sees your queries. - Blocklists: start with the default AdGuard DNS filter. Adding five giant lists mostly adds false positives. Add more only when you see ads that slip through.
- Query log: decide how long to keep it. Seven days is plenty, and you can disable it if you share the server.
Point devices at it
In your WireGuard client config add DNS = 10.8.0.1 under [Interface]. While the tunnel is on, every query goes through AdGuard. Test it:
dig @10.8.0.1 doubleclick.net +short
A blocked domain returns 0.0.0.0. Then, from a machine outside the VPN, confirm the server is not an open resolver:
dig @YOUR_PUBLIC_IP example.com +time=3 +tries=1
That must time out. If it answers, fix the binding before doing anything else.
Firewall
Belt and braces: only allow port 53 from the VPN subnet. With UFW:
ufw allow in on wg0 to any port 53 ufw status
Docker publishes ports by editing iptables directly and can bypass UFW rules, which is another reason to bind to a specific address in the compose file. Generate a full ruleset with the firewall generator and follow the fresh VPS setup and SSH hardening guides first.
Public DoH for phones without a VPN
AdGuard Home can serve DNS over HTTPS and TLS itself with a certificate. If you go this way, use a long random path or client-specific identifiers so only your devices use it, and enable rate limiting in the DNS settings. This is more maintenance and more exposure than the VPN design, so choose it only if you cannot run a tunnel on a device.
Things that break, and the fix
- A site or app stops working: check the query log, find the blocked domain and allow it. Banking and streaming apps are the usual suspects.
- Everything fails when the container is down: add a second, non-blocking DNS server on the clients as a fallback, or accept that "DNS down" means "internet down" on that tunnel.
- YouTube ads still appear: DNS blocking cannot remove ads served from the same domain as the video. Use a browser blocker for that.
Back it up
Everything is in conf/AdGuardHome.yaml. Add it to your restic backup; the work folder (logs and statistics) is optional. Size your schedule with the backup planner.