Skip to main content
RunBook Academy

Docker & ContainersXXVI Β· DNS & Service DiscoveryCustom DNS

Custom DNS for service discovery

Advanced⏱ ~26 mindocker

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

Not yet marked complete on this device.

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:, not args:. There is no args attribute in the Compose specification. A service with args: 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_address on 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 replace 127.0.0.11 in the container’s resolv.conf, and container names on dnsnet keep 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.

Read-only / Safewhat already owns 53
$ 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.

Configuration changea Corefile that does not loop
# ./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, and dig @<coredns-addr> example.com answers 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

  1. 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?

  2. Q2. Which Compose attribute passes command-line arguments to a container?

  3. 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`?

  4. Q4. Which are genuine problems with publishing CoreDNS as `-p 53:53/udp` on a Docker host? Select all that apply.

  5. 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.