Skip to content

adguard two node setup with adguard sync

Description

  • UPDATED 9/2/2025 - here we are a few years later, adguuard has been stable as heck
  • now i wanted to add IPv6 to this mix

these were the steps

  1. stop the stack
  2. delete the 6 adguard config networks and the two deployed macvlans from within portainer
  3. recreate using the instructions below adding the following for IPv6 (note the Ipv6 are the documented subnet examples - don't use them, use ones right for your network)

    node 1
    subnet  2001:db8:1000:1::/64
    gateway 2001:db8:1000:1::1
    range   2001:db8:1000:1::5/128
    
    node 2
    subnet  2001:db8:1000:1::/64
    gateway 2001:db8:1000:1::1
    range   2001:db8:1000:1::6/128
    
  4. assign the actual MVL networks (i actually renamed mine so the 6 config networks are called adguard1/2-config and the two macvlan networks are called adguard1/2-mvl - much easier, i had them the wrong way round when i wrote the original article)

  5. restart the stack (it really was this easy)
  • learning: also the randomness i talk about below when selecting the networks in the UI can be avoided if all your machines are hosts!
  • don't forget to add the IPv6 upstream resolvers in adguard

I wanted redundant adguard - there are two ways to do this:

  1. run single swarm instance and assume swarm will keep the service running (i ahve a template for this at the bottom of this gist)
  2. run two instances so you can specify two DNS servers on client - this is much harder and requires adguard sync too - this is what we are covering in this gist.
  • I also wanted adguard to accurately record the client host names accessing adgaurd - this meant i needed to use macvlan networking.
  • I also wanted to use native ports like 443 but have other services that need to use that too so rather than use host networking i used macvlan/ This is not rquired but i wanted to make this one interesing :-)

Update as of 2026.09.24: this is how i first set it up. what i run now is at the end.

State Considerations for SWARM

Each of the two nodes needs to have their own confgi and worker mounts. I chose to use the glusterfs volume driver to make these so they are available on any node.

Network Considerations

Wow, this is the most complex network setup because i need each adguard instance to be able to have its own MAC and IP address and i needed the adguard sync container to be able to sync between the two nodes. Also macvlan in swarm is a quite complex and a little werid. We have the following networks in this config:

  • adguard1-mvl-config

    • public macvlan config for adguard1 and is dsitributed to all 3 docker nodes
  • adguard1

    • public macvlan network used in the adgaurd1 container
  • adguard2-mvl-config

    • public macvlan config for adguard2 and is dsitributed to all 3 docker nodes
  • adguard2

    • public macvlan network used in the adgaurd2 container
  • adguard_sync

    • private overlay network to allow all 3 nodes to talk to each other for purpose of sync

