Nathan Peck
Nathan Peck
Technologist in Software, AI, and Infrastructure Orchestration
Aug 25, 2025 7 min read

Make .local (mDNS) work in scratch/BusyBox Docker containers via a host resolver, without changing your images

Context

I am running a small homelab powered by Raspberry Pi devices. By default, a Raspberry Pi advertises itself on your local network using mDNS. This allows you to reach it by using it’s hostname. For example, if you flash the Raspberry Pi with hostname raspberrypi1 then you can now do ping raspberrypi1.local or ssh raspberrypi1.local from other machines on your LAN.

However, these local mDNS addresses do not resolve inside of Docker conatiners, unless you are running a thick image that has a full init system and the avahi daemon running inside the container. Most modern applications are distributed as minimal images, built off scratch images, and therefore they do not have built-in mDNS suppport.

For example, I want to run the Portainer management interface, and use it to manage my containers running on other Raspberry Pi devices in my homelab. The Portainer image is a minimal Go application in a from scratch image. As a result when Portainer attempts to lookup the mDNS name (raspberrypi2.local) of another device in my LAN, the DNS lookup fails. While I could hardcode the IP addresses of all the other devices on my network, I would prefer for containerized software running on my homelab to be able to reach other Raspberry Pi’s on the LAN.

Working around this was an incredible challenge, with several fun side detours to deal with the fact that although I got it working in a stable fashion while the device was actively up and running, it was initially failing to work properly on reboot because the Docker bridge network gets added later on. I believe I’ve finally got a reproducible setup that survives reboots and even adapts to dynamic IP addresses if the router decides to change a device’s local IP address in the LAN.

Steps to Resolve

Let’s make it possible for .local hostnames (e.g., raspberrypi2.local) to resolve inside containers, even in ultra-minimal images (scratch, BusyBox).

You’ll run mDNS on the host (systemd-resolved + avahi) and expose it to containers through dnsmasq. No mDNS bits needed inside containers.

container → dnsmasq (host:53) → systemd-resolved (127.0.0.53) ↔ Avahi ↔ mDNS on LAN
  • systemd-resolved does peer mDNS on your LAN (wlan0/eth0).
  • dnsmasq listens on Docker bridges (and optionally LAN) and forwards all queries to 127.0.0.53.
  • Containers use plain unicast DNS and still get .local answers.

This will even work for scratch images that don’t have libc/NSS mdns and can’t generate multicast queries. By resolving .local on the host and exposing it via unicast DNS, even the most minimal containers get correct answers to mDNS hostname lookups, with zero changes inside the image.

Tested on Raspberry Pi OS/Debian 12 (Bookworm) with NetworkManager and Docker.


Prerequisites

  • Raspberry Pi OS Bookworm / Debian 12+ (systemd)
  • Docker installed
  • Peers on your LAN (the other Pis) run Avahi (default on Pi OS)

Interface names: replace wlan0 with eth0 if you’re wired.

All of the following commands are run on one of the Raspberry Pi hosts, in this case raspberrypi1.local. We will test to ensure that this host can look up external DNS (e.g. google.com), local mDNS name (raspberrypi1.local), and the mDNS name of other devices in the LAN (e.g. raspberrypi2.local).


1) Install & enable core services

sudo apt-get update
sudo apt-get install -y systemd-resolved avahi-daemon dnsmasq
sudo systemctl enable --now systemd-resolved avahi-daemon dnsmasq
# ensure host apps use the local DNS stub
sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf

Enable global mDNS in resolved:

sudo mkdir -p /etc/systemd/resolved.conf.d
printf "[Resolve]\nMulticastDNS=yes\n" | sudo tee /etc/systemd/resolved.conf.d/mdns.conf >/dev/null
sudo systemctl restart systemd-resolved

Find your active connection name:

# A) Simple table — note the CONNECTION for wlan0/eth0
nmcli device status

# B) Directly ask the device which connection is bound:
nmcli -g GENERAL.CONNECTION device show wlan0   # or eth0

Enable mDNS (and optionally disable LLMNR) on that connection:

nmcli connection modify "<CONNECTION_NAME>" connection.mdns yes
nmcli connection modify "<CONNECTION_NAME>" connection.llmnr no   # optional
nmcli connection down "<CONNECTION_NAME>" && nmcli connection up "<CONNECTION_NAME>"

Verify on the host:

resolvectl status | sed -n '1,120p'     # wlan0/eth0 should show “MulticastDNS setting: yes”
resolvectl query raspberrypi2.local     # expect its 192.168.x.x (or AAAA too)

Not using NetworkManager? See “Persistence (no NM)” at the end.


3) Configure dnsmasq to follow Docker interfaces (IP-agnostic)

Create a forwarding snippet:

sudo tee /etc/dnsmasq.d/mdns-forward.conf >/dev/null <<'EOF'
# Forward to systemd-resolved (which does mDNS via Avahi)
no-resolv
server=127.0.0.53

# Track interfaces dynamically (bridges can appear later)
bind-dynamic

# Small cache
cache-size=1000
EOF

Have dnsmasq bind to Docker bridges by interface name, generated at start:

sudo mkdir -p /etc/systemd/system/dnsmasq.service.d
sudo tee /etc/systemd/system/dnsmasq.service.d/override.conf >/dev/null <<'EOF'
[Unit]
After=network-online.target systemd-resolved.service
Wants=network-online.target

