Setup CoreDNS as a Local DNS Server on Ubuntu

Written by: Bagus Facsi Aginsa
Published at: 30 Jul 2026


If you run more than a couple of servers on a private network, you eventually hit the same wall: remembering 10.20.0.15 is fine for one machine, but once you have a database, a cache, a couple of internal APIs, and a monitoring box, typing IP addresses everywhere gets old fast. The usual first fix is editing /etc/hosts on every machine, which works until you add a server, change an IP, or forget to update one box out of ten.

What you actually want is your own small DNS server: something that resolves db.internal.lan to the right IP for every machine on your network, forwards anything it does not know about (like github.com) to a real public resolver, and is simple enough that you are not afraid to touch its config file. CoreDNS fits that job well. It started as the DNS server inside Kubernetes, but it runs perfectly well as a standalone service on a plain Ubuntu box, configured entirely through one small text file called a Corefile.

This tutorial is for sysadmins and DevOps engineers running a handful of servers on a private network (a home lab, an office network, or a fleet of cloud VMs on a private subnet) who want real internal DNS instead of another /etc/hosts file to keep in sync. You should already be comfortable with basic Linux commands and have sudo access on the server that will run CoreDNS.


Conceptual Overview

A DNS server answers one basic question: given a hostname like db.internal.lan, what IP address does it point to? Your Ubuntu machines already use one, usually your router or your cloud provider’s resolver, configured through /etc/resolv.conf or systemd-resolved. What we are adding is a second resolver that knows about your own internal hostnames and defers everything else to the resolver you already had.

CoreDNS is built around a chain of small, focused plugins, each doing one job: one plugin serves records from a local zone file, another forwards unmatched queries elsewhere, another logs queries, and so on. You wire these plugins together in a Corefile, which reads a bit like an Nginx config: a block per zone, a list of plugins inside each block, executed top to bottom for every matching query.

A zone is the domain CoreDNS is authoritative for, for example internal.lan. Within that zone you define records, most commonly A records that map a hostname to an IPv4 address. Any query CoreDNS receives for a name outside its zones (like a public website) falls through to the forward plugin, which relays the question to an upstream resolver such as 1.1.1.1 and hands the answer back, so your internal DNS server can fully replace your machines’ default resolver rather than living alongside it.


Prerequisites

  • An Ubuntu server to act as the DNS server (this guide uses Ubuntu 22.04 and 24.04), reachable from the rest of your network. Here it has the private IP 10.20.0.10.
  • sudo privileges on that server.
  • A private network where your servers already have static or predictable IPs. If you have not set that up yet, see the earlier guide on setting a static IP address with Netplan on Ubuntu, since a DNS server whose own IP keeps changing is not much use.
  • Basic comfort editing text files with nano or vim.

In this tutorial the internal zone is internal.lan and the example hosts are db.internal.lan (10.20.0.20), app.internal.lan (10.20.0.21), and the DNS server itself, ns.internal.lan (10.20.0.10). Replace these with your own values throughout.


Step 1: Install CoreDNS on Ubuntu

Ubuntu’s default repositories do not ship CoreDNS, so install it from the official GitHub release. Check the latest version and download the Linux amd64 binary:

curl -L -o coredns.tar.gz \
  https://github.com/coredns/coredns/releases/download/v1.11.3/coredns_1.11.3_linux_amd64.tgz

Extract and install the binary:

tar xzf coredns.tar.gz
sudo mv coredns /usr/local/bin/coredns
sudo chmod +x /usr/local/bin/coredns

Confirm it runs:

coredns -version
CoreDNS-1.11.3

Create a dedicated directory for its configuration and zone files:

sudo mkdir -p /etc/coredns

Step 2: Write a Zone File for Your Internal Domain

The zone file lists the actual hostname-to-IP mappings, using standard DNS zone file syntax. Create it:

sudo nano /etc/coredns/internal.lan.db
$ORIGIN internal.lan.
$TTL 3600

@       IN  SOA   ns.internal.lan. admin.internal.lan. (
                    2026073001 ; serial
                    3600       ; refresh
                    900        ; retry
                    604800     ; expire
                    3600 )     ; minimum TTL

@       IN  NS    ns.internal.lan.
ns      IN  A     10.20.0.10
db      IN  A     10.20.0.20
app     IN  A     10.20.0.21

