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 (ns1 and ns2), for example CubePath VPS in different locations, each with a non-root user with sudo privileges and a public IP.
  • A domain whose nameservers you can change at the registrar, called your_domain in this guide.
  • Your application deployed in at least two regions. The examples use 203.0.113.10 for Europe, 198.51.100.10 for North America and 192.0.2.10 as 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:

  1. Create glue records (sometimes called child nameservers or host records) for ns1.your_domain and ns2.your_domain with their public IPs.
  2. Set the domain's nameservers to ns1.your_domain and ns2.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.