CoreDNS as Authoritative DNS in Kubernetes: Corefile and Zones

Published: 2026-06-16

CoreDNS is the only DNS server in a Kubernetes cluster by default. It is authoritative for the cluster.local zone — it answers definitively without touching upstream. Everything outside that zone goes to forwarding. This post covers how CoreDNS becomes authoritative, how to add custom zones, and what to look at when DNS stops responding.


How CoreDNS lands in the cluster

When a cluster is installed (k0s, k3s, kubeadm), CoreDNS is deployed as a Deployment in kube-system. Two pods, one ConfigMap holding the Corefile, one Service with a ClusterIP — that IP is written into /etc/resolv.conf of every pod.

bashkubectl get pods -A | grep coredns
# kube-system   coredns-7f9fb5c85d-4btrq   1/1   Running   0   6d
# kube-system   coredns-7f9fb5c85d-9kxrp   1/1   Running   0   6d

kubectl get svc -n kube-system kube-dns
# NAME       TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)         AGE
# kube-dns   ClusterIP   10.96.0.10   <none>        53/UDP,53/TCP   6d

Inside any pod:

bashcat /etc/resolv.conf
# nameserver 10.96.0.10
# search default.svc.cluster.local svc.cluster.local cluster.local
# options ndots:5

ndots:5 means: if a name has fewer than five dots, search suffixes from the search list are appended in order. That's why redis resolves to redis.default.svc.cluster.local — CoreDNS answers authoritatively.


Corefile: plugin structure

All CoreDNS configuration is a single Corefile, stored in a ConfigMap:

bashkubectl get configmap coredns -n kube-system -o yaml

Standard Corefile from k0s/kubeadm:

corefile.:53 {
    errors
    health {
        lameduck 5s
    }
    ready
    kubernetes cluster.local in-addr.arpa ip6.arpa {
        pods insecure
        fallthrough in-addr.arpa ip6.arpa
        ttl 30
    }
    prometheus :9153
    forward . /etc/resolv.conf {
        max_concurrent 1000
    }
    cache 30
    loop
    reload
    loadbalance
}

Each zone { plugins } block is a server. Plugin execution order within a block is fixed at CoreDNS compile time — not by the order of lines in the file. Lines in the Corefile only configure plugins, not their invocation order.


The kubernetes plugin — the authoritative core

The kubernetes plugin owns the cluster.local zone. It reads objects from the Kubernetes API via an informer cache and returns answers without hitting upstream.

corefilekubernetes cluster.local in-addr.arpa ip6.arpa {
    pods insecure
    fallthrough in-addr.arpa ip6.arpa
    ttl 30
}
Parameter Effect
pods insecure Creates A records for pods as 1-2-3-4.namespace.pod.cluster.local without verification
pods verified Same, but verifies the IP actually belongs to the pod — guards against DNS rebinding
fallthrough in-addr.arpa ip6.arpa PTR queries the plugin can't handle pass to the next plugin
ttl 30 Response TTL in seconds

What this plugin resolves authoritatively:

# Service
redis.default.svc.cluster.local         → ClusterIP
redis.default.svc.cluster.local (SRV)   → port + ClusterIP

# Headless Service
redis.default.svc.cluster.local         → IPs of all endpoints

# Pod (pods insecure)
10-244-1-5.default.pod.cluster.local    → 10.244.1.5

# ExternalName Service
ext.default.svc.cluster.local           → CNAME → external.host

Adding a custom authoritative zone

Option 1: file plugin (static zone file)

Good for small zones that change infrequently. The zone file goes into a ConfigMap and is mounted into the CoreDNS pod.

ConfigMap with zone file:

yamlapiVersion: v1
kind: ConfigMap
metadata:
  name: coredns-custom-zones
  namespace: kube-system
data:
  internal.example.com.db: |
    $ORIGIN internal.example.com.
    $TTL 300
    @   IN SOA ns1.internal.example.com. admin.internal.example.com. (
                2026061601 ; serial
                3600       ; refresh
                900        ; retry
                604800     ; expire
                300 )      ; minimum TTL
    @   IN NS  ns1.internal.example.com.
    ns1 IN A   10.96.0.10
    api IN A   10.244.1.100
    db  IN A   10.244.2.50
    *   IN A   10.244.3.1

Patch CoreDNS ConfigMap — add the zone block:

yamlapiVersion: v1
kind: ConfigMap
metadata:
  name: coredns
  namespace: kube-system
data:
  Corefile: |
    internal.example.com:53 {
        file /etc/coredns/zones/internal.example.com.db
        log
        errors
    }
    .:53 {
        errors
        health {
            lameduck 5s
        }
        ready
        kubernetes cluster.local in-addr.arpa ip6.arpa {
            pods insecure
            fallthrough in-addr.arpa ip6.arpa
            ttl 30
        }
        prometheus :9153
        forward . /etc/resolv.conf {
            max_concurrent 1000
        }
        cache 30
        loop
        reload
        loadbalance
    }

