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 to127.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
witheth0
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
2) Make per-link mDNS persistent (NetworkManager)
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'