A few notes on this file, since zone file syntax trips up a lot of people the first time. The SOA (Start of Authority) record is mandatory for any zone and declares who is authoritative for it; the serial number should increase every time you edit the file, since some tools use it to detect changes (bumping the date-based number shown here, like 2026073001, is a common convention). Every hostname line after that is a plain A record: a name, the record type, and an IP address. The trailing dot in ns.internal.lan. matters: it means “this name is already fully qualified,” which is standard DNS notation.


Step 3: Write the Corefile

The Corefile ties the zone file to the plugins that serve it, and defines what happens to everything else. Create it:

sudo nano /etc/coredns/Corefile
internal.lan:53 {
    file /etc/coredns/internal.lan.db
    log
    errors
}

.:53 {
    forward . 1.1.1.1 8.8.8.8
    cache 30
    log
    errors
}

The first block handles the internal.lan zone specifically: the file plugin serves answers straight from the zone file you just wrote. The second block, matching . (everything), catches every other query, meaning any hostname that is not under internal.lan, and hands it to the forward plugin, which relays it to Cloudflare’s 1.1.1.1 and Google’s 8.8.8.8 as fallback. The cache plugin holds forwarded answers for 30 seconds so repeated lookups do not hit the upstream resolver every time, and log plus errors give you visibility into what CoreDNS is doing while you test it.


Step 4: Run CoreDNS as a systemd Service

Running coredns in a terminal is fine for testing, but you want it to survive reboots and restart if it crashes. Create a systemd unit:

sudo nano /etc/systemd/system/coredns.service
[Unit]
Description=CoreDNS DNS Server
After=network.target

[Service]
ExecStart=/usr/local/bin/coredns -conf /etc/coredns/Corefile
Restart=on-failure
RestartSec=5
User=root
AmbientCapabilities=CAP_NET_BIND_SERVICE

[Install]
WantedBy=multi-user.target

The AmbientCapabilities=CAP_NET_BIND_SERVICE line matters: DNS runs on port 53, which is a privileged port below 1024, and this capability lets CoreDNS bind to it without running the whole process as a heavier, riskier root login shell would imply.

Before starting CoreDNS, free up port 53. Ubuntu’s systemd-resolved usually listens there by default, and two services cannot bind the same port. Check what is using it:

sudo ss -tulpn | grep :53

If systemd-resolved shows up, disable its stub listener (its own DNS resolution for the local machine keeps working through /etc/resolv.conf, only the network-facing listener on port 53 is disabled):

sudo sed -i 's/#DNSStubListener=yes/DNSStubListener=no/' /etc/systemd/resolved.conf
sudo systemctl restart systemd-resolved

Now start CoreDNS:

sudo systemctl daemon-reload
sudo systemctl enable --now coredns

Check its status:

sudo systemctl status coredns

You should see active (running) with no errors in the last few log lines.


Step 5: Test Resolution Locally

Query CoreDNS directly using dig, pointing explicitly at its IP so you are not accidentally testing your old resolver:

dig @10.20.0.10 db.internal.lan
;; ANSWER SECTION:
db.internal.lan.       3600    IN      A       10.20.0.20

Now confirm forwarding works for a public domain:

dig @10.20.0.10 github.com +short

You should get back a real public IP for GitHub, proving the . catch-all block is correctly relaying to 1.1.1.1. If both queries return the expected answers, CoreDNS itself is working correctly; the remaining steps just point your actual clients at it.


Step 6: Point Client Machines at Your DNS Server

On each Ubuntu client that should use this DNS server (not the DNS server itself), edit its Netplan configuration:

sudo nano /etc/netplan/00-installer-config.yaml

Add or update the nameservers block under the relevant interface:

network:
  version: 2
  ethernets:
    eth0:
      dhcp4: true
      nameservers:
        addresses:
          - 10.20.0.10
          - 1.1.1.1

Listing 1.1.1.1 as a second entry gives clients a fallback if your CoreDNS box is ever down. Apply the change:

sudo netplan apply

Verify from the client:

resolvectl status eth0 | grep "DNS Server"
DNS Servers: 10.20.0.10 1.1.1.1

From this client, ping db.internal.lan and ping app.internal.lan should now resolve using your internal names.


