Geographic load balancing with DNS (GeoDNS) answers the same hostname with different IP addresses depending on where the client is, so users in Europe reach your European servers and users in America reach your American ones. In this tutorial you will turn BIND 9 on Ubuntu 24.04 into an authoritative GeoDNS server: you will install the MaxMind GeoLite2 database, define per-continent ACLs, serve a different zone file to each region with views, and test the answers.
Prerequisites
To follow this guide you need:
- Two servers running Ubuntu 24.04 LTS to act as authoritative nameservers (
ns1andns2), for example CubePath VPS in different locations, each with a non-root user withsudoprivileges and a public IP. - A domain whose nameservers you can change at the registrar, called
your_domainin this guide. - Your application deployed in at least two regions. The examples use
203.0.113.10for Europe,198.51.100.10for North America and192.0.2.10as the default; replace them with your servers' addresses. - A free MaxMind account with a license key, created at maxmind.com, to download GeoLite2 databases.
How GeoDNS works and its limits
When a user opens www.your_domain, their device asks a recursive resolver (their ISP's, or a public one), and that resolver asks your authoritative server. BIND looks up the source IP of that query in the GeoIP database and chooses a view, which is a separate copy of the zone with region-specific records.
Keep these limits in mind:
- BIND sees the resolver, not the user. Most users use their ISP's resolver, which is close to them, so this works well in practice. Users of a distant public resolver can get the wrong region.
- No health checks. BIND keeps returning an address even if that data center is down. For automatic failover you need a monitoring system that updates the zone, or a DNS server with health checks such as PowerDNS LUA records.
- Caching. Resolvers cache answers for the record's TTL. A short TTL (60 to 300 seconds) makes changes take effect quickly at the cost of more queries.
Run the same configuration on both nameservers. Zone transfers between servers with views need one transfer key per view, so for a small setup it is simpler to keep identical files on ns1 and ns2 and copy them with your usual deployment tool.
Step 1 - Installing BIND
On each nameserver, install BIND and its tools:
sudo apt update
sudo apt install -y bind9 bind9-utils bind9-dnsutils
GeoIP ACLs require BIND to be built with MaxMind DB support. Check that the Ubuntu build includes it:
named -V | grep -o "with-maxminddb"
with-maxminddb
Check the service. On Ubuntu 24.04 the unit is called named (bind9 is an alias):
sudo systemctl status named --no-pager
● named.service - BIND Domain Name Server
Loaded: loaded (/usr/lib/systemd/system/named.service; enabled; preset: enabled)
Active: active (running) since ...
Step 2 - Installing the GeoLite2 database
BIND reads GeoIP data from MaxMind's .mmdb files. The geoipupdate tool downloads them with your account ID and license key and keeps them up to date. Install it together with mmdb-bin, which provides a lookup tool for testing:
sudo apt install -y geoipupdate mmdb-bin
Edit the configuration file:
sudo nano /etc/GeoIP.conf
Set your account ID and license key, and request the country database. The country database also contains continent data:
AccountID your_account_id
LicenseKey your_license_key
EditionIDs GeoLite2-Country
DatabaseDirectory /var/lib/GeoIP
Protect the file, because it contains your license key, then download the database:
sudo chmod 600 /etc/GeoIP.conf
sudo mkdir -p /var/lib/GeoIP
sudo geoipupdate -v
Confirm the file is there and look up a well-known address:
ls -l /var/lib/GeoIP/
mmdblookup --file /var/lib/GeoIP/GeoLite2-Country.mmdb --ip 1.1.1.1 continent code
The directory should list GeoLite2-Country.mmdb, and the lookup should print the continent code:
"NA" <utf8_string>
MaxMind updates GeoLite2 twice a week. Schedule an update and tell BIND to reload the database afterwards:
sudo nano /etc/cron.d/geoipupdate
# Refresh GeoLite2 on Wednesdays and Saturdays, then make BIND reload it
17 4 * * 3,6 root /usr/bin/geoipupdate && /usr/sbin/rndc reload
Step 3 - Allowing BIND to read the database
Ubuntu confines named with AppArmor, which may block reads outside the paths BIND normally uses. Grant read access to the GeoIP directory through the local override file:
sudo nano /etc/apparmor.d/local/usr.sbin.named
Add this line:
/var/lib/GeoIP/** r,
Reload the profile:
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.named
If this step is missing, BIND logs a permission denied error for the .mmdb file when it starts and the GeoIP ACLs never match.
Step 4 - Defining GeoIP ACLs and options
Edit the global options. This server is authoritative only, so disable recursion; open resolvers are abused for amplification attacks:
sudo nano /etc/bind/named.conf.options
Replace the contents with:
options {
directory "/var/cache/bind";
// Where BIND looks for GeoLite2-Country.mmdb
geoip-directory "/var/lib/GeoIP";
recursion no;
allow-transfer { none; };
listen-on { any; };
listen-on-v6 { any; };
};
acl "geo-europe" {
geoip continent EU;
};
acl "geo-north-america" {
geoip continent NA;
};
Each acl matches clients whose address the database places on that continent. You can also match countries with geoip country ES; using ISO 3166 two-letter codes, and combine several lines in one ACL.
Step 5 - Creating per-region zone files
Each view serves its own copy of the zone. Create a directory and the zone for Europe:
sudo mkdir -p /etc/bind/zones
sudo nano /etc/bind/zones/db.your_domain.eu
$TTL 300
@ IN SOA ns1.your_domain. hostmaster.your_domain. (
2026092501 ; serial
3600 ; refresh
900 ; retry
1209600 ; expire
300 ) ; negative cache TTL
@ IN NS ns1.your_domain.
@ IN NS ns2.your_domain.
ns1 IN A ns1_public_ip
ns2 IN A ns2_public_ip
@ IN A 203.0.113.10
www IN A 203.0.113.10
Replace ns1_public_ip and ns2_public_ip with the addresses of your nameservers. Create the North America zone by copying the file and changing only the application addresses:
sudo cp /etc/bind/zones/db.your_domain.eu /etc/bind/zones/db.your_domain.na
sudo sed -i 's/203\.0\.113\.10/198.51.100.10/g' /etc/bind/zones/db.your_domain.na
sudo cp /etc/bind/zones/db.your_domain.eu /etc/bind/zones/db.your_domain.default
sudo sed -i 's/203\.0\.113\.10/192.0.2.10/g' /etc/bind/zones/db.your_domain.default
Check each zone for syntax errors:
for region in eu na default; do
sudo named-checkzone your_domain "/etc/bind/zones/db.your_domain.${region}"
done
zone your_domain/IN: loaded serial 2026092501
OK
zone your_domain/IN: loaded serial 2026092501
OK
zone your_domain/IN: loaded serial 2026092501
OK
When you change records later, edit every file that needs it and increase the serial.
Step 6 - Configuring the views
When a configuration uses views, every zone must be inside a view. Ubuntu's named.conf includes named.conf.default-zones, which defines zones outside any view, so disable that include. Open the main file:
sudo nano /etc/bind/named.conf
Comment out the default zones line. The other two includes stay as they are:
include "/etc/bind/named.conf.options";
include "/etc/bind/named.conf.local";
// include "/etc/bind/named.conf.default-zones";
The default zones only matter for recursive resolvers, so an authoritative server does not need them. Now define the views:
sudo nano /etc/bind/named.conf.local
view "europe" {
match-clients { geo-europe; };
zone "your_domain" {
type primary;
file "/etc/bind/zones/db.your_domain.eu";
};
};
view "north-america" {
match-clients { geo-north-america; };
zone "your_domain" {
type primary;
file "/etc/bind/zones/db.your_domain.na";
};
};
view "default" {
match-clients { any; };
zone "your_domain" {
type primary;
file "/etc/bind/zones/db.your_domain.default";
};
};
BIND checks views in order and uses the first one whose match-clients matches, so the catch-all default view must be last. Clients from other continents, and addresses missing from the database, get the default answer.
Validate the whole configuration. No output means no errors:
sudo named-checkconf
Restart BIND and check the log for problems loading the database or zones:
sudo systemctl restart named
sudo journalctl -u named --no-pager -n 30
Look for lines showing each view's zone was loaded and no permission denied or GeoIP errors:
... zone your_domain/IN/europe: loaded serial 2026092501
... zone your_domain/IN/north-america: loaded serial 2026092501
... zone your_domain/IN/default: loaded serial 2026092501
... all zones loaded
... running
Allow DNS through the firewall on both servers:
sudo ufw allow 53/udp
sudo ufw allow 53/tcp
Step 7 - Testing the geographic answers
A query from the server itself comes from 127.0.0.1, which has no location, so it gets the default view:
dig @127.0.0.1 www.your_domain +short
192.0.2.10
To test the other views, query the nameserver's public IP from machines in each region, such as another server in Europe and one in North America:
dig @ns1_public_ip www.your_domain +short
From a European machine:
203.0.113.10
From a North American machine:
198.51.100.10
If you are unsure which view a client should get, look up its IP in the database, and turn on query logging to see which view answered:
mmdblookup --file /var/lib/GeoIP/GeoLite2-Country.mmdb --ip client_ip continent code
sudo rndc querylog on
sudo journalctl -u named -f
Each logged query includes the view name, for example view europe: query: www.your_domain IN A. Turn logging off afterwards with sudo rndc querylog off, since it is verbose on a busy server.
Step 8 - Delegating the domain
Once both nameservers answer correctly, point the domain to them. At your registrar:
- Create glue records (sometimes called child nameservers or host records) for
ns1.your_domainandns2.your_domainwith their public IPs. - Set the domain's nameservers to
ns1.your_domainandns2.your_domain.
Delegation changes can take up to 48 hours to propagate, although most resolvers see them within a few hours. Check the delegation from the top of the DNS tree:
dig +trace www.your_domain
The last section of the output should show the answer coming from one of your nameservers.
Troubleshooting
named-checkconf reports when using 'view' statements, all zones must be in views. A zone is still defined outside a view, usually by named.conf.default-zones or an old entry in named.conf.local. Comment it out or move it into a view.
Every client gets the default answer. BIND could not read the database. Check sudo journalctl -u named for errors about the .mmdb file, confirm the file exists in geoip-directory, and verify the AppArmor rule from Step 3.
Users get the wrong region. Check the location of their resolver, not their own IP: a user in Spain using a resolver in the United States gets the North American answer. This is inherent to DNS-based routing.
Changes to a zone do not appear. Increase the serial in the zone file, run sudo rndc reload, and wait for the old TTL to expire in resolvers.
Conclusion
You configured BIND 9 on Ubuntu 24.04 as an authoritative GeoDNS server that uses MaxMind GeoLite2 data and views to send each continent to its closest data center, with automatic database updates and a default answer for everyone else. As next steps, add country-level ACLs where you have more locations, sign the zones with DNSSEC, and put external monitoring on each regional endpoint so you can switch a region's records quickly when a data center fails.
