CoreDNS is a DNS server written in Go that builds every feature (forwarding, caching, zone files, metrics) from plugins configured in a single file called the Corefile. It is the default cluster DNS in Kubernetes, and it works just as well as a standalone resolver for a private network. In this tutorial you will install CoreDNS on Ubuntu 24.04, serve a private zone called lab.internal, forward and cache every other query, run it as a systemd service and limit access to your own network.
Prerequisites
To follow this tutorial, you will need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a private network interface.
- A non-root user with
sudoprivileges. - UFW enabled with SSH allowed.
The examples use the private network 10.0.0.0/24, with the DNS server at 10.0.0.5. Replace these addresses with your own wherever they appear.
Step 1 - Installing the CoreDNS binary
CoreDNS is distributed as a single static binary on GitHub. Check the releases page for the latest version and set it in a variable:
COREDNS_VERSION=1.12.2
Download the archive and its checksum into /tmp:
cd /tmp
curl -fLO "https://github.com/coredns/coredns/releases/download/v${COREDNS_VERSION}/coredns_${COREDNS_VERSION}_linux_amd64.tgz"
curl -fLO "https://github.com/coredns/coredns/releases/download/v${COREDNS_VERSION}/coredns_${COREDNS_VERSION}_linux_amd64.tgz.sha256"
Compare the computed hash with the published one. Both lines must show the same value:
sha256sum "coredns_${COREDNS_VERSION}_linux_amd64.tgz"
cat "coredns_${COREDNS_VERSION}_linux_amd64.tgz.sha256"
On an ARM server, replace amd64 with arm64 in the file names.
Extract the binary and install it into /usr/local/bin:
tar -xzf "coredns_${COREDNS_VERSION}_linux_amd64.tgz"
sudo install -m 0755 coredns /usr/local/bin/coredns
Confirm that it runs:
coredns -version
CoreDNS-1.12.2
linux/amd64, go1.24.1, ...
Install dig as well, which you will use to test every step:
sudo apt update
sudo apt install bind9-dnsutils
Step 2 - Creating a service user and directories
CoreDNS does not need root privileges. Create a system user without a login shell, plus a directory for the configuration and zone files:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin coredns
sudo mkdir -p /etc/coredns/zones
Step 3 - Writing a zone file for the private domain
CoreDNS reads standard BIND-format zone files through the file plugin. Create one for lab.internal. The .internal top-level domain is reserved for private use, so it will never clash with a public name:
sudo nano /etc/coredns/zones/db.lab.internal
$ORIGIN lab.internal.
$TTL 3600
@ IN SOA ns1.lab.internal. hostmaster.lab.internal. (
2026092501 ; serial
7200 ; refresh
3600 ; retry
1209600 ; expire
3600 ) ; negative caching TTL
@ IN NS ns1.lab.internal.
ns1 IN A 10.0.0.5
db IN A 10.0.0.20
app IN A 10.0.0.21
www IN CNAME app.lab.internal.
The serial uses the YYYYMMDDNN convention. The file plugin checks the zone file every minute and reloads it when the serial increases, so always bump the serial after editing records.
Step 4 - Writing the Corefile
The Corefile contains one server block per zone. Here, the lab.internal block answers from the zone file, and the . (root) block forwards everything else to public resolvers and caches the answers.
Two details matter for a server exposed on a network:
- The
bindplugin makes CoreDNS listen only on the loopback and private addresses. This also avoids a conflict with thesystemd-resolvedstub listener on127.0.0.53:53, which Ubuntu uses for the server's own lookups. - The
aclplugin refuses queries from outside your network. A forwarding resolver open to the Internet will be abused for DNS amplification attacks.
Create the file:
sudo nano /etc/coredns/Corefile
lab.internal {
bind 127.0.0.1 10.0.0.5
acl {
allow net 127.0.0.0/8 10.0.0.0/24
block
}
file /etc/coredns/zones/db.lab.internal
log
errors
}
. {
bind 127.0.0.1 10.0.0.5
acl {
allow net 127.0.0.0/8 10.0.0.0/24
block
}
forward . 1.1.1.1 9.9.9.9
cache 300
loop
log
errors
health 127.0.0.1:8080
prometheus 127.0.0.1:9153
reload
}
What each plugin does:
| Plugin | Purpose |
|---|---|
file | Serves the zone authoritatively from the zone file. |
forward | Sends queries to upstream resolvers, with built-in health checks. |
cache | Caches answers for up to 300 seconds. |
loop | Stops CoreDNS if it detects a forwarding loop back to itself. |
log / errors | Logs every query and every error to standard output. |
health / prometheus | Exposes a health endpoint and metrics, bound to localhost only. |
reload | Reloads the Corefile automatically when it changes. |
The order of plugins inside a block does not matter: CoreDNS always runs them in a fixed, compiled-in order. On a busy resolver, remove log from the . block to avoid writing a line per query.
Step 5 - Testing the configuration in the foreground
Before creating a service, start CoreDNS by hand on an unprivileged port. The -dns.port flag applies to every server block that does not set its own port:
coredns -conf /etc/coredns/Corefile -dns.port 1053
lab.internal.:1053 on 10.0.0.5
lab.internal.:1053 on 127.0.0.1
.:1053 on 10.0.0.5
.:1053 on 127.0.0.1
CoreDNS-1.12.2
linux/amd64, go1.24.1, ...
From a second SSH session, query a record from the private zone and a public name:
dig @127.0.0.1 -p 1053 www.lab.internal +short
dig @127.0.0.1 -p 1053 cubepath.com +short
The first command returns the CNAME and its target address:
app.lab.internal.
10.0.0.21
The second returns the public addresses of the domain. Stop CoreDNS with CTRL+C once both queries work.
Step 6 - Running CoreDNS as a systemd service
Port 53 is privileged. Instead of running CoreDNS as root, grant the coredns user only the CAP_NET_BIND_SERVICE capability through the unit file:
sudo nano /etc/systemd/system/coredns.service
[Unit]
Description=CoreDNS DNS server
Documentation=https://coredns.io
After=network-online.target
Wants=network-online.target
[Service]
User=coredns
Group=coredns
ExecStart=/usr/local/bin/coredns -conf /etc/coredns/Corefile
ExecReload=/bin/kill -SIGUSR1 $MAINPID
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
LimitNOFILE=1048576
Restart=on-failure
[Install]
WantedBy=multi-user.target
SIGUSR1 makes CoreDNS reload its configuration gracefully, so systemctl reload coredns works without dropping queries.
Load the unit, then start and enable the service:
sudo systemctl daemon-reload
sudo systemctl enable --now coredns
Check that it is running and listening on port 53:
systemctl status coredns --no-pager
sudo ss -ulpn 'sport = :53'
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
UNCONN 0 0 10.0.0.5:53 0.0.0.0:* users:(("coredns",pid=2143,fd=10))
UNCONN 0 0 127.0.0.1:53 0.0.0.0:* users:(("coredns",pid=2143,fd=9))
UNCONN 0 0 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=612,fd=14))
CoreDNS and the systemd-resolved stub listener coexist because each one binds different addresses. Query the service on the standard port:
dig @10.0.0.5 db.lab.internal +short
10.0.0.20
CoreDNS logs to standard output, so its query and error logs are in the journal:
sudo journalctl -u coredns -f
Step 7 - Opening the firewall and configuring clients
Allow DNS over UDP and TCP only from the private network:
sudo ufw allow from 10.0.0.0/24 to any port 53 proto udp
sudo ufw allow from 10.0.0.0/24 to any port 53 proto tcp
On another Ubuntu machine in the same network, you can send only lab.internal queries to CoreDNS and keep the default resolver for everything else. Replace eth1 with the name of that machine's private interface:
sudo resolvectl dns eth1 10.0.0.5
sudo resolvectl domain eth1 '~lab.internal'
Test the name resolution through the system resolver:
resolvectl query app.lab.internal
app.lab.internal: 10.0.0.21 -- link: eth1
resolvectl changes are lost on reboot. To make them permanent, add nameservers with addresses: [10.0.0.5] and search: [lab.internal] to the interface in the client's Netplan configuration and run sudo netplan apply.
Step 8 - Adding and changing records
To add a record, edit the zone file, add the new line and increase the serial:
sudo nano /etc/coredns/zones/db.lab.internal
@ IN SOA ns1.lab.internal. hostmaster.lab.internal. (
2026092502 ; serial
...
cache IN A 10.0.0.22
Within a minute the file plugin detects the new serial and reloads the zone, with no restart required:
dig @10.0.0.5 cache.lab.internal +short
10.0.0.22
Changes to the Corefile itself are picked up by the reload plugin within about 30 seconds, or immediately with sudo systemctl reload coredns.
Using CoreDNS in Kubernetes
In a Kubernetes cluster, CoreDNS runs as a Deployment in kube-system and reads its Corefile from the coredns ConfigMap. To let pods resolve lab.internal through the server you just built, add a separate server block to that ConfigMap:
kubectl -n kube-system edit configmap coredns
data:
Corefile: |
.:53 {
# existing cluster configuration, leave unchanged
}
lab.internal:53 {
errors
cache 30
forward . 10.0.0.5
}
The default cluster configuration already includes the reload plugin, so the change is applied within a couple of minutes. Test it from a temporary pod:
kubectl run dnstest --image=busybox:1.36 --rm -it --restart=Never -- nslookup app.lab.internal
The cluster nodes must be inside 10.0.0.0/24, or you must add their network to the acl and UFW rules on the CoreDNS server.
Troubleshooting
listen udp 10.0.0.5:53: bind: cannot assign requested address: the address in the bind plugin is not configured on the server. Check it with ip -brief address and fix the Corefile.
listen udp :53: bind: address already in use: a block is missing the bind plugin, so CoreDNS tries to listen on all addresses and collides with systemd-resolved. Add bind to every server block.
plugin/loop: Loop (127.0.0.1:... -> :53) detected: CoreDNS is forwarding to itself, usually because forward . /etc/resolv.conf points back at the server. Forward to explicit upstream addresses as shown in Step 4.
Queries from clients time out or return REFUSED: REFUSED comes from the acl plugin, so the client's address is not in an allowed network. A timeout usually means UFW is blocking it; check with sudo ufw status.
A zone change is not visible: the serial was not increased. Bump it and check the journal with sudo journalctl -u coredns -n 20.
Conclusion
You now have CoreDNS serving a private lab.internal zone, forwarding and caching all other queries, running as an unprivileged systemd service and answering only your own network. From here you can scrape the metrics on 127.0.0.1:9153 with Prometheus, add a second CoreDNS server with the same zone for redundancy, or use the hosts plugin to publish a few names without maintaining a full zone file.