Placement Considerations

  • It is not possible to have a single host adapter (i.e eth0) have two macvlans running at the same time.
  • It is not possible to have a sigle host support two default gateways.
  • You may see placement rejection of the second service if it initially tries to place it on the same node as the other adguard instance. Once rejected docker will try the service on another node and it will wok. The rejection errors can be ignored. This works as an implicit placement constraint. If someone knows how to specifiy that two services in the same stack run on different swarm nodes (lables won't cut it in this 3 node scenario) let me know in the comments!

Reaching AdGuard from the node it runs on

A macvlan child cannot talk to its parent. The node running an AdGuard cannot reach it, and nor can any container on that node. Other nodes on the LAN can. Anything on that node that needs DNS or the AdGuard API times out.

Each docker host carries a macvlan shim to fix it. mac0 is a second macvlan child of eth0, and child-to-child traffic is allowed, so routing the two resolver addresses out of it works. eth0's own stanza in /etc/network/interfaces builds it:

allow-hotplug eth0
iface eth0 inet static
  address 192.168.1.41
  netmask 255.255.255.0
  gateway 192.168.1.1
  post-up ip link add mac0 link eth0 type macvlan mode bridge 2>/dev/null || true
  post-up ip link set dev mac0 up
  post-up ip addr add 192.168.1.41/32 dev mac0 2>/dev/null || true
  post-up ip route replace 192.168.1.5/32 dev mac0 src 192.168.1.41
  post-up ip route replace 192.168.1.6/32 dev mac0 src 192.168.1.41
  post-up iptables -N DOCKER-USER 2>/dev/null || true
  post-up iptables -C DOCKER-USER -o mac0 -j ACCEPT 2>/dev/null || iptables -I DOCKER-USER -o mac0 -j ACCEPT
  post-up iptables -C DOCKER-USER -i mac0 -j ACCEPT 2>/dev/null || iptables -I DOCKER-USER -i mac0 -j ACCEPT
  pre-down ip link del mac0 2>/dev/null || true
  • only the host's own address changes per host, in the ip addr line and both src
  • it is in eth0's stanza, not one of its own, because a macvlan child is deleted with its parent. eth0 is allow-hotplug, so when its card comes back its stanza runs again and rebuilds mac0. a separate auto mac0 stanza only runs at boot
  • the shim carries the host's own address as a /32, the one eth0 already has, so nothing has to be reserved. without it, docker's masquerade borrows the first address it finds in device order. that is eth0's until the card is re-plugged, which re-registers eth0 after docker0. then containers leave mac0 as 172.16.0.1 and reach neither adguard, while the host still does
  • an iptables SNAT rule instead does not survive a reboot. networking starts before docker, and docker inserts its masquerade rule at the top
  • the /32 routes are correct whether or not the adguard is local. mac0 is on the same segment, so traffic to a resolver on another node just goes out the wire
  • the iptables rules let container traffic forward out mac0. without them the host works but containers do not. containers also lose the remote resolver they reached before, because the route pulls that traffic onto mac0
  • DOCKER-USER is created first because networking starts before docker at boot. docker keeps an existing chain and its rules

Check it from the host and from a container. If swarm services depend on it, check from a container on an overlay network as well:

docker run --rm --network <an overlay> alpine:3 ping -c2 192.168.1.5

With the shim in place no service needs a placement constraint to avoid the AdGuard nodes. On a three node cluster, a service kept off both resolvers has one eligible node, and losing that node leaves it unschedulable.

when a docker VM's NIC changes

proxmox unplugs and re-plugs a running VM's network card when you change that card's settings. adding a second card leaves the first alone. when eth0 goes away, every macvlan child of it goes too: mac0, and the LAN interface of an AdGuard running on that node.

  • mac0 comes back with eth0, from eth0's stanza above
  • docker never rebuilds a running container's macvlan interface, so the AdGuard keeps running with no address on the LAN. its healthcheck asks for localhost on its own macvlan address, fails, and swarm replaces the task with one that has a fresh interface
  • each AdGuard has a placement constraint that keeps it off the other's node. it reads auto-label's labels, which follow a move only after auto-label next runs, so if both are rescheduled at once they can still land together. move them one at a time
  • a change to net0 is safest with the VM stopped

Network Preparation

  • This is one of the few times where showing picture will use less space than trying to explain something complex and non-intutive.
  • Note is asbolutely possible to do this via command line. If you prefer that this is the best article i won't be covering command line here as i didn't use it after i had learnt what i was doing :-).

Note this result in your tow adguard servers being 192.168.1.5 and 192.168.1.6 respectively. Adjust as needed for your network.

