AdGuard Home on Docker — Bridge Mode and Upstream DNS
The default AdGuard Home documentation puts it on host networking and walks you through disabling systemd-resolved to free port 53. That's one way to do it. If you're already running a layered DNS stack — an upstream resolver handling local overrides, a reverse proxy managing your internal services — host networking creates more problems than it solves. This build runs AdGuard in bridge mode with explicit port mappings, pointing upstream at an existing Unbound resolver. The filtering layer stays in Docker where it belongs, and the rest of the stack doesn't have to change around it.
The Unbound configuration on OPNsense is covered separately. This build assumes it exists and is reachable — the only thing AdGuard needs to know is its address. Jump to Commands Reference →
Create the Working Directories
Docker does not create host paths that don't exist at deploy time. On Rocky Linux with SELinux enforcing, a missing directory produces a permission error that looks like an SELinux policy problem — it isn't, the path just wasn't there. Create both directories before deploying the stack:
mkdir -p /working/docker/adguard/confdir
mkdir -p /working/docker/adguard/workdir
These map to AdGuard's config and work directories inside the container. Keep them separate — AdGuard writes runtime data to the work directory and configuration to conf. Mixing them into one mount makes backups messier than they need to be.
Deploy the Stack
Deploy via Portainer Stacks. The compose file uses bridge networking with explicit port mappings for DNS and the admin interface.
version: "3.8"
services:
adguard:
container_name: adguard
restart: unless-stopped
image: adguard/adguardhome:latest
volumes:
- /working/docker/adguard/confdir:/opt/adguardhome/conf:z
- /working/docker/adguard/workdir:/opt/adguardhome/work:z
- /etc/localtime:/etc/localtime:ro
ports:
- "53:53/tcp"
- "53:53/udp"
- "3000:3000/tcp"
- "80:80/tcp"
networks:
- adguard_net
networks:
adguard_net:
driver: bridge
The volume mounts use :z rather than :Z — lowercase z applies a shared SELinux label, appropriate when multiple containers might need access to the same path. :Z marks the volume as private to a single container. For AdGuard's config and work directories, either works in practice, but :z is the safer default if you ever want to mount these paths elsewhere for backup or inspection. The /etc/localtime mount keeps container timestamps aligned with the host — AdGuard's query log timestamps are the main reason this matters.
Port 53 is published on both TCP and UDP. This works cleanly on a minimal Rocky Linux install where nothing else is competing for the port. If you're on a full install with systemd-resolved active, that service squats on 127.0.0.53:53 by default and will conflict — disabling the stub listener in /etc/systemd/resolved.conf resolves it, but that scenario is out of scope here.
Port 3000 is the setup wizard — only needed on first run. Port 80 becomes the admin interface after the wizard completes. Both are exposed explicitly here so the wizard is reachable from a LAN device without any additional steps.
Run the Setup Wizard
Navigate to http://<host-ip>:3000 from a LAN device. The wizard walks through interface binding, admin interface port, and initial credentials.
During the wizard, bind the DNS server to the host's main LAN interface rather than all interfaces. Binding to all interfaces means AdGuard answers DNS on Docker's internal bridge addresses too, which creates noise in the query log without adding anything useful. Set the admin interface to port 80.
After the wizard completes, AdGuard restarts on the configured ports. Port 3000 is no longer active — the admin interface is now at http://<host-ip>:80.
Configure the Upstream Resolver
In Settings → DNS settings, set the upstream DNS server to your Unbound instance on OPNsense. If Unbound is listening on the standard port at your router's LAN IP:
<router-ip>:53
Replace with your actual router LAN IP. All queries AdGuard can't answer from its filtering rules or cache go upstream to Unbound, which handles local hostname overrides and recursive resolution from there. The filtering happens at AdGuard. The resolution happens at Unbound. Each layer does one job.
Set the Bootstrap DNS to a reliable IP-addressed resolver — 1.1.1.1 or 9.9.9.9 work fine. Bootstrap is only used during startup to resolve upstream hostnames if you're using DoH endpoints. Since this setup points at a local IP, bootstrap is mostly academic here, but it's worth setting correctly in case the upstream config changes later.
Point the Network at AdGuard
In OPNsense, update the DHCP server configuration for your client network to hand out the host IP as the primary DNS server. Leave the router's own IP as the secondary. Clients pick this up on their next DHCP renewal.
The secondary DNS fallback matters: when the host running AdGuard is down, clients fall back to Unbound directly and continue resolving. They lose the filtering layer, but they don't lose connectivity. That's a conscious tradeoff — a degraded-but-functional state is better than a hard outage for every client on the network the moment the primary server goes offline.
After clients start using AdGuard as their resolver, the query log in the AdGuard interface will begin filling with traffic. That's the confirmation it's working.
Reverse Proxy Note
If you're running Traefik or another reverse proxy in front of your services, AdGuard's admin interface can be routed through it like any other container — add the appropriate labels or config, drop the explicit port 80 mapping, and access the interface through your proxy. The DNS ports (53 TCP/UDP) stay as direct port mappings regardless — reverse proxies handle HTTP, not DNS traffic.
Commands Reference
# Create working directories
mkdir -p /working/docker/adguard/confdir
mkdir -p /working/docker/adguard/workdir
# docker-compose.yml
# ---
# version: "3.8"
#
# services:
# adguard:
# container_name: adguard
# restart: unless-stopped
# image: adguard/adguardhome:latest
# volumes:
# - /working/docker/adguard/confdir:/opt/adguardhome/conf:z
# - /working/docker/adguard/workdir:/opt/adguardhome/work:z
# - /etc/localtime:/etc/localtime:ro
# ports:
# - "53:53/tcp"
# - "53:53/udp"
# - "3000:3000/tcp"
# - "80:80/tcp"
# networks:
# - adguard_net
#
# networks:
# adguard_net:
# driver: bridge
# ---
# Verify AdGuard is answering DNS
dig @<host-ip> example.com
# Verify from a LAN client
nslookup example.com <host-ip>