High-Availability DNS: How to Deploy Dual AdGuard Home with Keepalived and AdGuardHome-Sync in Docker

Eliminate DNS as a single point of failure in your homelab or office. Deploy dual AdGuard Home instances with Keepalived Virtual IP failover and automated configuration synchronization in Docker.

Network engineer inspecting dual AdGuard Home high-availability DNS setup with Keepalived Virtual IP in Docker
Eliminate the single point of failure in your network with dual AdGuard Home instances, Keepalived Virtual IP failover, and automated sync.

Domain Name System (DNS) resolution is the fundamental backbone of any digital environment. In modern homelabs, home offices, and enterprise networks, administrators routinely deploy local DNS sinkholes like AdGuard Home or Pi-hole to strip telemetry, block malicious domains, filter intrusive advertisements, and resolve private split-horizon domain names for internal services. However, running a single DNS server introduces an intolerable Single Point of Failure (SPOF). If that single host reboots for a kernel patch, crashes, or suffers a power blip, every client on your network instantly loses internet connectivity.

A common misconception is that entering two DNS IP addresses into your DHCP router settings provides seamless redundancy. In reality, client operating systems (including Windows, macOS, Android, and iOS) do not treat primary and secondary DNS servers as an active/standby pair. Instead, they query both servers concurrently or route queries non-deterministically. If your secondary server is a fallback public DNS (such as 1.1.1.1 or 8.8.8.8), ads and telemetry will constantly leak through. If both entries point to un-synchronized local instances, maintaining custom DNS rewrites and client allowlists across two separate web consoles quickly becomes a maintenance nightmare.

The definitive engineering solution is High-Availability (HA) Dual AdGuard Home orchestrated with Keepalived and AdGuardHome-Sync. In this comprehensive guide, you will learn how to deploy two redundant AdGuard Home nodes across separate Docker hosts, bind them to a single floating Virtual IP (VIP) using VRRP (Virtual Router Redundancy Protocol), and automate real-time two-way configuration synchronization.

Architecture: VRRP Failover & Automated Config Sync

Our architecture utilizes two physical or virtual Linux nodes: Node 1 (Primary) and Node 2 (Secondary). Each node runs AdGuard Home in a Docker container using host networking for zero-overhead Layer 4 DNS throughput. Keepalived manages a floating Virtual IP (e.g., 192.168.1.5). All network clients receive only this single Virtual IP via DHCP.

+-----------------------------------------------------------------------+
|  Local Area Network Clients (Workstations, Phones, Smart Devices)     |
|  Configured DHCP DNS: 192.168.1.5 (Floating Virtual IP)               |
+-----------------------------------+-----------------------------------+
                                    |
                    VRRP Virtual IP: 192.168.1.5
                                    |
            +-----------------------+-----------------------+
            |                                               |
+-----------v-----------------------+   +-------------------v-----------+
|  Node 1: Primary (192.168.1.10)   |   |  Node 2: Backup (192.168.1.11) |
|                                   |   |                               |
|   +---------------------------+   |   |   +-----------------------+   |
|   | Keepalived (MASTER)       |   |   |   | Keepalived (BACKUP)   |   |
|   | Priority: 101             |   |   |   | Priority: 100         |   |
|   | Healthcheck: port 53 DNS  |   |   |   | Healthcheck: port 53  |   |
|   | Holds VIP: 192.168.1.5    |   |   |   | Standby Mode          |   |
|   +-------------+-------------+   |   |   +-----------+-----------+   |
|                 |                 |   |               |               |
|   +-------------v-------------+   |   |   +-----------v-----------+   |
|   | AdGuard Home (Docker)     |   |   |   | AdGuard Home (Docker) |   |
|   | Port 53 / WebUI: 3000     |   |   |   | Port 53 / WebUI: 3000 |   |
|   +-------------^-------------+   |   |   +-----------^-----------+   |
+-----------------|-----------------+   +---------------|---------------+
                  |                                     |
                  +========= AdGuardHome-Sync ==========+
                             (Runs in Docker)
                    Syncs: Rewrites, Filters, Clients,
                     Upstream DNS, and Blocklists

If Node 1’s hardware fails or its AdGuard container crashes, Keepalived’s internal health check detects the failure within 2 seconds. The Virtual IP immediately migrates to Node 2 with zero manual intervention and zero dropped connections.

Prerequisites & Network Planning

