How To Configure A Bind9 Container To Support Dynamic Updates

In the video below, we show you how to configure a Bind9 Docker image to support Dynamic DNS
Dynamic Updates for DNS servers like Bind are very useful, particularly when you use DHCP because it allows you to access a device via its hostname and it makes no difference if the IP address changes
And it has uses beyond that as I found when deploying my own ACME server
Now I did post a video about how to configure Dynamic DNS for Bind for a stand alone server some time ago, but it’s a bit more tricky when you’re running Bind as a container because of file permissions
Useful links:
https://Bind.readthedocs.io/en/stable/chapter6.html#dynamic-update
https://Bind.readthedocs.io/en/stable/reference.html#namedconf-statement-update-policy
Assumptions:
Now because this video is specifically about configuring Dynamic Updates in Bind I’m going to assume you already have Bind running in a container
If not then I do have another video which shows you to create your own Docker image to run Bind in a container
What I won’t be covering, however, is how to configure an end device like a DHCP server because it depends on what that end device is
But I do have another video on how to configure Kea to support Dynamic DNS for instance
Zone File Changes:
For a long long time, DNS has involved just static zone files
The IP addressing and hostname rarely changed, so you configured a file which contains the DNS records and you gave the Bind user read access to them
The idea is, if the DNS service is ever compromised, the attacker can’t modify your DNS entries through that Bind user account
For operating systems like Debian, zone files are typically found in the /etc/bind folder
There, only root or someone with elevated privileges can make changes to a file
docker exec bind9 ls -l /etc | grep bind
But for Dynamic DNS to work, the Bind user needs to make changes to the zone file as well as to create and edit a journal file in the same folder
For that, it makes sense to use the /var/lib/bind folder where the Bind user already has write access
docker exec bind9 ls -l /var/lib | grep bind
Because if we allow write access in /etc/bind, the Bind user could potentially be able to modify the domains hosted on the server as well
I’m already logged in as the Docker user that manages containers
So first we’ll create a folder on the host so zone files can survive a container restart for instance
mkdir -p bind9/var/lib/bind
For me, this is in the user’s home directory and config and zone files for Bind are normally in bind9/etc/bind
So if you use different folders you’ll need to modify the commands being used
Now try as I might, I couldn’t come up with an easy and practical way to keep dynamic and static records separate
I don’t want to use subdomains because I don’t want a DNS server called ns1.homelab.lan and a web server called www.dynamic.homelab.lan for instance
But for security reasons, I don’t want Bind to be able to replace static host records
I spent a really long time looking into this, even getting feedback from the likes of ChatGPT and Gemini
But the problem was they kept coming up with solutions that didn’t work and it was only after mentioning that would they then explain why it didn’t work
It really makes you wonder what these things know, because if you know a solution won’t work, why suggest it in the first place?
In any case, based on the feedback, I’m going to have to stick with a single zone file for now, and at a later date, work on a way to get Ansible to update and maintain static entries in a different way
This is the same method I’ve been using with a standalone Bind server, but later we’ll need to address the permissions problems
First, we’ll move the original files
mv bind9/etc/bind/db.homelab.lan bind9/var/lib/bind
mv bind9/etc/bind/db.192.168 bind9/var/lib/bind
Then we’ll update the configuration file to point to the new locations
nano bind9/etc/bind/named.conf.local
So now it looks like this
zone "homelab.lan" {
type master;
file "/var/lib/bind/db.homelab.lan";
};
zone "168.192.in-addr.arpa" {
type master;
file "/var/lib/bind/db.192.168";
};
Now save and exit
Next, the volume mappings need to change and for me that means updating the compose file, but we’ll take a backup first
cp docker-compose.yml docker-compose.yml.bak
nano docker-compose.yml
So now the volume mappings looks like this
volumes:
- ./bind9/etc/bind/named.conf.local:/etc/bind/named.conf.local:ro
- ./bind9/etc/bind/named.conf.options:/etc/bind/named.conf.options:ro
- ./bind9/etc/resolv.conf:/etc/resolv.conf:ro
- ./bind9/var/lib/bind:/var/lib/bind
Now save and exit
The reason I’m mapping individual files that belong in /etc/bind is there are other files in the folder that I don’t want to manage
If the developers of Bind ever added a new file to /etc/bind or existing ones changed due to an update, I don’t need to concern myself with those because they’ll still be there
However, if I mapped the entire /etc/bind folder instead, a file like named.conf for instance would no longer be seen by the container if it doesn’t exist on the host and then Bind wouldn’t work
Now, because the Bind user will later need write access to the /var/lib/bind folder, it’s easier to map the whole folder as there’s nothing there to begin with
This is a major change, so we’ll stop and remove the container
docker compose down bind9
Then we’ll start it back up afresh
docker compose up -d
We’ll then check the container is still working
docker ps -l
Assuming Bind is up and running, we’ll check to make sure that DNS resolution is working
host ca.homelab.lan
host 192.168.102.12
New Container Image:
A big problem I’ve run into with containers like this is file permissions
We want files to persist a container restart, but the user inside the container needs write access
In this case, we’ve got a problem with the /var/lib/bind folder and the files within it
What we’ve done is to map the folder from the host to the container, and now the ownership of /var/lib/bind within the container has been overwritten
docker exec bind9 ls -l /var/lib | grep bind
What we’d see is the UID and GID of our host user which doesn’t exist in this container
More importantly though, Bind currently doesn’t have write access to this folder
There are various ways to deal with this, and up till now I’ve been aligning the UID and GID of the host and container users to resolve this
I’d prefer to have tighter security and I wasn’t having any success with using a shared group but I didn’t like this ID mapping strategy
So I had a conversation with ChatGPT to find a solution
First, we’ll create a new folder
mkdir images/bind9
cd images/bind9
Since I’ve been creating my own Bind image, this is the revised Dockerfile for this
nano dockerfile
FROM debian:latest
# Arguments for user and group names, as well as user and group IDs
ARG USER_NAME=binduser
ARG GROUP_NAME=bindgroup
ARG USER_ID=5001
ARG GROUP_ID=5001
# Install bind9 and acl
RUN apt update && \
apt install -y bind9 acl && \
apt clean && rm -rf /var/lib/apt/lists/*
# Create shared group and user for bind
RUN groupadd --gid ${GROUP_ID} ${GROUP_NAME} && \
useradd --uid ${USER_ID} --gid ${GROUP_ID} \
--home /nonexistent --shell /usr/sbin/nologin ${USER_NAME}
# Modify ownership and permissions for the new user account and group
RUN chown -R root:${GROUP_NAME} /var/cache/bind && \
chmod -R 2770 /var/cache/bind && \
chown -R root:${GROUP_NAME} /etc/bind && \
find /etc/bind -type f -exec chmod 640 {} \; && \
find /etc/bind -type d -exec chmod 2750 {} \;
# Set default ACLs so new files are always group writable
RUN setfacl -d -m g:bindgroup:rw /var/cache/bind
# Switch to non-root user
USER ${USER_NAME}
# Start named with umask enforced
CMD ["sh", "-c", "umask 002 && exec /usr/sbin/named -f"]
Now save and exit
We’ll leave the existing Bind user account and group alone and instead create our own
To make it easier to change these, we’ll define arguments for them
I’d prefer to avoid a clash of the UID and GID now and in the future, so we’ll define high numbers to assign to use
Then we’ll install the bind9 and acl packages
What’s interesting is that ChatGPT added an extra line to remove files in /var/lib/apt/lists/ in order to shrink the image size
As they say, you learn something new every day
Next we create a new group and user account to run Bind, but it doesn’t need a home folder and it doesn’t need to login via SSH
We then need to change the ownership of folders to match these
There was a lot of to-ing and fro-ing around permissions and what was actually necessary to change
For example, /var/lib/bind was in the proposed dockerfile I was given, but since it’s mapped through by Docker it wouldn’t achieve anything so I had it removed
The main problem I ran into in my earlier testing was file permissions weren’t working the way I’d intended for a shared group
What was suggested by ChatGPT was the use of chmod -R 2770, so that new files will inherit the group permissions of the folder because the setgid bit is set to 2
The setafcl command though is an extra step to set a default ACL for the folder to ensure any new file created in the folder has the correct group permissions
We want to avoid root access as much as possible so we’ll switch to the new user account
Even an interactive session will then have limited access
When the container does run, we want it to run in the foreground, to keep the container running
TIP: A container will simply run and then stop if you start a service or run a command in the background
One thing to note is the use of umask 002 which is to ensure that when Bind does run, new files are written with the correction permissions
Again, this is part of the reason why I had problems trying to get a shared group working myself
Without these extra changes, of setgid and umask, files were being given permissions of the user’s primary group which in turn had only read access
I now need to check what the next version should be
docker image ls | grep bind9
We’ll then create a new version of the image
docker build --network=host --no-cache -t bind9_image:0.3 .
TIP: It would be tempting to just build a newer latest version but if this doesn’t work you may find yourself without a working DNS server and then you may not be able to fix the problem because DNS is down and so you can’t rebuild the image
I’ve used the –network=host parameter as I want Docker to use internal DNS and not the default of Google’s DNS servers
In addition, I’ve also used –no-cache as sometimes caching causes issues
Once the build is complete, we’ll update the compose file to use this newer version
cd
nano docker-compose.yml
...
bind9:
# image: bind9_image:latest
image: bind9_image:0.3
...
Now save and exit
I’ve commented out the latest version to make it a bit easier to swap between images during the testing phase
Host Permissions:
For this to actually work, we need to make some changes to file ownership and permissions on the host computer so I’ll switch to a user with sudo rights
This is because the user account I use with Docker was made a member of the docker group so that I don’t have to keep using sudo
Although granted, non-root accounts make more sense when you’re using Podman
In any case, we’ll create a new group on the host to match the one we created in the container, set the permissions and add the Docker user to the group
sudo groupadd -g 5001 bindgroup
sudo chown -R dockermgr:bindgroup /home/dockermgr/bind9/var/lib/bind
sudo chmod -R 2774 /home/dockermgr/bind9/var/lib/bind
sudo usermod -aG bindgroup dockermgr
The reason others will have read access incidentally is so backups can be done by another user
The only problem is we need to maintain permissions for files, so we’ll use ACLs for this
sudo apt install acl
sudo setfacl -d -m g::rw /home/dockermgr/bind9/var/lib/bind
sudo setfacl -d -m o::r /home/dockermgr/bind9/var/lib/bind
For the changes to take effect on the host, we’ll have to exit out as the Docker user and then log back in
For the container itself, we need to stop and restart the Bind container
docker compose down bind9
docker compose up -d
We’ll then check the container is still working
docker ps -l
Again, we’ll also check DNS resolution is working
host pbs.homelab.lan
host 192.168.102.30
And to make sure the permissions are working, we’ll create a new file within the container
docker exec bind9 touch /var/lib/bind/test.txt
Assuming we don’t get an error, the file should also show up in the host
ls -l bind9/var/lib/bind
Assuming that worked, we’ll remove the file using the Docker user
rm bind9/var/lib/bind/test.txt
So both users now have write access
Enable Dynamic Updates:
Dynamic Updates or Dynamic DNS involves allowing computers to make changes to DNS records
But we don’t want just any device being able to make changes, so we’ll create a key or passphrase to restrict this ability
TIP: The Bind developers removed IP address restrictions because the source IP address could potentially be spoofed
Now depending on your environment you could create one key and give it to all your servers
Or you could give servers their own individual keys
The choice comes down to, if a server gets compromised, how many servers will you need to update with a new key and how long will it take to do this?
For DHCP servers for instance, you may only have two servers and since they’ll have the same access, it may not matter so much if they used the same key
Of course, if you have an automated way to push keys out to individual servers, I guess it wouldn’t take much time to replace all of the keys on all the servers
As an example though, I’ll create one key for DHCP servers to use
For Debian, the command we need is installed as part of the Bind installation
In which case, to create a key we’ll run this command through the container
docker exec bind9 tsig-keygen -a hmac-sha256 dhcp.key >> bind9/etc/bind/dns.keys.conf
The output would normally be something like this but I’ve created a file to store keys in called dns.keys.conf
key "dhcp.key" {
algorithm hmac-sha256;
secret "qggo3scR2I3qvllD4ts5qOsgve52hZZnSQcyKsZrVtA=";
};
TIP: Although the documentation mentions using an FQDN for the key name, it doesn’t seem to be necessary Having said that, an FQDN would make management easier if you’re running multiple domains, each with a separate key
For security reasons and to avoid system errors, we’ll restrict access to this file from an account with sudo rights
sudo chown dockermgr:bindgroup /home/dockermgr/bind9/etc/bind/dns.keys.conf
sudo chmod 640 /home/dockermgr/bind9/etc/bind/dns.keys.conf
Now this does throw up a conundrum because earlier we allowed others to have read access of the zone files so they could be backed up
We can’t do that with this file though because Bind will complain, and also because it contains more sensitive information
And while we could make the backup user a member of bindgroup, that would give the backup user write access to the zone files
For me, one option is to encrypt this file and make that encrypted file readable so it can be backed up
But my goal is to let Ansible maintain all this anyway, so that I only need to keep the Ansible computer backed up
The Bind user will need to be able to read this file, so back as the Docker user, we’ll add an extra mapping in our Docker Compose file
nano docker-compose.yml
...
- ./bind9/etc/bind/dns.keys.conf:/etc/bind/dns.keys.conf:ro
...
Now save and exit
Next, we’ll backup the existing configuration file
cp bind9/etc/bind/named.conf.local bind9/etc/bind/named.conf.local.bak
TIP: I ran into quite a few issues myself due to typos for instance, so this makes it much easier to revert back to a working file then try again
Now we’ll update the forward and reverse lookup zones so that it looks like this
nano bind9/etc/bind/named.conf.local
include "/etc/bind/dns.keys.conf";
zone "homelab.lan"
type master;
file "/var/lib/bind/db.homelab.lan";
update-policy { grant dhcp.key wildcard *.homelab.lan A DHCID; };
};
zone "168.192.in-addr.arpa" {
type master;
file "/var/lib/bind/db.192.168";
update-policy { grant dhcp.key wildcard *.168.192.in-addr.arpa PTR DHCID; };
};
Now save and exit
At the top of the config file I’ve added a line to include the file containing the key
Going forward, any more keys would be added to that same dns.keys.conf file
I’ve also added a line within each zone to allow Dynamic DNS updates for devices with that key
This is standard practice because you can hand over the config file for support for instance without revealing any secrets
For a DHCP server, I only expected this would involve A records in the forward lookup zone and PTR records in the reverse lookup zone
However, more modern DHCP servers also maintain DHCID records to track client devices
This is to protect an existing leasing from being overwritten
We’ll now stop and start the container again for these changes take effect
docker compose down bind9
docker compose up -d
Then double check the container is running
docker ps -l
Once again, we’ll double check that DNS is still working
host testpve.homelab.lan
host 192.168.102.11
NSUPDATE:
Before you move on to configuring a DHCP server for instance, it’s best to test that Dynamic Updates work
Otherwise, if your DHCP server for instance can’t update Bind, you would have to work out if it’s a problem with the Bind server or the DHCP server
To do this we’ll use the nsupdate command
First I’ll switch to a user with sudo rights
Then we’ll make sure the dnsutils package is installed
sudo apt update
sudo apt install dnsutils -y
Now I’ll switch back to our Docker user
There are different ways to use nsupdate, but in these examples we’ll create an instruction file
nano nsupdate.txt
server testdns1.homelab.lan
update delete ddnstest.homelab.lan. A
update add ddnstest.homelab.lan. 600 A 192.168.250.250
show
send
Now save and exit
First we select the DNS server that has a writable version of the database
We’ll delete an existing A record, if it exists
Then create an A record to test against
We’ll then display the changes to be applied and then apply them
Now we can test if DDNS works
nsupdate -k bind9/etc/bind/dns.keys.conf -v nsupdate.txt
NOTE: At this point in time there’s only one key in the key file so I can reference this with the -k parameter. Otherwise I’d need a separate file. Unfortunately, there’s no way that I know of to reference a particular key in a file
The -v parameter is to use TCP instead of the default option which is UDP. If a request is too big it can be a problem for UDP, so it’s easier to just stick with TCP
Assuming there are no errors returned, a DNS lookup should return details about this new record
dig -t A ddnstest.homelab.lan.
Expect to see a status of NOERROR as well as the A record in the ANSWER SECTION
This record will probably not show up immediately in the zone file though
cat bind9/var/lib/bind/db.homelab.lan
This is because Bind uses a journal to track and later update changes made to a zone file, similar to a database server
ls -l bind9/var/lib/bind/
Assuming there are no problems, we’ll tidy things up by removing the record
nano nsupdate.txt
server testdns1.homelab.lan
update delete ddnstest.homelab.lan. A
show
send
Now save and exit
Then we’ll apply the change
nsupdate -k bind9/etc/bind/dns.keys.conf -v nsupdate.txt
Now we shouldn’t be able to find this record
dig -t A ddnstest.homelab.lan.
Finally we’ll remove this temporary file
rm nsupdate.txt
NOTE: Going forward, dynamic changes will be being made to DNS and you can run into conflicts because there are also static entries to manage
There are two approaches to handling this
One is to pause Dynamic Updates
docker exec bind9 rndc freeze
Then you can manually update the zone file(s) with the record(s) you want to add, remove or modify but you must remember to increment the serial number as well
nano bind9/var/lib/bind/db.homelab.lan
Then you can re-enable updates
docker exec bind9 rndc thaw
But since the last video I released for Dynamic Updates, I’ve read that the better approach is to use nsupdate for your own static IP records
For one thing, you aren’t pausing Dynamic Updates and you won’t forget to increment the serial number
In which case, if you plan to do that, I think it would make sense to create a separate key for an Administrator to use
Static Records:
My main concern with this is that static records can be changed through Dynamic Updates, accidentally or maliciously
That’s why I’ve been looking for a way to separate static and dynamic records
Basically, Bind loads everything into memory and at that point changes can be made if you have the right key
For example, let’s resolve the IP address for a record I manually added to the zone file
host test2.homelab.lan
Next, we’ll use nsupdate to change this, so first we’ll create a file to use
nano nsupdate.txt
server testdns1.homelab.lan
update delete test2.homelab.lan. A
update add test2.homelab.lan. 600 A 192.168.250.220
show
send
Now save and exit
Then we’ll apply the change
nsupdate -k bind9/etc/bind/dns.keys.conf -v nsupdate.txt
Now as you’ll see this hostname is resolving to a different IP address
host test2.homelab.lan
In which case I’ll revert this back
rm nsupdate.txt
nano nsupdate.txt
server testdns1.homelab.lan
update delete test2.homelab.lan. A
update add test2.homelab.lan. 600 A 192.168.102.101
show
send
Now save and exit
Then we’ll apply the change
nsupdate -k bind9/etc/bind/dns.keys.conf -v nsupdate.txt
And we’ll double check we resolve to the correct IP address
host test2.homelab.lan
To protect these type of records you can deny dynamic changes to them
First though, we’ll backup the existing configuration file
cp bind9/etc/bind/named.conf.local bind9/etc/bind/named.conf.local.bak
For example, to prevent the record for pvedemo1.homelab.lan from being changed we would edit the named.conf.local file and modify the update-policy rules
nano bind9/etc/bind/named.conf.local
zone "homelab.lan" {
type master;
file "/var/lib/bind/db.homelab.lan";
update-policy {
deny * name pvedemo1.homelab.lan;
grant dhcp.key wildcard *.homelab.lan A DHCID;
};
};
Now save and exit
NOTE: The ordering of policies is important. Like a firewall, Bind will stop at the first match found, so this deny rule has to precede the grant rule
For the change to take effect we’ll need to restart the container
docker compose restart bind9
Then double check the container is running
docker ps -l
Now we’ll try to change that record
rm nsupdate.txt
nano nsupdate.txt
server testdns1.homelab.lan
update delete pvedemo1.homelab.lan. A
update add pvedemo1.homelab.lan. 600 A 192.168.250.220
show
send
Now save and exit
Then we’ll try and apply the change
nsupdate -k bind9/etc/bind/dns.keys.conf -v nsupdate.txt
What we should see is that the update is refused
In which case, we’ll remove this temporary file
rm nsupdate.txt
I must admit, this is going to mean more admin, which is another reason to use Ansible
Security is important and it’s all about layers and this would protect against unwanted changes that could have major consequences
What it won’t do is prevent a compromised Bind server rewriting the original zone file though
This makes regular patching and restricting access all the more important
Versioning:
Since we look to have a working image for Bind, I need to roll out a new latest version
cd images/bind9
docker build --network=host --no-cache -t bind9_image:latest .
And then update the compose file
cd
nano docker-compose.yml
...
bind9:
image: bind9_image:latest
...
Now save and exit
For the changes to take effect, we need to stop and restart the Bind container
docker compose down bind9
docker compose up -d
We’ll then check the container is still working
docker ps -l
It’s actually the same image, but we should still check DNS resolution is working all the same
host test2.homelab.lan
host 192.168.102.32
host www.microsoft.com
Assuming there are no issues, Bind is now ready to use for Dynamic Updates
For me, my DHCP servers will need updating to support this
How you configure that depends on what DHCP server you’re using and I can’t cover all of those in this video
But if you’re interested in how to configure Kea for instance, check out my other video as the process is the same whether Kea is a stand alone server or in a container
In this example, I’ve set up a key for a DHCP server, but I’d also want one one for an Admin user, for servers using ACME, for Ansible and so on
Once those keys are created, it would be a matter of updating the policies and restarting the container
Summary:
Running services like Bind in containers does present extra challenges, in this case file permissions
But containers need much less compute power and resources which for me saves on capital and running costs
And now I have a Bind container that supports Dynamic Updates, it can be used to support services like Dynamic DNS and ACME DNS challenges
Sharing is caring!