DNS Server with Docker Compose: CoreDNS, Pi-hole, and Service Discovery Guide

how to set up dns server with docker compose

Table of Contents

  1. How Docker DNS Actually Works
  2. When You Need More Than Docker’s Built-in DNS
  3. DNS Tool Comparison: CoreDNS vs Pi-hole vs Bind9 vs Technitium
  4. Option 1: CoreDNS for Internal Service Discovery
  5. Option 2: Pi-hole for Network-Wide DNS and Ad Blocking
  6. Option 3: Pi-hole + Unbound for Full DNS Privacy
  7. Configuring Docker Compose DNS Settings
  8. System-Wide Docker DNS via daemon.json
  9. Testing and Troubleshooting DNS Inside Containers
  10. Security Hardening for Production DNS
  11. Common Issues and Quick Fixes
  12. Next Steps

DNS is the part of your infrastructure you think about least — until it breaks. Inside Docker, name resolution involves several layers that aren’t always obvious: Docker’s own embedded DNS resolver, the host’s system resolver, and any custom DNS servers you’ve configured. This guide covers all three, then goes further to show how to run CoreDNS and Pi-hole as proper production DNS services inside Docker Compose, with persistent configuration, health checks, and network-wide coverage.

If you’re looking for a basic local DNS setup using Bind9 in Docker, our earlier guide on setting up a local DNS server using Docker covers that approach. This guide is the production-grade companion — covering more modern tools, full Docker Compose setups, and patterns that scale beyond a single development machine.

How Docker DNS Actually Works

Before running any custom DNS server, it’s worth understanding what Docker already provides — because in many cases, it’s enough.

On custom bridge networks, Docker runs an embedded DNS server at 127.0.0.11. This server resolves container names and service names to container IPs, and forwards external DNS queries to the host’s configured DNS resolvers.

This embedded DNS server is what makes service-to-service communication work by name in Docker Compose without any configuration:

services:
  api:
    image: myapp:latest

  database:
    image: postgres:15

In this stack, the api container can reach database by just using the hostname database — Docker’s embedded DNS at 127.0.0.11 resolves it to the database container’s IP automatically. No /etc/hosts entries, no manual IP configuration.

Key facts about Docker’s embedded DNS:

Container resolution flow:
  container → 127.0.0.11 (Docker embedded DNS)
      ├── Is it a service/container name? → Return container IP
      └── Is it external? → Forward to host resolver → Internet
  • Only works on custom bridge networks — not the default bridge network
  • Available in all Docker Compose stacks automatically (Compose always creates a custom network)
  • Supports DNS aliases — a container can be reachable by multiple names via aliases in the network config
  • Does not provide wildcard resolution or custom zone management

For user-defined bridge networks, Docker runs an embedded DNS server at 127.0.0.11 that handles service discovery between containers. For the default bridge network, Docker copies /etc/resolv.conf from the host into the container.

When You Need More Than Docker’s Built-in DNS

Docker’s embedded DNS is sufficient for most Compose stacks. You need a custom DNS server when:

  • Internal domain resolution — you want api.mycompany.internal to resolve to specific IPs across multiple hosts or stacks
  • Network-wide ad blocking — you want all containers (and optionally all devices on the network) to have tracking/ad domains blocked at the DNS level
  • Split-horizon DNS — different DNS answers for internal vs external clients for the same hostname
  • Cross-stack service discovery — services in separate Compose stacks need to find each other by name (Docker’s embedded DNS only sees services within the same Compose project)
  • DNS privacy — you want recursive resolution that doesn’t log queries to upstream providers like Google (8.8.8.8) or Cloudflare (1.1.1.1)
  • Air-gapped environments — no external DNS access; all resolution must be handled internally

DNS Tool Comparison: CoreDNS vs Pi-hole vs Bind9 vs Technitium

