Docker & ContainersXXVI Β· DNS & Service DiscoveryCustom DNS
Custom DNS for service discovery
What you'll learn
- Decide whether a problem actually needs more than the embedded resolver
- Give a CoreDNS container a stable address that other containers can be pointed at
- Avoid the port-53 conflict with the host resolver stub
- Recognise and break a forwarding loop
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12
The embedded resolver handles service-to-service resolution within a Docker network, on one host, with no configuration. Past that boundary β several hosts, containers that must resolve VMs, split-horizon answers, an external registry β you need a real DNS server, and the usual choice is CoreDNS.
Adding it is not hard. Deploying it correctly runs into three problems that are specific to doing this inside Docker, and all three are more interesting than the Corefile.
First: check that you actually need it
Reach for a DNS server when you have one of these:
- Multi-host. A service on host A is not in host Bβs registry. The embedded resolver is per-daemon and has no cross-host view.
- Containers must resolve non-containers. VMs, bare-metal databases, appliances. Your internal DNS knows them; the embedded resolver forwards to it and this already works β which is why this reason is usually not one.
- Split-horizon. The same name must answer differently inside and outside.
- An external registry β Consul, etcd, a cloud service registry β is the source of truth for what exists.
Not reasons to add it:
- βContainer names should have a domain suffix.β Network aliases do that.
- βI want round-robin across replicas.β You already have that, and it is not health-aware in CoreDNS either unless you add health checking.
- βI want DNS-based failover.β DNS is a poor failover mechanism whoever serves it. Put a proxy in front.
Every one of those is a resolver you now have to keep running, and it sits on the critical path of every service in the stack.
Problem one: the resolver needs an address, not a name
You are configuring what resolves names. So you cannot refer to the resolver by name. Give it a fixed address on a user-defined network with an explicit subnet, and point everything at that.
services:
coredns:
image: coredns/coredns:1.11.3
command: ["-conf", "/etc/coredns/Corefile"]
volumes:
- ./Corefile:/etc/coredns/Corefile:ro
networks:
dnsnet:
ipv4_address: 172.28.0.53
restart: unless-stopped
app:
image: myapp:1.0.0
dns:
- 172.28.0.53
networks:
- dnsnet
depends_on:
- coredns
networks:
dnsnet:
ipam:
config:
- subnet: 172.28.0.0/24
Three details in that file are the ones people get wrong:
command:, notargs:. There is noargsattribute in the Compose specification. A service withargs:is not passing arguments to anything; Compose rejects the key, or in older tooling ignores it, and the container runs its default command.- The static address requires an explicit
subnet:.ipv4_addresson a network with no IPAM configuration is an error, because Docker has no guarantee the address is in the range it allocated. dns:sets the forwarding target of the embedded resolver. It does not replace127.0.0.11in the containerβsresolv.conf, and container names ondnsnetkeep resolving through the embedded resolver as before. This is usually what you want: the embedded registry stays authoritative for container names, and CoreDNS handles everything it does not know.
Problem two: port 53 is already taken
The instinct is to publish CoreDNS on the host so that host processes and other machines can use it. On a modern Debian or Ubuntu host that collides.
$ sudo ss -ulnp 'sport = :53'UNCONN 0 0 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=812,fd=13))Illustrative output
The stub binds one loopback address, so -p 53:53 on 0.0.0.0 often
succeeds β and then you have two resolvers on the host answering on
different addresses, with the host using one and the containers using the
other. Which is worse than a clean conflict, because nothing fails.
Problem three: the forwarding loop
Here is the Corefile that looks obviously right and is not:
. {
forward . /etc/resolv.conf
cache 30
log
errors
}
forward . /etc/resolv.conf tells CoreDNS to use the nameservers in its own
resolv.conf. Inside a container on a user-defined network, that file
contains nameserver 127.0.0.11. The embedded resolver forwards what it does
not know to the hostβs upstream β but if the daemon has been configured to
point at this CoreDNS instance, the query comes straight back.
CoreDNS asks the embedded resolver, which asks CoreDNS. Every external lookup in the stack now consumes resources at both ends until the client times out.
The loop plugin exists for exactly this: it detects the condition at
startup and refuses to start rather than serving a broken resolver.
# ./Corefile
. {
# Explicit upstreams. Not /etc/resolv.conf.
forward . 192.0.2.53 198.51.100.53 {
policy sequential
health_check 5s
}
cache 30
loop
log
errors
reload
}
# Split-horizon: answer internal names ourselves
internal.example.com {
file /etc/coredns/internal.example.com.zone
log
errors
}loop is cheap insurance and belongs in every Corefile. reload picks up
Corefile changes without a restart. health_check on the forward plugin is
what stops a dead upstream from absorbing a third of your queries β the
weakness the embedded resolver has and cannot fix.
Forwarding to a service registry
Consul serves DNS on port 8600 for the .consul zone. Forward only that
zone, not everything:
consul. {
forward . 192.0.2.10:8600
cache 5
errors
}
. {
forward . 192.0.2.53 198.51.100.53
cache 30
loop
errors
}
Scoping the stanza to consul. matters. A single . block forwarding
everything to Consul makes the service registry a hard dependency for
resolving github.com, and its outage becomes a total DNS outage.
Keep the cache TTL low for the registry zone β service registration changes in seconds, and a 30-second cache is 30 seconds of traffic to an instance that has deregistered.
Sanity check
dig @127.0.0.11 <peer-service>from inside a container answers from the registry, anddig @<coredns-addr> example.comanswers from CoreDNS. You checked both, separately.- CoreDNS is not publishing port 53 on
0.0.0.0. - The Corefile contains
loop, and CoreDNS started, which means it is not forwarding to itself. - You have stopped the CoreDNS container once, on purpose, and know exactly what broke.
Knowledge check
Knowledge check Β· 5 questions
Q1. A CoreDNS container has `forward . /etc/resolv.conf` in its Corefile and the Docker daemon is configured to use that CoreDNS as its DNS server. What happens?
Q2. Which Compose attribute passes command-line arguments to a container?
Q3. A container has `dns: [172.28.0.53]` pointing at CoreDNS, and is on a user-defined network with a peer container named `api`. Which resolver answers a lookup for `api`?
Q4. Which are genuine problems with publishing CoreDNS as `-p 53:53/udp` on a Docker host? Select all that apply.
Q5. While the CoreDNS container is down, containers pointed at it can still resolve each other by service name.
Passing score: 75%. Answers are checked in this browser.