Plan your IP allocation before editing configuration files. Example topology:

  • Router Gateway: 192.168.1.1
  • Virtual Floating IP (VIP): 192.168.1.5 (Unassigned IP in your subnet outside the DHCP pool)
  • Primary Host (Node 1): 192.168.1.10 (Mini PC, server, or Proxmox VM)
  • Secondary Host (Node 2): 192.168.1.11 (Second server, Raspberry Pi, or separate VM)
  • Subnet Mask: /24 (255.255.255.0)
  • Network Interface: eth0 (Verify on your host using ip -br link)

Step 1: Resolve Port 53 Conflicts (systemd-resolved)

Modern Ubuntu and Debian distributions run systemd-resolved by default, binding a local DNS stub listener to 127.0.0.53:53. This blocks AdGuard Home from binding to port 53. Execute this step on both Node 1 and Node 2:

# Edit the systemd-resolved configuration
sudo mkdir -p /etc/systemd/resolved.conf.d/
sudo tee /etc/systemd/resolved.conf.d/adguard.conf <<'EOF'
[Resolve]
DNS=127.0.0.1
DNSStubListener=no
EOF

# Update symlink for resolv.conf to point to static configuration
sudo rm -f /etc/resolv.conf
sudo ln -s /run/systemd/resolve/resolv.conf /etc/resolv.conf

# Restart systemd-resolved
sudo systemctl restart systemd-resolved

# Confirm port 53 is completely free
sudo ss -tulpn | grep :53

Step 2: Deploy AdGuard Home in Docker Compose

On both Node 1 and Node 2, create the base deployment directory:

sudo mkdir -p /opt/adguard-ha/{adguard_work,adguard_conf} && cd /opt/adguard-ha

Create the docker-compose.yml file. We use network_mode: host to bypass Docker’s userland proxy overhead and ensure that client source IP addresses are preserved accurately in AdGuard’s query logs:

# /opt/adguard-ha/docker-compose.yml
services:
  adguardhome:
    image: adguard/adguardhome:latest
    container_name: adguardhome
    restart: unless-stopped
    network_mode: host
    volumes:
      - ./adguard_work:/opt/adguardhome/work
      - ./adguard_conf:/opt/adguardhome/conf
    environment:
      - TZ=UTC

Start AdGuard Home on both nodes:

docker compose up -d

Complete the initial wizard on both nodes by opening http://192.168.1.10:3000 and http://192.168.1.11:3000. Bind the DNS server to all interfaces on port 53 and set the administrative Web interface port to 8080 (or 3000). Create an identical administrator username and password (e.g., adguardadmin) on both instances.

Step 3: Configure Keepalived for Virtual IP Failover

Install Keepalived directly on the host operating system of both nodes. While running Keepalived in Docker is possible, running it on the host ensures direct kernel netlink interaction, resilient ARP handling, and seamless failover even if the Docker daemon restarts:

sudo apt-get update && sudo apt-get install -y keepalived curl

Configuration on Node 1 (MASTER)

Create /etc/keepalived/keepalived.conf on Node 1 (192.168.1.10). Replace eth0 with your actual network interface name:

# /etc/keepalived/keepalived.conf (Node 1 - MASTER)
global_defs {
    router_id adguard_node1
    enable_script_security
    script_user root
}

vrrp_script check_dns {
    script "/usr/bin/curl -s -o /dev/null -m 2 http://127.0.0.1:3000/control/status || exit 1"
    interval 2
    weight 4
    fall 2
    rise 2
}

vrrp_instance VI_ADGUARD {
    state MASTER
    interface eth0
    virtual_router_id 51
    priority 101
    advert_int 1

    authentication {
        auth_type PASS
        auth_pass Secr3tVrrpP@ss!
    }

    virtual_ipaddress {
        192.168.1.5/24 dev eth0
    }

    track_script {
        check_dns
    }
}

Configuration on Node 2 (BACKUP)

Create /etc/keepalived/keepalived.conf on Node 2 (192.168.1.11):

# /etc/keepalived/keepalived.conf (Node 2 - BACKUP)
global_defs {
    router_id adguard_node2
    enable_script_security
    script_user root
}

vrrp_script check_dns {
    script "/usr/bin/curl -s -o /dev/null -m 2 http://127.0.0.1:3000/control/status || exit 1"
    interval 2
    weight 4
    fall 2
    rise 2
}

vrrp_instance VI_ADGUARD {
    state BACKUP
    interface eth0
    virtual_router_id 51
    priority 100
    advert_int 1

    authentication {
        auth_type PASS
        auth_pass Secr3tVrrpP@ss!
    }

    virtual_ipaddress {
        192.168.1.5/24 dev eth0
    }

    track_script {
        check_dns
    }
}