CoreDNSPi-holeBind9Technitium
Primary useService discovery, Kubernetes DNSAd blocking + local DNSFull-featured authoritative DNSAll-in-one homelab DNS
Config styleCorefile (plugin-based)Web UI + config filesZone files (complex)Web UI
Docker imageOfficial (coredns/coredns)Official (pihole/pihole)Community (internetsystemsconsortium/bind9)Official (technitium/dns-server)
Ad blocking❌ No✅ Yes (built-in)❌ No✅ Yes
Web UI❌ No (metrics only)✅ Yes❌ No✅ Yes
Kubernetes default✅ Yes❌ No❌ No❌ No
Native clusteringVia KubernetesVia Nebula SyncManual✅ Native (v2025+)
Learning curveLow-moderateLowHighLow
Best forService discovery, custom zonesAd blocking, homelab, privacyEnterprise, advanced DNS featuresFull-featured homelab/SMB DNS

Recommendation:

  • Service discovery between Docker containers → CoreDNS
  • Homelab with ad blocking + privacy → Pi-hole (+ Unbound for recursive)
  • Enterprise environment needing authoritative DNS → Bind9 (see our local DNS with Bind9 guide)
  • Full-featured self-hosted DNS with native clustering → Technitium

Option 1: CoreDNS for Internal Service Discovery

CoreDNS is the DNS server running inside every Kubernetes cluster. Outside of Kubernetes, it’s an excellent lightweight choice for adding custom internal zones to a Docker environment — minimal footprint, plugin-based configuration, and a simple text config file.

Project structure:

coredns/
├── compose.yml
├── Corefile
└── zones/
    └── internal.zone

Corefile — CoreDNS configuration:

# Forward queries for internal.local to our zone file
internal.local:53 {
    file /root/zones/internal.zone internal.local
    log
    errors
}

# Forward everything else to public DNS with caching
.:53 {
    forward . 8.8.8.8 8.8.4.4 {
        max_concurrent 1000
        health_check 5s
    }
    cache 300
    log
    errors
    health :8080
}

zones/internal.zone — DNS records:

$ORIGIN internal.local.
$TTL 3600

@   IN  SOA ns.internal.local. admin.internal.local. (
            2026070101  ; Serial (YYYYMMDDNN)
            3600        ; Refresh
            600         ; Retry
            86400       ; Expire
            60          ; Negative Cache TTL
)

    IN  NS  ns.internal.local.

; Name server record
ns      IN  A   172.25.0.53

; Internal services — add your services here
api     IN  A   172.25.0.10
db      IN  A   172.25.0.20
cache   IN  A   172.25.0.30
monitor IN  A   172.25.0.40

compose.yml:

version: '3.8'

networks:
  dns-net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.25.0.0/24

volumes:
  coredns-data:

services:
  coredns:
    image: coredns/coredns:latest
    container_name: coredns
    restart: unless-stopped
    command: -conf /etc/coredns/Corefile
    volumes:
      - ./Corefile:/etc/coredns/Corefile:ro
      - ./zones:/root/zones:ro
    ports:
      - "127.0.0.1:53:53/udp"    # Bind only to localhost — don't expose publicly
      - "127.0.0.1:53:53/tcp"
    networks:
      dns-net:
        ipv4_address: 172.25.0.53
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
      interval: 10s
      timeout: 3s
      retries: 5

  # Example app using CoreDNS for resolution
  app:
    image: nginx:alpine
    container_name: app
    dns:
      - 172.25.0.53          # Use CoreDNS first
      - 8.8.8.8              # Fallback to Google DNS
    dns_search:
      - internal.local       # Short names resolve under .internal.local
    depends_on:
      coredns:
        condition: service_healthy
    networks:
      - dns-net

Start and verify:

docker compose up -d

# Test internal zone resolution
docker compose exec app nslookup api.internal.local
# Expected: Address: 172.25.0.10

# Test external resolution (forwarded to 8.8.8.8)
docker compose exec app nslookup google.com
# Expected: normal resolution

