Build a Hardened DNS Resolver
Configure Unbound with DNSSEC validation and DNS-over-TLS on Ubuntu.
Introduction
30 Second Summary
Every time you visit a website, your computer asks a DNS server for directions. By default, those questions travel in plain text. That means your ISP (and anyone else on your network) can see every domain you visit.
In this project, you will build your own secure DNS resolver using Unbound on an Ubuntu VM. Your resolver will encrypt all outgoing queries and cryptographically verify every response, so no one can spy on or tamper with your DNS traffic.
What You'll Build
A privacy-focused DNS resolver running on Ubuntu 24.04 that validates responses with DNSSEC and encrypts upstream queries with DNS-over-TLS.
By the end of this project, you'll have:
- A running Unbound resolver handling all DNS queries on your VM, with the systemd-resolved port conflict cleanly resolved.
- A hardened configuration with DNSSEC validation, access controls, cache poisoning mitigations, and privacy protections that follow real-world best practices.
- Encrypted upstream queries via DNS-over-TLS, proven by capturing traffic on port 853 with tcpdump.
- Secret Mission: Add local DNS zones to resolve custom internal hostnames on your network.
Are there any prerequisites?
You should have a basic understanding of DNS concepts (how resolution works and what records are). Familiarity with using a Linux terminal (cd, sudo, editing files) is also expected.
You will need an Ubuntu 24.04 LTS VM running in Parallels on macOS. The project walks you through installing everything else.
Install Unbound and Resolve the Port 53 Conflict
Before your VM can run its own secure DNS resolver, you need to deal with a conflict.
Ubuntu 24.04 runs a built-in DNS stub listener called systemd-resolved that already occupies port 53. Unbound also needs port 53 to serve DNS queries. In this step, you will disable the stub listener safely, install Unbound, and confirm it resolves queries.
In this step, get ready to:
- Disable the systemd-resolved stub listener so port 53 is free.
- Install Unbound and dnsutils on Ubuntu 24.04.
- Point the system DNS to Unbound and confirm resolution works.
Disable the systemd-resolved stub listener
Ubuntu's systemd-resolved occupies port 53 with a DNS stub listener at 127.0.0.53. You will disable only the stub listener (not the entire service) so your system still has fallback DNS if Unbound fails to start.
- Create a drop-in config file that disables the stub listener by running these commands:
sudo mkdir -p /etc/systemd/resolved.conf.d/
printf "[Resolve]\nDNSStubListener=no\n" | sudo tee /etc/systemd/resolved.conf.d/disable-stub.conf
What do these commands do?
- mkdir -p creates the drop-in directory where systemd-resolved reads override configs.
- printf ... | sudo tee writes a config file that sets DNSStubListener=no. This tells systemd-resolved to stop listening on port 53 without disabling the service entirely.
- Update the resolv.conf symlink so the system uses upstream DNS servers directly by running:
sudo rm /etc/resolv.conf
sudo ln -s /run/systemd/resolve/resolv.conf /etc/resolv.conf
Why update the symlink?
The file /etc/resolv.conf tells your system which DNS server to use. By pointing it to /run/systemd/resolve/resolv.conf, your system reads the upstream DNS servers that systemd-resolved knows about. This keeps DNS working while you install Unbound.
- Restart systemd-resolved and verify port 53 is free by running:
sudo systemctl restart systemd-resolved
sudo ss -lntp | grep ':53 '
The ss command should return no output at all. Empty output means nothing is listening on port 53, which is exactly what you need.
Still seeing output on port 53?
If ss still shows a listener on port 53, confirm the drop-in file was created correctly.
- Run cat /etc/systemd/resolved.conf.d/disable-stub.conf and check it shows [Resolve] on the first line and DNSStubListener=no on the second.
- If the file looks correct, restart systemd-resolved again and re-run the ss command.
Help me debug why port 53 is still occupied after disabling the stub listener.
Install Unbound and dnsutils
- Install Unbound and the dig utility by running:
sudo apt update && sudo apt install -y unbound dnsutils
What is being installed?
- unbound is a recursive DNS resolver purpose-built for security and privacy.
- dnsutils provides dig, a command-line tool for querying DNS records and inspecting responses.
Unbound starts automatically after installation.
- Verify Unbound is running by checking its service status:
sudo systemctl status unbound
You should see Active: active (running) in the output.
- Confirm Unbound can resolve a DNS query by running:
dig @127.0.0.1 example.com
You should see an ANSWER SECTION with an A record for example.com. This confirms Unbound is resolving DNS queries on port 53.
Seeing 'connection refused' from dig?
- Run sudo systemctl status unbound to check if the service failed to start.
- If Unbound failed, check the logs with sudo journalctl -u unbound --no-pager -n 20 for error details.
- A common cause is another process grabbing port 53 before Unbound could start. Re-run sudo ss -lntp | grep ':53 ' to check.
Help me troubleshoot why Unbound is not responding to dig queries.
Point the system DNS to Unbound
Right now, your system's /etc/resolv.conf still points to the upstream servers from systemd-resolved. You need to update it so all system DNS queries flow through Unbound at 127.0.0.1.
- Point your system DNS to Unbound by running:
echo "nameserver 127.0.0.1" | sudo tee /etc/resolv.conf
What does this do?
This writes nameserver 127.0.0.1 to /etc/resolv.conf, which tells your operating system to send all DNS queries to Unbound running on localhost. Every program on your VM now uses Unbound for DNS resolution.
- Test that system-wide DNS resolution works through Unbound by running:
dig example.com
You should see a successful response with an ANSWER SECTION. Notice you did not need @127.0.0.1 this time. The system automatically routes the query through Unbound because /etc/resolv.conf points there.
DNS resolution failing?
- Check /etc/resolv.conf contains exactly nameserver 127.0.0.1 by running cat /etc/resolv.conf.
- If the file has other entries or is empty, re-run the echo command above.
- Confirm Unbound is still running with sudo systemctl status unbound.
Help me figure out why system DNS queries are not reaching Unbound.
✔️ Awesome, I've got everything!
Great. Double check that both config files match what is below, then move on.
ⓧ I'd like to double check the full code
Here are the two configuration files your system should have at this point.
/etc/systemd/resolved.conf.d/disable-stub.conf:
[Resolve]
DNSStubListener=no
Verify with:
cat /etc/systemd/resolved.conf.d/disable-stub.conf
/etc/resolv.conf:
nameserver 127.0.0.1
Verify with:
cat /etc/resolv.conf
Your resolver is live and handling DNS queries. Next up, you will enable DNSSEC validation so Unbound can cryptographically verify every response it receives.
Configure DNSSEC Validation and Test It
Your Unbound resolver is running and handling DNS queries on port 53. But right now, it accepts whatever answers it gets from upstream servers without any proof those answers are legitimate.
DNSSEC (Domain Name System Security Extensions) adds cryptographic signatures to DNS records. It lets your resolver verify that a response actually came from the authoritative source and was not tampered with in transit. In this step, you will initialize the root trust anchor and prove that DNSSEC validation is working.
In this step, get ready to:
- Initialize and verify the DNSSEC root trust anchor.
- Test DNSSEC validation with a valid domain and a broken one.
- Understand why DNSSEC protects you from forged DNS responses.
Initialize the root trust anchor
Unbound enables DNSSEC validation by default on Ubuntu 24.04 via the auto-trust-anchor-file directive, which points to /var/lib/unbound/root.key. This file contains the cryptographic keys for the DNS root zone. You need to make sure it is properly initialized and owned by the Unbound service account.
- Initialize the root trust anchor, set its ownership, and restart Unbound by running these commands:
sudo unbound-anchor -a /var/lib/unbound/root.key
sudo chown unbound:unbound /var/lib/unbound/root.key
sudo systemctl restart unbound
What do these commands do?
- unbound-anchor downloads and validates the root zone trust anchor from IANA. This is the starting point for all DNSSEC chain-of-trust verification.
- chown unbound:unbound ensures the Unbound process (which runs as the unbound user) can read and update the trust anchor file.
- systemctl restart unbound reloads Unbound so it picks up the freshly initialized trust anchor.
- Verify Unbound is still running after the restart:
sudo systemctl status unbound
You should see active (running) in green text. This confirms Unbound restarted successfully with the trust anchor in place.
Seeing inactive or failed?
If the status shows anything other than active, check that the trust anchor file exists and has the correct ownership by running ls -la /var/lib/unbound/root.key. The owner should be unbound:unbound.
If the file does not exist, re-run the sudo unbound-anchor -a /var/lib/unbound/root.key command. Make sure your VM has internet connectivity.
Help me troubleshoot why Unbound failed to start after initializing the trust anchor.
Test DNSSEC validation with a valid domain
The trust anchor is in place, but you need proof that Unbound is actually using it to validate responses. The dig command has a +dnssec flag that asks the resolver to include DNSSEC records in its response.
- Query a domain with valid DNSSEC and check for the AD (Authenticated Data) flag by running:
dig @127.0.0.1 example.com +dnssec
Look for flags: in the output header. You should see ad among the listed flags. The AD flag means Unbound cryptographically verified the entire chain of trust for this response.
What is the AD flag?
AD stands for Authenticated Data. When this flag appears in a DNS response, it means the resolver validated all DNSSEC signatures in the chain from the root zone down to the queried domain. If any signature was missing, expired, or forged, the AD flag would not appear.
- Now test against a domain with intentionally broken DNSSEC by running:
dig @127.0.0.1 dnssec-failed.org
This should return SERVFAIL in the status field. Unbound refuses to hand back a response that fails cryptographic validation.
Why is SERVFAIL a good thing here?
The domain dnssec-failed.org is a test domain with purposely broken DNSSEC signatures. A validating resolver like Unbound detects the broken signatures and returns SERVFAIL instead of the forged data.
This is exactly what you want. If an attacker tampers with a DNS response, DNSSEC catches it and blocks it. Without DNSSEC, your resolver would happily accept the tampered response and send you to a malicious server.
🙋♀️ Not seeing the AD flag or getting a successful response for dnssec-failed.org?
If the AD flag is missing from the example.com response, make sure you are querying @127.0.0.1 (your Unbound instance) and not a different resolver.
If dnssec-failed.org returns a successful response instead of SERVFAIL, your trust anchor may not be initialized. Re-run sudo unbound-anchor -a /var/lib/unbound/root.key followed by sudo systemctl restart unbound.
Help me troubleshoot why DNSSEC validation is not working on my Unbound resolver.
Understand what DNSSEC is protecting you from
What is DNSSEC solving?
Without DNSSEC, a DNS response is just a packet with no proof of authenticity. An attacker who can intercept your DNS traffic (on public Wi-Fi, a compromised router, or via your ISP) can substitute fake answers and redirect you to malicious sites.
DNSSEC adds cryptographic signatures to DNS records. Unbound downloads the root trust anchor (root.key) and uses it to verify the chain of trust from the DNS root all the way down to the domain you queried. If any link in that chain is broken or forged, the response is rejected.
You just proved this works. A valid domain got the AD flag (chain of trust verified), and a broken domain got SERVFAIL (chain of trust failed, response blocked).
Your resolver now cryptographically verifies every DNS response. Next up, you will harden it against cache poisoning attacks, lock down access controls, and add privacy protections that minimize the data your resolver leaks upstream.
Harden the Resolver Against Attacks
In the previous step, you proved that DNSSEC validation works. Your resolver now rejects tampered responses. But DNSSEC only protects integrity. It does not protect against attackers abusing your resolver itself.
An unhardened resolver is vulnerable to cache poisoning, amplification attacks, and fingerprinting. In this step, you will replace the default Unbound configuration with a hardened one that locks down access, mitigates poisoning, and hides your resolver's identity from attackers.
In this step, get ready to:
- Replace the default Unbound configuration with a hardened version.
- Validate the configuration and restart Unbound.
- Verify that hardening directives are active by testing version hiding.
Create a hardened Unbound configuration
The default unbound.conf on Ubuntu 24.04 is minimal. You will replace it with a configuration that explicitly locks down every attack surface.
- Back up the default configuration file by running this command:
sudo cp /etc/unbound/unbound.conf /etc/unbound/unbound.conf.backup
Why back up first?
If your new configuration has a syntax error that prevents Unbound from starting, you can restore the original with sudo cp /etc/unbound/unbound.conf.backup /etc/unbound/unbound.conf.
- Open the configuration file in nano by running:
sudo nano /etc/unbound/unbound.conf
- Select all existing content and delete it. Press Ctrl+K repeatedly until the file is empty.
- Paste the following hardened configuration into the file:
server:
# Network
interface: 127.0.0.1
port: 53
do-ip4: yes
do-ip6: no
do-udp: yes
do-tcp: yes
# Access control
access-control: 127.0.0.0/8 allow
access-control: 0.0.0.0/0 refuse
# DNSSEC validation
auto-trust-anchor-file: "/var/lib/unbound/root.key"
val-clean-additional: yes
# Hardening against DNS attacks
harden-glue: yes
harden-dnssec-stripped: yes
harden-referral-path: yes
harden-below-nxdomain: yes
harden-algo-downgrade: yes
harden-large-queries: yes
# Cache poisoning mitigation
use-caps-for-id: yes
# Privacy
hide-identity: yes
hide-version: yes
qname-minimisation: yes
# Performance
num-threads: 2
msg-cache-size: 64m
rrset-cache-size: 128m
cache-min-ttl: 60
cache-max-ttl: 86400
prefetch: yes
# TLS certificate bundle for upstream DoT verification
tls-cert-bundle: "/etc/ssl/certs/ca-certificates.crt"
# Logging
verbosity: 1
log-queries: no
log-replies: no
log-servfail: yes
use-syslog: yes
What does this configuration do?
This config locks down your resolver across four categories:
- Access control: Only queries from localhost (127.0.0.0/8) are allowed. Everything else is refused. This prevents your resolver from being exploited in DNS amplification attacks.
- Hardening directives: harden-glue, harden-dnssec-stripped, use-caps-for-id (0x20 bit randomisation), and others make cache poisoning dramatically harder for attackers.
- Privacy: hide-identity and hide-version prevent attackers from fingerprinting your Unbound version. qname-minimisation reduces the amount of data sent to upstream servers (RFC 7816).
- Performance: prefetch, cache TTL tuning, and thread configuration keep responses fast.
- Save the file by pressing Ctrl+O, then Enter to confirm the filename, then Ctrl+X to exit nano.
You won't see a change yet. The next substep validates this configuration and restarts Unbound.
Pasting issues in nano?
If your paste adds extra indentation or garbles the text, try right-clicking in the terminal window to paste instead of using a keyboard shortcut. Some terminal emulators interpret Ctrl+V differently.
Help me fix pasting into nano
✔️ Awesome, I've got everything!
Great. Double check you saved the file before moving on.
ⓧ I'd like to double check the full code
Here is the complete /etc/unbound/unbound.conf file at this point:
server:
# Network
interface: 127.0.0.1
port: 53
do-ip4: yes
do-ip6: no
do-udp: yes
do-tcp: yes
# Access control
access-control: 127.0.0.0/8 allow
access-control: 0.0.0.0/0 refuse
# DNSSEC validation
auto-trust-anchor-file: "/var/lib/unbound/root.key"
val-clean-additional: yes
# Hardening against DNS attacks
harden-glue: yes
harden-dnssec-stripped: yes
harden-referral-path: yes
harden-below-nxdomain: yes
harden-algo-downgrade: yes
harden-large-queries: yes
# Cache poisoning mitigation
use-caps-for-id: yes
# Privacy
hide-identity: yes
hide-version: yes
qname-minimisation: yes
# Performance
num-threads: 2
msg-cache-size: 64m
rrset-cache-size: 128m
cache-min-ttl: 60
cache-max-ttl: 86400
prefetch: yes
# TLS certificate bundle for upstream DoT verification
tls-cert-bundle: "/etc/ssl/certs/ca-certificates.crt"
# Logging
verbosity: 1
log-queries: no
log-replies: no
log-servfail: yes
use-syslog: yes
Validate and apply the configuration
Before restarting Unbound, you need to confirm there are no syntax errors in the config. Unbound ships with a built-in validation tool for exactly this.
- Check the configuration for syntax errors by running:
sudo unbound-checkconf
- You should see this output:
unbound-checkconf: no errors in /etc/unbound/unbound.conf
Seeing a syntax error?
The error message will name the exact line number and directive that failed. Open the file again with sudo nano /etc/unbound/unbound.conf and compare that line against the configuration above. Common causes are missing colons, extra spaces before a directive name, or a mistyped yes/no value.
Help me fix this syntax error
- Restart Unbound to apply the new configuration by running:
sudo systemctl restart unbound
- Verify Unbound is running by checking its status:
sudo systemctl status unbound
- You should see active (running) in the output. Press q to exit the status view.
- Test that DNS resolution still works by running:
dig @127.0.0.1 example.com
You should see a valid A record in the ANSWER SECTION of the output. This confirms your hardened config did not break resolution.
Unbound failed to start?
If systemctl status unbound shows a failure, check the journal for details by running sudo journalctl -u unbound --no-pager -n 20. The log entry will identify which directive caused the failure.
Help me debug the Unbound startup failure
Verify hardening is active
A running resolver is not the same as a hardened one. You need to confirm that the hardening directives are actually working. The easiest test is version hiding.
- Try to query your resolver's version by running:
dig @127.0.0.1 version.bind CH TXT
What is this query doing?
The version.bind query in the CH (Chaosnet) class is a standard way attackers fingerprint DNS servers. Without hide-version: yes, Unbound would respond with its exact version number. Attackers use this to find resolvers running versions with known vulnerabilities.
With your hardened config, the response shows status: REFUSED instead of revealing any version information.
- Look for status: REFUSED in the header section of the output. This confirms Unbound is blocking version fingerprinting attempts.
- Run a normal query to confirm that regular resolution still works alongside the hardening:
dig @127.0.0.1 example.com
You should see a successful response with an ANSWER SECTION. Your resolver blocks fingerprinting queries while still serving legitimate DNS lookups.
Still seeing a version number?
If the version.bind query returns a version string instead of REFUSED, open /etc/unbound/unbound.conf and confirm the hide-version: yes directive is present under the # Privacy section. Save, run sudo unbound-checkconf, and restart with sudo systemctl restart unbound.
Help me fix the version hiding configuration
Your resolver is now locked down. It only answers queries from localhost, blocks fingerprinting, and fights cache poisoning. Next up, you will encrypt all upstream queries with DNS-over-TLS so your ISP cannot see which domains you are resolving.
Enable DNS-over-TLS and Verify Encryption
Your resolver is now hardened against attacks. It rejects forged responses, blocks fingerprinting attempts, and refuses queries from outside localhost. But there is still one major gap: every DNS query leaving your machine travels in plaintext.
In this step, you will configure DNS-over-TLS (DoT) so that all upstream queries are encrypted on port 853. Your ISP will no longer be able to see which domains you are resolving.
In this step, get ready to:
- Add a DNS-over-TLS forward zone to your Unbound configuration.
- Verify encrypted traffic on port 853 with tcpdump.
- Confirm no plaintext DNS leaves your machine to external servers.
Add the DNS-over-TLS forward zone to unbound.conf
Right now, Unbound resolves queries by talking directly to authoritative nameservers over unencrypted UDP. By adding a forward zone with TLS enabled, you tell Unbound to route all queries through encrypted connections to trusted upstream resolvers instead.
- Open your Unbound configuration file by running:
sudo nano /etc/unbound/unbound.conf
- Scroll to the very bottom of the file, past the last line in the server: block (use-syslog: yes).
- Add the following forward-zone block at the end of the file:
# DNS-over-TLS forwarding to upstream resolvers
forward-zone:
name: "."
forward-tls-upstream: yes
# Cloudflare
forward-addr: 1.1.1.1@853#cloudflare-dns.com
forward-addr: 1.0.0.1@853#cloudflare-dns.com
# Quad9
forward-addr: 9.9.9.9@853#dns.quad9.net
forward-addr: 149.112.112.112@853#dns.quad9.net
What does this configuration do?
This block tells Unbound to forward all DNS queries (the name: "." means "everything") over TLS instead of plain UDP.
- forward-tls-upstream: yes enables TLS encryption for all upstream connections.
- Each forward-addr uses the format IP@port#tls-auth-name. The hostname after # is the TLS authentication name used for certificate domain validation.
- Without the TLS authentication name, an attacker with a self-signed certificate could intercept your queries. The tls-cert-bundle directive (already in your server: block) provides the CA certificates for verifying these connections.
💡 Why Cloudflare and Quad9?
Cloudflare (1.1.1.1) and Quad9 (9.9.9.9) both support DNS-over-TLS on port 853 and operate no-logging or minimal-logging policies. Having two providers gives you redundancy if one goes down.
- Save the file by pressing Ctrl+O, then Enter, then exit with Ctrl+X.
✔️ Awesome, I've got everything!
Great. Make sure you saved unbound.conf before moving on.
ⓧ I'd like to double check the full code
Here is the complete /etc/unbound/unbound.conf file after adding the forward-zone block:
server:
# Network
interface: 127.0.0.1
port: 53
do-ip4: yes
do-ip6: no
do-udp: yes
do-tcp: yes
# Access control
access-control: 127.0.0.0/8 allow
access-control: 0.0.0.0/0 refuse
# DNSSEC validation
auto-trust-anchor-file: "/var/lib/unbound/root.key"
val-clean-additional: yes
# Hardening against DNS attacks
harden-glue: yes
harden-dnssec-stripped: yes
harden-referral-path: yes
harden-below-nxdomain: yes
harden-algo-downgrade: yes
harden-large-queries: yes
# Cache poisoning mitigation
use-caps-for-id: yes
# Privacy
hide-identity: yes
hide-version: yes
qname-minimisation: yes
# Performance
num-threads: 2
msg-cache-size: 64m
rrset-cache-size: 128m
cache-min-ttl: 60
cache-max-ttl: 86400
prefetch: yes
# TLS certificate bundle for upstream DoT verification
tls-cert-bundle: "/etc/ssl/certs/ca-certificates.crt"
# Logging
verbosity: 1
log-queries: no
log-replies: no
log-servfail: yes
use-syslog: yes
# DNS-over-TLS forwarding to upstream resolvers
forward-zone:
name: "."
forward-tls-upstream: yes
# Cloudflare
forward-addr: 1.1.1.1@853#cloudflare-dns.com
forward-addr: 1.0.0.1@853#cloudflare-dns.com
# Quad9
forward-addr: 9.9.9.9@853#dns.quad9.net
forward-addr: 149.112.112.112@853#dns.quad9.net
Unsure where to add the forward-zone block?
The forward-zone block goes outside and below the server: block. It is a top-level directive, not indented under server:. Make sure there is a blank line between the last server: directive and the forward-zone: line.
Help me figure out where to place the forward-zone block in my unbound.conf
Apply and test DNS-over-TLS
With the forward zone saved, you need to validate the syntax, restart Unbound, and then prove that your DNS traffic is actually encrypted.
- Check your configuration for syntax errors by running:
sudo unbound-checkconf
- You should see: unbound-checkconf: no errors in /etc/unbound/unbound.conf.
- Restart Unbound to apply the new configuration by running:
sudo systemctl restart unbound
- Confirm Unbound is running by running:
sudo systemctl status unbound
- You should see active (running) in the output.
- Test that resolution still works by running:
dig @127.0.0.1 example.com
- You should see an ANSWER SECTION with an A record for example.com. This confirms Unbound is resolving queries through the encrypted upstream connection.
Seeing SERVFAIL or a timeout?
If dig returns SERVFAIL or times out, your VM might not have outbound access to port 853. Check that your VM's network adapter allows outbound TCP on port 853.
Also run sudo unbound-checkconf again to make sure there are no typos in the forward-addr lines. A missing @853 or a misspelled hostname after # will cause TLS validation to fail.
Help me troubleshoot why my DNS-over-TLS forwarding is not working
Now you will prove that the traffic is actually encrypted. You will use tcpdump to capture network packets and confirm they travel on port 853 (the DNS-over-TLS port).
- Start a packet capture on port 853 in the background by running:
sudo tcpdump -i any port 853 -c 5 &
- Trigger a DNS query to generate traffic by running:
dig @127.0.0.1 cloudflare.com
- You should see tcpdump output showing TCP traffic on port 853 to upstream servers like 1.1.1.1 or 9.9.9.9. This is your encrypted DNS traffic.
Next, confirm that no plaintext DNS is leaving your machine on port 53 to external IPs.
- Start a packet capture on port 53 by running:
sudo tcpdump -i any port 53 -c 5
- In a second terminal window, make a query by running:
dig @127.0.0.1 example.com
- You should see only local traffic (127.0.0.1 to 127.0.0.1) on port 53. No outbound traffic to external IPs on port 53 means your DNS is no longer sent in plaintext.
tcpdump showing no output on port 853?
If the background tcpdump captured nothing, Unbound may have used a cached result. Try querying a domain you have not queried before (e.g., dig @127.0.0.1 wikipedia.org) to force a fresh upstream lookup.
Help me verify that my DNS-over-TLS traffic is working with tcpdump
Understand the privacy improvement
What changed for your privacy?
Before DNS-over-TLS, every DNS query you made was sent in plaintext over UDP port 53. Your ISP, anyone on the same network, or a compromised router could read exactly which domains you were visiting.
Now, all upstream queries are wrapped in TLS on port 853. Your ISP can see you are connecting to 1.1.1.1 or 9.9.9.9, but cannot see which domains you are querying.
Combined with DNSSEC from the previous step, you now have both encrypted transport and verified integrity. Your queries are private and their responses are proven authentic.
Secret mission
Add Local DNS Zones for Internal Hostnames
Your resolver encrypts and validates external DNS queries. But what about internal hostnames on your local network? In this mission, you'll configure Unbound to resolve custom domain names for internal servers without registering anything publicly.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local Ubuntu VM, so there are no ongoing cloud costs. Decide whether to keep your resolver running, pause it to come back later, or delete it entirely.
Resources you used:
- Unbound DNS resolver service (running on port 53).
- Configuration file at /etc/unbound/unbound.conf (and backup at /etc/unbound/unbound.conf.backup).
- Drop-in config at /etc/systemd/resolved.conf.d/disable-stub.conf (disables the systemd-resolved stub listener).
- DNSSEC root trust anchor at /var/lib/unbound/root.key.
- dnsutils package (provides dig).
- Modified /etc/resolv.conf symlink pointing to 127.0.0.1.
Keep everything running
No action needed. Choose this if you want your VM to keep using your hardened Unbound resolver for all DNS queries. Unbound uses minimal memory and CPU.
- Unbound continues resolving DNS queries with DNSSEC validation and DNS-over-TLS encryption.
- Your local DNS zones (if configured in the Secret Mission) remain active.
- All hardening directives stay in effect.
Pause - I'll come back to this later
Stop Unbound and re-enable the default DNS stub listener so your VM still has working DNS. All your configuration files stay intact for when you return.
- Stop and disable Unbound by running:
- Re-enable the systemd-resolved stub listener so your VM has DNS:
sudo systemctl stop unbound
sudo systemctl disable unbound
- Remove the drop-in config that disables the stub listener:
sudo rm /etc/systemd/resolved.conf.d/disable-stub.conf
sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
sudo systemctl restart systemd-resolved
Your VM now uses the default systemd-resolved stub listener for DNS. When you want to resume, re-create the drop-in config, restore the resolv.conf symlink to /run/systemd/resolve/resolv.conf, and run sudo systemctl enable --now unbound.
Delete - I don't want to use this again
Remove Unbound, dnsutils, all configuration files, and restore your system to its default DNS setup.
- Stop Unbound and remove both packages by running:
sudo systemctl stop unbound
sudo apt remove --purge -y unbound dnsutils
sudo apt autoremove -y
- Remove any leftover configuration and data directories:
sudo rm -rf /etc/unbound
sudo rm -rf /var/lib/unbound
- Remove the drop-in config and restore the default DNS stub listener:
sudo rm -f /etc/systemd/resolved.conf.d/disable-stub.conf
sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
sudo systemctl restart systemd-resolved
- Verify DNS is working again through systemd-resolved:
dig example.com
You should see a successful response. Your system is now back to its default DNS configuration.
Nice Work!
Nice Work!
You just built a production-grade hardened DNS resolver from scratch. Your Ubuntu VM now handles its own DNS resolution with encrypted transport, cryptographic verification, and attack mitigations that follow real-world deployment best practices.
Here's what you accomplished:
- Configured DNSSEC validation that cryptographically verifies DNS responses and rejects tampered answers, proven by testing against intentionally broken DNSSEC domains.
- Hardened the resolver with access controls, cache poisoning mitigations (0x20 bit randomisation, glue hardening), version hiding, and QNAME minimisation to reduce data leakage.
- Enabled DNS-over-TLS to encrypt all upstream queries on port 853, verified with packet captures proving no plaintext DNS leaves your machine.
- Secret Mission: Created local DNS zones for internal hostname resolution, giving your network friendly names without public DNS registration.
Ready to quiz yourself?