Enable and start the Keepalived service on both machines:

sudo systemctl enable --now keepalived

Verify that Node 1 currently holds the Virtual IP:

# On Node 1:
ip addr show eth0 | grep 192.168.1.5

Step 4: Synchronizing Configuration with AdGuardHome-Sync

With failover active, any change made on the primary node (such as adding a DNS rewrite for nas.local or blacklisting a telemetry domain) must automatically replicate to the secondary node. We deploy adguardhome-sync in Docker on Node 1.

Append the sync service to /opt/adguard-ha/docker-compose.yml on Node 1:

# Append to /opt/adguard-ha/docker-compose.yml on Node 1
  adguardhome-sync:
    image: ghcr.io/bakito/adguardhome-sync:v0.6.14
    container_name: adguardhome-sync
    restart: unless-stopped
    environment:
      - ORIGIN_URL=http://192.168.1.10:3000
      - ORIGIN_USERNAME=adguardadmin
      - ORIGIN_PASSWORD=YourSecurePassword123!
      - REPLICA_URL=http://192.168.1.11:3000
      - REPLICA_USERNAME=adguardadmin
      - REPLICA_PASSWORD=YourSecurePassword123!
      - CRON=*/5 * * * *
      - RUN_ON_START=true
      - FEATURES_GENERAL_SETTINGS=true
      - FEATURES_REWRITES=true
      - FEATURES_FILTER_LISTS=true
      - FEATURES_CLIENTS=true
      - FEATURES_SERVICES=true

Re-run Docker Compose on Node 1:

docker compose up -d

Inspect the synchronization logs to verify seamless replication:

docker logs -f adguardhome-sync

Step 5: Testing Live VRRP Failover

Before updating your main DHCP router, test the resilience of your cluster from any workstation on the network:

  1. Send continuous DNS lookups to the Virtual IP:
    # On your client workstation
    while true; do dig @192.168.1.5 google.com +short; sleep 0.5; done
  2. Simulate a catastrophic primary failure by stopping the AdGuard container on Node 1:
    # On Node 1
    docker stop adguardhome
  3. Watch the client terminal. The continuous dig stream will continue uninterrupted without a single dropped packet. Node 2 detects the health check failure, assumes MASTER state, and claims 192.168.1.5.
  4. Restart the primary container (docker start adguardhome). Node 1 automatically reclaims the MASTER role due to its higher VRRP priority.

Once validated, log into your main network router (OPNsense, UniFi, pfSense, or consumer router) and update the DHCP Server options: set DNS Server 1 to 192.168.1.5 and leave DNS Server 2 empty.

Troubleshooting Common Production Pitfalls

1. VRRP Split-Brain: Both Nodes Claim the Virtual IP

Symptoms: Running ip addr on both nodes displays 192.168.1.5 simultaneously, causing erratic DNS resolution and packet loss.

Resolution: VRRP relies on multicast packets sent to 224.0.0.18. Managed network switches with aggressive IGMP snooping often drop these packets. Ensure IGMP snooping is disabled on your switch port VLAN, verify that host firewalls permit VRRP traffic:

sudo ufw allow in on eth0 proto vrrp

2. AdGuard Fails to Start: “bind: address already in use” on Port 53

Symptoms: The AdGuard container exits immediately with code 1, reporting an inability to bind to UDP/TCP port 53.

Resolution: A host service is still bound to port 53. Check active bindings with sudo lsof -i :53. Common culprits include dnsmasq, bind9, or an incomplete systemd-resolved disablement. Ensure DNSStubListener=no was set in Step 1.

3. AdGuardHome-Sync Loop or Authentication Lockout

Symptoms: AdGuardHome-Sync logs 403 Forbidden or triggers account rate-limiting.

Resolution: Ensure both nodes are running identical minor versions of AdGuard Home. Verify that the administrative user account has complete administrative privileges and does not require multi-factor authentication (2FA) for API endpoints.

Conclusion & Next Steps

Deploying dual AdGuard Home instances with Keepalived and AdGuardHome-Sync turns a brittle homelab DNS setup into a rock-solid, enterprise-grade high-availability system. Your entire household or office benefits from network-wide privacy filtering, lightning-fast cached DNS queries, and zero-downtime maintenance windows.

To further enhance your infrastructure, configure Caddy as a reverse proxy with automated SSL to encrypt internal WebUI access, explore Docker host and bridge networking architectures, or route remote mobile devices through your HA DNS using a Tailscale subnet router.