# Test short name (uses dns_search domain)
docker compose exec app nslookup api
# Expected: resolves to api.internal.local → 172.25.0.10

Adding CoreDNS to an existing Compose stack (without a fixed subnet):

services:
  coredns:
    image: coredns/coredns:latest
    command: -conf /etc/coredns/Corefile
    volumes:
      - ./Corefile:/etc/coredns/Corefile:ro
      - ./zones:/root/zones:ro
    ports:
      - "127.0.0.1:5353:53/udp"  # Use non-standard port to avoid conflict with host DNS

  myapp:
    image: myapp:latest
    dns:
      - 172.x.x.x   # CoreDNS container IP (check with `docker inspect coredns`)

Option 2: Pi-hole for Network-Wide DNS and Ad Blocking

Pi-hole is the most popular self-hosted DNS solution for homelabs and small networks — it blocks ads and tracking domains at the DNS level for every device on the network, with a polished web interface for management.

Project structure:

pihole/
├── compose.yml
├── .env
└── data/
    ├── pihole/
    └── dnsmasq.d/

.env:

PIHOLE_PASSWORD=change_this_strong_password
TZ=Asia/Jakarta
PIHOLE_DOMAIN=home.local

compose.yml:

version: '3.8'

services:
  pihole:
    image: pihole/pihole:latest
    container_name: pihole
    restart: unless-stopped
    ports:
      - "53:53/tcp"              # DNS TCP
      - "53:53/udp"              # DNS UDP
      - "127.0.0.1:8080:80"     # Web UI (localhost only — put behind Nginx Proxy Manager)
    environment:
      TZ: ${TZ}
      FTLCONF_webserver_api_password: ${PIHOLE_PASSWORD}
      FTLCONF_dns_upstreams: "8.8.8.8;8.8.4.4"
      FTLCONF_dns_listeningMode: "all"
      FTLCONF_LOCAL_IPV4: "0.0.0.0"
    volumes:
      - ./data/pihole:/etc/pihole
      - ./data/dnsmasq.d:/etc/dnsmasq.d
    cap_add:
      - NET_ADMIN                # Required for full DNS operation
    healthcheck:
      test: ["CMD", "dig", "+short", "+norecurse", "+retry=0", "@127.0.0.1", "pi.hole"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 60s

Start and access:

docker compose up -d
docker compose logs -f pihole

Access the admin interface at http://localhost:8080/admin and log in with the password from .env.

Add local DNS records (your services):

In the Pi-hole admin → Local DNS → DNS Records, add entries like:

grafana.home.local  →  192.168.1.100
superset.home.local →  192.168.1.100
portainer.home.local →  192.168.1.100

This is the fastest way to give real hostnames to services running behind Nginx Proxy Manager — Pi-hole resolves the name, NPM routes the traffic, and Let’s Encrypt provides the certificate.

Point your router to Pi-hole:

After Pi-hole is running, configure your router’s DHCP to hand out the Pi-hole server’s IP as the DNS server for all devices. Every device on the network then gets ad blocking automatically — without installing anything on individual devices.

Option 3: Pi-hole + Unbound for Full DNS Privacy

By default, Pi-hole forwards your DNS queries to upstream resolvers (Google 8.8.8.8, Cloudflare 1.1.1.1) — those providers still see what you’re resolving. Unbound replaces this upstream with a recursive resolver that queries the root servers directly — no third party sees your DNS traffic.

version: '3.8'

services:
  unbound:
    image: mvance/unbound:latest
    container_name: unbound
    restart: unless-stopped
    volumes:
      - ./unbound/unbound.conf:/opt/unbound/etc/unbound/unbound.conf:ro
    networks:
      dns-net:
        ipv4_address: 172.30.0.2

  pihole:
    image: pihole/pihole:latest
    container_name: pihole
    restart: unless-stopped
    environment:
      TZ: ${TZ}
      FTLCONF_webserver_api_password: ${PIHOLE_PASSWORD}
      # Point to Unbound instead of public DNS
      FTLCONF_dns_upstreams: "172.30.0.2#5335"
      FTLCONF_dns_listeningMode: "all"
    ports:
      - "53:53/tcp"
      - "53:53/udp"
      - "127.0.0.1:8080:80"
    volumes:
      - ./data/pihole:/etc/pihole
    cap_add:
      - NET_ADMIN
    depends_on:
      - unbound
    networks:
      - dns-net

networks:
  dns-net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.30.0.0/24

unbound/unbound.conf:

server:
    verbosity: 1
    interface: 0.0.0.0@5335
    port: 5335
    do-ip4: yes
    do-udp: yes
    do-tcp: yes
    do-ip6: no

    # Performance
    num-threads: 1
    cache-max-ttl: 86400
    cache-min-ttl: 300

    # Privacy: minimize query data sent to root servers
    qname-minimisation: yes

    # Security: DNSSEC validation
    auto-trust-anchor-file: "/var/lib/unbound/root.key"

    # Refuse requests from outside (only accept from Pi-hole container)
    access-control: 172.30.0.0/24 allow
    access-control: 0.0.0.0/0 refuse

With this setup: devices → Pi-hole (filtering) → Unbound (recursive) → Root servers. No upstream DNS provider sees your queries.

Configuring Docker Compose DNS Settings

Beyond running a DNS server as a container, Docker Compose has built-in options for controlling DNS behavior per-service:

services:
  app:
    image: myapp:latest

    # Primary and fallback DNS servers for this container
    dns:
      - 172.25.0.53    # Custom internal DNS (CoreDNS/Pi-hole)
      - 8.8.8.8        # Public fallback

    # Search domains — short names get these appended automatically
    dns_search:
      - internal.local
      - mycompany.com

    # DNS resolver options (same as /etc/resolv.conf options)
    dns_opt:
      - timeout:2      # 2 second timeout per query
      - attempts:3     # 3 retry attempts
      - ndots:2        # Use search domains for names with fewer than 2 dots

The dns_search option sets the search domain list for hostname lookups. When a container tries to resolve a short hostname, the DNS resolver appends each search domain and tries the lookup again. For example, if your search domain is mycompany.internal and your application tries to connect to api, the resolver will also try api.mycompany.internal.

Practical dns_search example with Nginx Proxy Manager:

services:
  app:
    image: myapp:latest
    dns:
      - 192.168.1.100  # Pi-hole running on the same host
    dns_search:
      - home.local     # Pi-hole's local domain
    environment:
      # App can now connect to 'grafana' instead of 'grafana.home.local'
      GRAFANA_URL: http://grafana:3000
      REDIS_URL: redis://cache:6379

System-Wide Docker DNS via daemon.json

If every container in your Docker environment should use the same DNS, configure it once at the daemon level:

sudo nano /etc/docker/daemon.json
{
  "dns": ["192.168.1.100", "8.8.8.8"],
  "dns-search": ["home.local", "internal.local"],
  "dns-opts": ["timeout:2", "attempts:3"]
}
sudo systemctl restart docker

All containers now use 192.168.1.100 (your Pi-hole or CoreDNS) as their primary DNS, without needing to set dns: in every Compose file. Override per-service where needed.

Verify it’s working:

docker run --rm busybox cat /etc/resolv.conf
# Should show your custom DNS servers

Testing and Troubleshooting DNS Inside Containers

# Check what DNS servers a container is using
docker exec <container> cat /etc/resolv.conf

# Test DNS resolution from inside a container
docker exec <container> nslookup google.com
docker exec <container> dig api.internal.local @172.25.0.53

# Test Pi-hole is responding
dig @127.0.0.1 pi.hole
# Expected: 0.0.0.0 (Pi-hole's self-test domain)

# Test CoreDNS health endpoint
curl http://localhost:8080/health
# Expected: OK

# Check CoreDNS query logs
docker compose logs coredns -f

# Check if port 53 is already in use (common conflict)
sudo ss -tlnup | grep :53
# If systemd-resolved is using port 53:
sudo systemctl disable systemd-resolved
sudo systemctl stop systemd-resolved

Port 53 conflict on Ubuntu: Ubuntu 26.04 runs systemd-resolved which listens on port 53 by default. To run your own DNS server on port 53:

# Disable systemd-resolved's stub listener
sudo nano /etc/systemd/resolved.conf
[Resolve]
DNSStubListener=no
sudo systemctl restart systemd-resolved
# Port 53 is now free for your DNS container

Security Hardening for Production DNS

A DNS server running in Docker needs the same hardening discipline as any other infrastructure service — see our Docker Container Security Best Practices guide for the full framework:

1. Bind DNS ports to specific interfaces:

ports:
  - "127.0.0.1:53:53/udp"    # Only localhost — for host-only use
  # OR
  - "192.168.1.100:53:53/udp" # Only internal network interface
  # NOT:
  # - "53:53/udp"             # This binds to ALL interfaces including internet-facing ones

2. Restrict recursion in CoreDNS:

.:53 {
    forward . 8.8.8.8 {
        # Only allow recursive queries from internal networks
        acl {
            allow net 172.0.0.0/8
            allow net 192.168.0.0/16
            allow net 10.0.0.0/8
            block
        }
    }
}

3. Rate limiting (prevent DNS amplification attacks):

.:53 {
    forward . 8.8.8.8
    # Limit to 100 queries per second per IP
    ratelimit 100
}

4. Firewall rules:

# Allow DNS only from trusted subnets
sudo ufw allow from 192.168.0.0/16 to any port 53
sudo ufw allow from 10.0.0.0/8 to any port 53
sudo ufw deny 53
sudo ufw reload

5. Monitor DNS queries:
Integrate CoreDNS or Pi-hole metrics with your Prometheus + Grafana monitoring stack — both expose Prometheus-compatible metrics endpoints for query rates, cache hits, and blocked domains.

Common Issues and Quick Fixes

SymptomLikely CauseFix
Port 53 already in usesystemd-resolved occupying port 53Disable DNSStubListener in /etc/systemd/resolved.conf
Containers can’t resolve external namesDNS server running but no upstream forwarder configuredAdd forward . 8.8.8.8 to CoreDNS Corefile or set upstream in Pi-hole
Pi-hole web UI not loadingPort 80 conflict with another serviceChange Pi-hole’s web port mapping to 127.0.0.1:8080:80
Short hostname not resolvingdns_search not set in ComposeAdd dns_search: - internal.local to the service config
DNS works inside stack but not across stacksDocker embedded DNS scoped to one Compose projectUse CoreDNS or Pi-hole as shared DNS server; set dns: in both stacks
Pi-hole blocking legitimate domainsBlocklist too aggressiveAdd domain to Pi-hole allowlist in the admin UI
CoreDNS returns SERVFAIL for internal zoneZone file syntax errorValidate zone file with named-checkzone internal.local zones/internal.zone

Next Steps

With a proper DNS server running in Docker, the rest of your infrastructure benefits immediately:

  • Nginx Proxy Manager — combine Pi-hole’s local DNS records with Nginx Proxy Manager for a complete solution: Pi-hole resolves app.home.local to your server IP, NPM routes the request to the right container and terminates SSL
  • Monitoring — add CoreDNS or Pi-hole to your Prometheus + Grafana stack to track query rates, blocked domains, and cache hit rates
  • Security — review the Docker Container Security Best Practices guide to ensure your DNS container follows the same hardening discipline as the rest of your stack
  • Local dev DNS — for a simpler single-developer setup using Bind9 without the production overhead, see our original local DNS server guide
(Visited 1 times, 1 visits today)

You may also like