[Service]
# Build interface list at each start (docker0 + all br-*)
ExecStartPre=/bin/sh -c '\
  : > /etc/dnsmasq.d/20-interfaces.conf; \
  ip link show docker0 >/dev/null 2>&1 && echo "interface=docker0" >> /etc/dnsmasq.d/20-interfaces.conf; \
  for IF in $(ip -o link | awk -F": " "/^ *[0-9]+: br-/{print \$2}"); do \
    echo "interface=$IF" >> /etc/dnsmasq.d/20-interfaces.conf; \
  done; \
  true'
Restart=on-failure
RestartSec=2s
EOF

sudo systemctl daemon-reload
sudo systemctl restart dnsmasq

Optional: also listen on your LAN NIC by name (no IP hardcode). Append this inside the ExecStartPre block above:

DEV=$(ip route get 1.1.1.1 | awk '/dev/{print $5; exit}')
echo "interface=$DEV" >> /etc/dnsmasq.d/20-interfaces.conf

Quick host checks:

ss -lunp | grep ':53'                            # dnsmasq on 172.17.0.1:53 (and br-*, maybe LAN)
dig @127.0.0.53 raspberrypi2.local +short       # resolved path
# If you added LAN and have IP (e.g., 192.168.1.15):
dig @192.168.1.15 raspberrypi2.local +short

4) Point Docker at a reachable DNS

Option A (default bridge) — stable & IP-agnostic

sudo tee /etc/docker/daemon.json >/dev/null <<'JSON'
{
  "dns": ["172.17.0.1"],
  "dns-search": []
}
JSON
sudo systemctl restart docker

Option B (LAN DNS)

If you added your LAN NIC to dnsmasq, you can instead use your host LAN IP:

HOST_LAN_IP=$(hostname -I | awk '{print $1}')
sudo tee /etc/docker/daemon.json >/dev/null <<JSON
{
  "dns": ["${HOST_LAN_IP}"],
  "dns-search": []
}
JSON
sudo systemctl restart docker

5) Verify end-to-end

On the host:

resolvectl query google.com
resolvectl query raspberrypi1.local
resolvectl query raspberrypi2.local

From a minimal container:

docker run --rm busybox:uclibc sh -c '
  echo "resolv.conf:"; cat /etc/resolv.conf; echo;
  nslookup google.com || true;
  nslookup raspberrypi1.local || true;
  nslookup raspberrypi2.local || true;'

You should see working answers for all three lookups.


6) (Optional) Stop advertising Docker bridge IPs via Avahi

If raspberrypi1.local shows extra addresses like 172.17.0.1:

# /etc/avahi/avahi-daemon.conf
# choose your LAN interface(s)
allow-interfaces=wlan0
# or: allow-interfaces=eth0
# alt: deny-interfaces=docker0,br-*
sudo systemctl restart avahi-daemon

Troubleshooting (quick)

  • Containers timeout on DNS ss -lunp | grep ':53' → dnsmasq must be listening. If not, sudo systemctl status dnsmasq for errors. Ensure Docker "dns" points to an IP/interface dnsmasq actually listens on.

  • REFUSED for *.local resolvectl status → your LAN link must show mDNS enabled. resolvectl query raspberrypi2.local must work on the host first.

  • Only AAAA (IPv6) shows up That’s fine; mDNS is dual-stack. Apps that need IPv4 will request it. For a forced A-record test: getent ahostsv4 raspberrypi2.local.

  • Systemd ordering loops Don’t add After=docker.service to dnsmasq. Use the drop-in above (no hard deps on Docker).

  • Firewall Allow UDP/TCP 53 to the host (containers → dnsmasq) and UDP 5353 on LAN (mDNS).


Persistence (no NetworkManager)

Not using NM? Re-apply per-link mDNS at boot:

sudo tee /etc/systemd/system/resolved-mdns-overrides.service >/dev/null <<'UNIT'
[Unit]
Description=Set systemd-resolved mDNS per-link overrides at boot
After=network-online.target systemd-resolved.service
Wants=network-online.target
Requires=systemd-resolved.service

[Service]
Type=oneshot
ExecStart=/bin/sh -c '\
  DEV=$(ip route get 1.1.1.1 | awk "/dev/{print \$5; exit}"); \
  resolvectl mdns "$DEV" yes || true; \
  resolvectl mdns docker0 no || true; \
  for i in $(ip -o link | awk -F": " "/^ *[0-9]+: br-/{print \$2}"); do \
    resolvectl mdns "$i" no || true; \
  done'

[Install]
WantedBy=multi-user.target
UNIT

sudo systemctl daemon-reload
sudo systemctl enable --now resolved-mdns-overrides.service

One-shot diagnostics (optional)

If anything’s off, this snippet collects all the exact state needed to diagnose by copy and pasting into ChatGPT or other tool:

bash -c 'set -euo pipefail
echo "== Basics =="; uname -a; . /etc/os-release && echo $PRETTY_NAME
ip -br addr; ip -4 route
echo; echo "== Services =="; systemctl is-active systemd-resolved avahi-daemon dnsmasq docker || true
echo; echo "== resolved =="; resolvectl status || true; sudo resolvectl flush-caches; resolvectl query raspberrypi2.local || true
echo; echo "== dnsmasq =="; systemctl status --no-pager -n 20 dnsmasq || true
grep -R --line-number -E "no-resolv|server=|bind-dynamic|interface=" /etc/dnsmasq* || true
ss -lunp | grep ":53" || true
echo; echo "== Docker DNS =="; cat /etc/docker/daemon.json 2>/dev/null || true
docker run --rm busybox:uclibc nslookup raspberrypi2.local || true'