Define the macvlan configuration for adguard1 service

  • make sure you select the 3 docker nodes and get the IP details correct (modify if you don't use 192.168.1.0/24 as your LAN)
  • Note: the ip range of /32 is valid - this hard sets the IP on this service / container to that IP.

adgaurd1-mvl

Create the macvlan for adguard1 service

You will have 3 nodes to pick from (see picture) 2 will not work and throw error, 1 will work - it is trial and error to find the right one (the one that works is you managerr node, you only need to this once, not once per node) adgaurd1-creation

Define the macvlan configuration for adguard2 service

  • Do the same again, note the change in IP range.
  • make sure you select the 3 docker nodes and get the IP details correct (modify if you don't use 192.168.1.0/24 as your LAN)

adgaurd2-mvl

Create the macvlan for adguard2 service

Same as before, it is trial and error as to which one will work adgaurd2-creation

note on adguard sync

This is created automatically from the stack and allows all 3 nodes to talk to each other and have private name resolution (without any need to mess with DNS, hosts) it also keeps the traffic off the LAN

if you have made it to this point, here is the template

version: '3.2'
services:
  adguard1:
    image: 'adguard/adguardhome:latest'
    restart: always
    volumes:
      - work1:/opt/adguardhome/work
      - config1:/opt/adguardhome/conf
    networks:
      - adguard1
      - adguard_sync

  adguard2:
    image: 'adguard/adguardhome:latest'
    restart: always
    volumes:
      - work2:/opt/adguardhome/work
      - config2:/opt/adguardhome/conf
    networks:
      - adguard2
      - adguard_sync

  adguardhome-sync:
    image: ghcr.io/bakito/adguardhome-sync
    command: run
    networks:
      - adguard_sync
    depends_on:
      - adguard1
      - adguard2
    environment:
      # Origin Server is you first server.  For first time run connect to port 3000, set username and password to what you set bellow for source and origin, settings and move admin interface port 80 when propmted.
      - ORIGIN_URL=http://adguard1
      - ORIGIN_USERNAME=username
      - ORIGIN_PASSWORD=password
      # replica server - this will be setup automatically
      - REPLICA_AUTOSETUP=true # if true, AdGuardHome is automatically initialized. 
      - REPLICA_URL=http://adguard2:3000  #note the autosetup will not move this port to 3000, hoever as it is replica it doesn't really need to be moved
      - REPLICA_USERNAME=username
      - REPLICA_PASSWORD=password
      - CRON=*/1 * * * * # run every 1 minutes
      - RUNONSTART=true
      # Configure sync features; by default all features are enabled.
      # - FEATURES_GENERALSETTINGS=true
      # - FEATURES_QUERYLOGCONFIG=true
      # - FEATURES_STATSCONFIG=true
      # - FEATURES_CLIENTSETTINGS=true
      # - FEATURES_SERVICES=true
      # - FEATURES_FILTERS=true
      # - FEATURES_DHCP_SERVERCONFIG=true
      # - FEATURES_DHCP_STATICLEASES=true
      # - FEATURES_DNS_SERVERCONFIG=true
      # - FEATURES_DNS_ACCESSLISTS=true
      # - FEATURES_DNS_REWRITES=true
    restart: unless-stopped


volumes:
  work1:
    driver: gluster-vol1
  config1:
    driver: gluster-vol1
  work2:
    driver: gluster-vol1
  config2:
    driver: gluster-vol1  

networks:
   adguard1:
     external: true
   adguard2:
     external: true
   adguard_sync:  

single node swarm - much less frightening

If you don't want to mess with all of that this will work quite fine (it still requires you to make one macvlan, but if you want to skip that and go for host networking do that. I won't cover that here other than to say remeber you need to use the longform host publishing port syntax.

version: '3.2'
services:
  adguard1:
    image: 'adguard/adguardhome:latest'
    restart: always
    volumes:
      - work1:/opt/adguardhome/work
      - config1:/opt/adguardhome/conf
    networks:
      - adguard1

volumes:
  work1:
    driver: gluster-vol1
  config1:
    driver: gluster-vol1

networks:
   adguard1:
     external: true

what i run now

  • the volumes are named binds on cephfs, see stack conventions
  • each adguard has a placement constraint from auto-label that keeps it off the other's node
  • each adguard has a healthcheck on its own macvlan address, see when a docker VM's NIC changes
  • the two sync passwords are in one swarm secret. adguardhome-sync reads it as its --config file, see secrets
  • the cache's minimum TTL override is 0, so an answer lasts as long as its source says. it was 300, which held every short TTL on the internet for five minutes. optimistic caching stays on: it answers from an expired entry while it refreshes, so lookups stay fast
  • adguard has no separate setting for "no such name" answers. a name looked up just before it's created keeps failing until the zone's negative TTL runs out, plus one stale answer from the optimistic cache. settings → DNS settings → clear cache fixes it at once
compose.yml, 138 lines, 6 notes

each in the code opens a note on that line. download compose.yml

version: '3.8'
services:
  adguard1:
    image: 'adguard/adguardhome:latest@sha256:8a4107ec812023842ccab9e04600c5d39d3be6b15e907c34a36339c184c8fccf'
    environment:
      - TZ=America/Los_Angeles
    restart: always
    healthcheck:  # (1)!
      test: ["CMD-SHELL", "nslookup localhost 192.168.1.5 >/dev/null 2>&1 || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s
    volumes:
      - work:/opt/adguardhome/work
      - config:/opt/adguardhome/conf
    networks:
      - adguard1-mvl
      - adguard_sync
    deploy:
      mode: replicated
      replicas: 1
      placement:  # (2)!
        constraints: [node.labels.running_adguard2 == 0]
      labels:  # (3)!
        - homepage.group=Infrastructure
        - homepage.name=AdGuard 1
        - homepage.icon=adguard-home.png
        - homepage.href=https://adguard1.mydomain.com
        - homepage.description=DNS and filtering, primary
        - homepage.widget.type=adguard
        - homepage.widget.url=http://192.168.1.5
        - homepage.widget.username=<username>
        - homepage.widget.password={{HOMEPAGE_FILE_ADGUARD1_PASSWORD}}

  adguard2:
    image: 'adguard/adguardhome:latest@sha256:8a4107ec812023842ccab9e04600c5d39d3be6b15e907c34a36339c184c8fccf'
    environment:
      - TZ=America/Los_Angeles
    restart: always
    healthcheck:
      test: ["CMD-SHELL", "nslookup localhost 192.168.1.6 >/dev/null 2>&1 || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s
    volumes:
      - work2:/opt/adguardhome/work
      - config2:/opt/adguardhome/conf
    networks:
      - adguard2-mvl
      - adguard_sync
    deploy:
      mode: replicated
      replicas: 1
      placement:  # (4)!
        constraints: [node.labels.running_adguard1 == 0]
      labels:
        - homepage.group=Infrastructure
        - homepage.name=AdGuard 2
        - homepage.icon=adguard-home.png
        - homepage.href=https://adguard2.mydomain.com
        - homepage.description=DNS and filtering, secondary
        - homepage.widget.type=adguard
        - homepage.widget.url=http://192.168.1.6:3000
        - homepage.widget.username=<username>
        - homepage.widget.password={{HOMEPAGE_FILE_ADGUARD2_PASSWORD}}

  adguardhome-sync:  # (5)!
    image: ghcr.io/bakito/adguardhome-sync:latest@sha256:fb7224aeeab74d68990c3ecf51594a6f92428fcc93c7a162c3bc221e77a49025
    volumes:
      - type: bind
        source: /usr/share/zoneinfo
        target: /usr/share/zoneinfo
        read_only: true
    deploy:
      labels:
        - homepage.group=Infrastructure
        - homepage.name=AdGuard Sync
        - homepage.icon=adguard-home.png
        - homepage.description=Replicates config adguard1 to adguard2
    command: --config /run/secrets/adguard_sync_config run
    networks:
      - adguard_sync
    depends_on:
      - adguard1
      - adguard2
    environment:
      - TZ=America/Los_Angeles
      - ORIGIN_URL=http://adguard1
      - ORIGIN_USERNAME=<username>
      - REPLICA_AUTOSETUP=true
      - REPLICA_URL=http://adguard2:3000
      - REPLICA_USERNAME=<username>

      - CRON=*/1 * * * *
      - RUNONSTART=true
    restart: unless-stopped
    secrets:
      - adguard_sync_config

secrets:
  adguard_sync_config:
    external: true

volumes:  # (6)!
  work:
    driver: local
    driver_opts:
      type: none
      device: "/mnt/docker-cephFS/adguard_work"
      o: bind
  config:
    driver: local
    driver_opts:
      type: none
      device: "/mnt/docker-cephFS/adguard_config"
      o: bind
  work2:
    driver: local
    driver_opts:
      type: none
      device: "/mnt/docker-cephFS/adguard_work2"
      o: bind
  config2:
    driver: local
    driver_opts:
      type: none
      device: "/mnt/docker-cephFS/adguard_config2"
      o: bind

networks:
   adguard1-mvl:
     external: true
   adguard2-mvl:
     external: true

   adguard_sync:
  1. asks adguard1 for localhost at 192.168.1.5, its macvlan address, which adguard answers from its hosts file without going upstream. if that interface is gone, the check fails and swarm replaces the task, see when a NIC changes.
  2. the mirror of adguard2's rule: adguard1 only starts on a node where auto-label has set running_adguard2 to 0.
  3. the tile on the homepage dashboard. {{HOMEPAGE_FILE_ADGUARD1_PASSWORD}} is a placeholder homepage fills from a file, so the label holds no password, see widgets and their keys.
  4. adguard2 only starts on a node where auto-label has set running_adguard1 to 0, which keeps it off adguard1's node once the labels are current.
  5. the command line passes the swarm secret adguard_sync_config to adguardhome-sync as --config. it is a yaml file holding the origin and replica passwords, and the urls and usernames stay in environment.
  6. named binds on the cephfs mount, a work and a conf folder for each adguard. all four folders must exist before the first deploy, or the task refuses to start.

This page started as a gist: the original, with its comments