Setting up and managing a home lab has been fun so far. Self-hosted apps like Home Assistant, NextCloud, Immich, Jellyfin, and others would work well — until they became unresponsive. I wanted these apps to be responsive and consistently accessible both within my home network and remotely, using Tailscale or a VPN.

While figuring out how to make that happen, I came across the concept of split-horizon DNS, and it felt like a perfect fit for my home lab. Split-horizon DNS, also known as split-view DNS, is a technique where a DNS server uses the same domain but provides different DNS responses depending on where the queries come from — inside the home network or outside.

This approach enables devices within my home network to resolve private IP addresses for communicating with each other. Meanwhile, the external clients get a public or VPN-based IP address for secure remote access. By creating separate internal and external DNS views, I ensure that devices and self-hosted apps receive the correct IP addresses based on their location. Here’s how I set up split-horizon DNS for my home lab to achieve that.

Why I chose BIND9 for split-horizon DNS setup

Full DNS control with long-term flexibility

The flexibility to resolve internal and external traffic using BIND9 DNS server is certainly a game-changer. My key motivator was ensuring my devices and self-hosted apps were quickly available whenever I summoned them. Accessing and using them remotely using Tailscale has always been a snail-slow experience for me. I blame myself for not optimizing their configuration, and I aim to remedy that with the split-horizon DNS technique.

After much deliberation, I turned to BIND9 to help me deploy split-horizon DNS for all the clients and self-hosted apps on my homelab. Despite its complex initial setup, I look forward to its long-term benefits, such as security, reliability, and flexibility. That would be beneficial for my home lab.

BIND9 allows me to define multiple DNS views — typically one for internal users and one for external users. Each view includes one or more zones, where each zone contains a DNS record for the devices and services that I’d want the DNS server to resolve. That way, internal users get local IPs, and external users view public or VPN-based ones.

Setting up split-horizon DNS using BIND9 and Unbound

Balancing act of handling DNS queries

Initially, I had planned to install BIND9 on my Raspberry Pi 4 running Pi OS since it’s always on. Instead, I opted for a Debian 13-based virtual machine on Proxmox, as I wanted to take regular snapshots and backups easily. They’d be useful to roll back to a previously stable version if something breaks after an update or a tweak.

After installing BIND9 and dnsutil packages, the first thing I did was to define separate internal and external views, representing internal and external zones, respectively. For that, I used my domain name to resolve the devices and services defined in zones differently for each view. Here’s how my zones file for internal view looks:

$TTL 3600
@ IN SOA ns1.shadez.co. admin.shadez.co. (
2024090701 ; Serial
3600 ; Refresh
1800 ; Retry
604800 ; Expire
86400 ) ; Minimum TTL

IN NS ns1.shadez.co.

; DNS Server
ns1 IN A 192.168.1.7
ns1.dns IN A 192.168.1.7

; Computers
desktop IN A 192.168.1.101
minipc IN A 192.168.1.102
pi4 IN A 192.168.1.103

; Media Devices
ps5.media IN A 192.168.1.104
jellyfin.media IN A 192.168.1.105

; IoT Devices
ha.iot IN A 192.168.1.201
camera.iot IN A 192.168.1.222

Avoid using.local domain names in your zones for internal view, otherwise it'll lead to conflicts with the mDNS service.

For internal networks, I’ve used the IP range that my router serves. Note that the DNS domain name needs to carry the IP address of the machine that runs the BIND9 DNS server.

Similarly, I crafted a zone file for external view using the same domain name for consistency:

$TTL 3600
@ IN SOA ns1.shadez.co. admin.shadez.co. (
2024090705 ; Serial
3600 ; Refresh
1800 ; Retry
604800 ; Expire
86400 ) ; Minimum TTL

IN NS ns1.shadez.co.

ns1 IN A 100.121.23.86 ; Tailscale IP of public DNS server
desktop IN A 100.85.123.64 ; Tailscale IP of desktop
minipc IN A 100.117.11.124 ; Tailscale IP of proxmox on minipc
pi4 IN A 100.93.121.125 ; Tailscale IP of pi4

The above configuration records the Tailscale IP since I use it to access those internal devices remotely. Additionally, it eliminates the need to open ports or expose services publicly. Tailscale’s access control lists enable me to maintain full control over access to these devices and services. With an internal view as well, I get the benefits of a direct connection without relying on Tailscale's DERP to detect that my devices are on the same network.

Next, I configured the internal and external views in the main configuration file (named.conf.local) for BIND9. Make sure to set up your ACLs correctly.

view "internal" {
match-clients { internal; };
recursion yes;
zone "db.shadez.co.internal" {
type master;
file "/etc/bind/zones/db.shadez.co.internal";
};

forwarders {
127.0.0.1 port 5353; # Forward unknown queries to Unbound
};
forward only;
};

view "external" {
match-clients { any; };
recursion no;
zone "db.shadez.co.external" {
type master;
file "/etc/bind/zones/db.shadez.co.external";
};
};

After that, I restarted the BIND9 service and kept an eye out for indentation and syntax errors with named-checkzone command. The external view file is in the same location.

Making Unbound forward the internal DNS queries

Picking a public DNS resolver

Next, I set up Unbound as a recursive DNS resolver to work with BIND9. That made BIND9 answer all internal clients within the internal zone and pass on external queries to Unbound. Then, I ensured Unbound forwarded those encrypted DNS queries to Quad9 via DNS over TLS.

Here’s how my Quad9 configuration for Unbound appears:

server:
# Listen on localhost only
interface: 127.0.0.1
port: 5353
access-control: 127.0.0.1/32 allow
verbosity: 2
auto-trust-anchor-file: "/var/lib/unbound/root.key"
harden-glue: yes
harden-dnssec-stripped: yes
qname-minimisation: yes
hide-identity: yes
hide-version: yes
prefetch: yes
cache-min-ttl: 3600
cache-max-ttl: 86400

forward-zone:
name: "."
forward-tls-upstream: yes
forward-addr: 9.9.9.9@853
forward-addr: 149.112.112.112@853

This configuration ensures Unbound listens only to localhost and comes with added DNSSEC validation as well as a little privacy cover by hiding identity and version. You can use the IP addresses of any public DNS resolver like Google, OpenDNS, or Cloudflare in Unbound's configuration file. Of course, I need to set better ACLS and configure more effective firewall rules to enhance my home network's security.

Speed up clients while keeping the internal network safe

I wanted quicker access and resolution of the self-hosted apps and other devices in my home lab. While I could optimize this setup with a dedicated DHCP server, I let my router handle it for a while. I'm glad I configured BIND9 for internal and external DNS traffic separation and Unbound to handle external forwarding duties. It hasn't made my internet faster, but accessing self-hosted apps and devices in my home lab feels spiffier, and I love this setup. Next, I'll need to follow the essential firewall rules for my home lab to secure and safeguard it better.