Mount the zone file into the CoreDNS Deployment:

bashkubectl edit deployment coredns -n kube-system

Add to spec.template.spec:

yamlvolumes:
  - name: custom-zones
    configMap:
      name: coredns-custom-zones
containers:
  - name: coredns
    volumeMounts:
      - name: custom-zones
        mountPath: /etc/coredns/zones
        readOnly: true

The reload plugin watches the Corefile and reloads it automatically on ConfigMap changes — no restart needed. It does not watch zone files: after updating a zone file ConfigMap, run kubectl rollout restart deployment/coredns -n kube-system.

Option 2: stub zone — forward a specific zone to an external server

If a zone is served by an external DNS (corporate AD, internal bind9), configure a stub:

corefile.:53 {
    errors
    kubernetes cluster.local in-addr.arpa ip6.arpa {
        pods insecure
        fallthrough in-addr.arpa ip6.arpa
        ttl 30
    }
    forward corp.example.com 192.168.1.53 192.168.1.54 {
        policy round_robin
        health_check 5s
    }
    forward . /etc/resolv.conf {
        max_concurrent 1000
    }
    cache 30
    reload
}

Queries for *.corp.example.com go to 192.168.1.53/192.168.1.54. Everything else goes to the system resolver. The order of forward blocks matters: CoreDNS matches the first fitting one.


Verification

bash# Launch a debug pod
kubectl run dnstest --image=busybox:1.36 --rm -it --restart=Never -- sh

# Inside the pod:
nslookup redis.default.svc.cluster.local
nslookup api.internal.example.com
nslookup google.com

# Check what Corefile CoreDNS is actually running
kubectl exec -n kube-system $(kubectl get pod -n kube-system -l k8s-app=kube-dns -o name | head -1) \
  -- cat /etc/coredns/Corefile

# DNS trace from a pod
nslookup -type=A -debug redis.default.svc.cluster.local 10.96.0.10

CoreDNS Prometheus metrics (if scraping is set up):

promql# Requests per second by zone
rate(coredns_dns_requests_total[5m])

# SERVFAIL errors
rate(coredns_dns_responses_total{rcode="SERVFAIL"}[5m])

# p99 latency
histogram_quantile(0.99, rate(coredns_dns_request_duration_seconds_bucket[5m]))

What can go wrong

CoreDNS returns NXDOMAIN for a valid service

bash# Check the service exists
kubectl get svc redis -n default

# Check endpoints
kubectl get endpoints redis -n default

# If endpoints are empty — the problem is not DNS, it's selector/pod labels
kubectl describe endpoints redis -n default

CoreDNS only returns an A record if the Service has at least one Ready endpoint (headless) or a ClusterIP (regular). A Service without pods but with a ClusterIP resolves fine — traffic just has nowhere to go.

Loop detected — CoreDNS in CrashLoopBackOff

bashkubectl logs -n kube-system -l k8s-app=kube-dns | grep -i loop
# [ERROR] plugin/loop: Loop (127.0.0.1:53 -> :53) detected

The loop plugin found that /etc/resolv.conf on the node points to 127.0.0.1 (systemd-resolved or dnsmasq). CoreDNS forwards queries back to itself.

Fix — specify upstream explicitly instead of /etc/resolv.conf:

corefileforward . 8.8.8.8 8.8.4.4 {
    max_concurrent 1000
}

Or configure /etc/resolv.conf on the node to not point at localhost.

Slow DNS — every query takes 5 seconds

Symptom: ndots:5 causes the full search suffix list to be tried for external names. A query for google.com becomes 6 queries:

google.com.default.svc.cluster.local  → NXDOMAIN
google.com.svc.cluster.local          → NXDOMAIN
google.com.cluster.local              → NXDOMAIN
google.com.                           → answer

Fix — use a trailing dot (FQDN) for external names in the application, or lower ndots in the Pod spec:

yamlspec:
  dnsConfig:
    options:
      - name: ndots
        value: "2"

Zone file did not reload after ConfigMap update

The reload plugin only watches the Corefile, not zone files. After updating the ConfigMap containing the zone file:

bashkubectl rollout restart deployment/coredns -n kube-system
kubectl rollout status deployment/coredns -n kube-system

Summary

  • CoreDNS is authoritative for cluster.local via the kubernetes plugin — answers from the API informer cache, no upstream involved
  • Custom zones are added via the file plugin (static zone file in a ConfigMap) or forward (stub to an external DNS)
  • The reload plugin auto-reloads the Corefile; zone files only reload after a pod restart
  • For debugging: kubectl run dnstest, nslookup -debug, metrics on :9153
  • ndots:5 is the cause of slow DNS for external names — fix with dnsConfig.options in the Pod spec