Step 7: Restrict Access with UFW

A DNS server answering queries from the whole internet is an open door for abuse (it can be used to amplify DDoS traffic against third parties). Since this server should only answer your own private network, lock it down. If you have not set up UFW yet, see the earlier guide on setting up a firewall with UFW on Ubuntu.

Allow DNS only from your private subnet:

sudo ufw allow from 10.20.0.0/24 to any port 53 proto udp
sudo ufw allow from 10.20.0.0/24 to any port 53 proto tcp
sudo ufw enable

Confirm the rules are active:

sudo ufw status
To                         Action      From
--                         ------      ----
53/udp                     ALLOW       10.20.0.0/24
53/tcp                     ALLOW       10.20.0.0/24

Common Mistakes and Troubleshooting

CoreDNS fails to start with “address already in use”. Something else, almost always systemd-resolved, is already bound to port 53. Recheck sudo ss -tulpn | grep :53 and make sure the stub listener is disabled as shown in Step 4, then restart both services in order: systemd-resolved first, then coredns.

Internal names resolve, but public sites stop working entirely. This usually means the forward block is missing, misplaced below the internal zone block, or pointing at an upstream IP that is unreachable from this server. Confirm outbound connectivity with dig @1.1.1.1 github.com directly, bypassing CoreDNS, to rule out a network issue first.

Clients still resolve using the old DNS server after netplan apply. DNS changes do not always take effect instantly on running connections. Run resolvectl flush-caches and re-check resolvectl status, or reboot the client if you are testing on a machine you do not mind restarting.

Zone file edits do not seem to apply. The file plugin loads the zone file when CoreDNS starts (or reloads on SIGHUP in newer versions), it does not watch the file continuously by default. After editing the zone file, run sudo systemctl restart coredns to be sure the new records are loaded, and remember to bump the serial number as a habit even though the file plugin does not strictly require it.

Typo in the zone file breaks the whole zone, not just one record. A single malformed line in a zone file can cause CoreDNS to refuse the entire zone rather than skipping the bad line. Check sudo journalctl -u coredns -n 50 immediately after any zone file change; CoreDNS’s error messages usually point at the exact line number.


Best Practices

Keep a second DNS entry as fallback. Always list a public resolver as a second nameserver on clients, as shown in Step 6. If your CoreDNS box ever goes down for maintenance, clients keep resolving public names instead of losing internet access entirely.

Run CoreDNS on more than one host for real redundancy. A single DNS server is a single point of failure for your whole network. Once you are comfortable with the basics here, run a second CoreDNS instance on another machine with an identical Corefile and zone file, and list both as nameservers on your clients.

Firewall it tightly. DNS servers reachable from the public internet are a known amplification vector for DDoS attacks. The UFW rule in Step 7 restricting queries to your private subnet is not optional if this server has any path to the internet at all.

Version-control your Corefile and zone files. Both are plain text and change rarely enough that Git is a perfect fit. It gives you history, an easy rollback if a serial number bump or a typo breaks something, and a clean way to deploy the same config to a second server for redundancy.

Automate deployment if you manage more than one DNS server. Copying the same Corefile and zone file to multiple machines by hand invites drift. If you already use Ansible for server configuration, template the zone file so adding a new internal host is a one-line change applied everywhere at once.

Consider it for private overlay networks too. If you already run ZeroTier or a WireGuard mesh between servers, CoreDNS works just as well resolving hostnames to your overlay network’s private IPs, so you never have to remember which 10.x.x.x address belongs to which machine.


Conclusion

You now have a real internal DNS server running CoreDNS on Ubuntu: a zone file describing your private hostnames, a Corefile that serves them and forwards everything else upstream, running as a systemd service that survives reboots, and locked down with UFW so only your own network can query it. Client machines resolve db.internal.lan and app.internal.lan just as easily as they resolve github.com, with one config file as the single source of truth instead of a dozen out-of-sync /etc/hosts files.

From here, the natural next steps are adding a second CoreDNS instance for redundancy, and looking at CoreDNS’s other plugins, rewrite for URL-style redirects at the DNS level, or metrics if you want query statistics exposed for Prometheus to scrape. If your network already spans multiple sites connected over a VPN, extending this same zone across all of them turns a scattered collection of IP addresses into a network that actually has names. </content>