Infrastructure-as-Code with Proxmox: Dynamic DNS (DDNS)

In this module, we will install and configure BIND9 as an authoritative server for an internal DNS zone. We will then generate an authentication key for pfSense to dynamically update DNS records for DHCP clients in select VLANs.
In: Proxmox, Dynamic DNS, DDNS, BIND9, Home Lab, Infrastrucute-as-Code, DevSecOps, Automation
ℹ️
This page is part of a larger series on learning Infrastructure-as-Code (IaC) using Proxmox Virtual Environment. Click here to be taken back to the project home page.

Previous Step

Infrastructure-as-Code with Proxmox: Manual Templating
In this module, we will hand-build a VM and LXC template in Proxmox VE that we will later clone and use as a base image for additional servers in our lab while we work our way up to deploying with Infrastructure-as-Code.



How DDNS Works in the Lab


Click here to view this diagram in a new tab



Staging the Environment

⚠️
It goes without saying, but this is unique to my environment due to the fact that I'm using a pfSense firewall — let alone the custom VLAN configurations. I'm putting this here in hopes you may find a similar solution that suits your environment.

Adding the VLAN for BIND

Add the VLAN to pfSense

Add a VLAN with the LAN interface as the parent
Add the interface in Interface > Assignments



Configure the Interface

Using a small CIDR block since I only plan on running one BIND server



Configure DHCP

⚠️
ISC DHCP backend has been deprecated. There's no word when it will be removed from pfSense. As of this writing — March 6, 2026 — Kea DHCP backend does not support Dynamic DNS registration of DHCP leases in the GUI.

The server will boot up and grab the only available DHCP address



Configure Firewall Rules

Interface-Specific Rules

Select the interface name of the VLAN to configure interface-specific rules.

Firewall rules for subnet allow internet access only
⚠️
One thing we haven't done here is create rules to allow SSH login to the BIND server. If you intend to manage BIND over SSH, ensure you allow the traffic from your desired subnets to tcp/22 on your BIND server.



Configure Managed Switch(es)

  1. Log into managed switch(es)
  2. Add VLAN 53 to 802.1q VLANs
  3. Tag each interface where VLAN 53 is expected to flow across the wire



Configure Proxmox Networking

Classic Networking

⚠️
Warning!
Assumes you're using OVS Bridge
If you do it this way, you must create this VLAN on each PVE node.
  1. Log into Proxmox VE
  2. Select a PVE node
  3. Click Network > Create > OVS IntPort
This is effectively the same as tagging your trunk ports on your managed switch



SDN

Add VLAN Zone
  1. Log into Proxmox VE
  2. Select Datacenter > SDN
  3. Select Zones
Add > VLAN
Fill out accordingly... ensures VLANs will trunk up from vmbr0 to managed switch



Add VLAN Tag
  1. Log into Proxmox VE
  2. Select Datacenter > SDN
  3. Select VNets
Click "Create"
Fill out accordingly
Go to SDN > Click "Apply"



Add a DNS Record for BIND Server

"bind.ns.home.internal" will point to the new BIND server



Install and Configure BIND9

Create the Linux Container

  1. Log into Proxmox VE
  2. Clone the Debian 13 template Linux Container created previously
  3. Migrate the clone to your desired PVE node
Add some additional tags after cloning for automation
Resource changes
Network configs (put on desired VLAN, and DHCP-enabled with DHCP reservation)
DNS updates
You may now start the container.



Install BIND9

apt update && apt install -y bind9 bind9utils bind9-doc
systemctl enable named



Generate Dynamic DNS Key

This key will be used by the pfSense DHCP service to add / update / remove DNS records in specific home.internal subdomains as clients come online. For this to work, you'll need to note:

  • Key name
  • Secret
tsig-keygen -a HMAC-SHA256 pfsense-key > /etc/bind/pfsense.key

Key name: "pfsense-key", Secret is stored in "/etc/bind/pfsense.key"

chown root:bind /etc/bind/pfsense.key
chmod 640 /etc/bind/pfsense.key



Configure BIND

Options

nano /etc/bind/named.conf.options
acl "trusted_net" {
        127.0.0.0/8;    # Allow to self
        10.53.53.2/32;  # Allow self
        10.53.53.1/32;  # Allow queries from pfSense for domain overrides
};

options {
        directory "/var/cache/bind";

        recursion yes;
        allow-query { trusted_net; };

        # Forward unknown to gateway
        forwarders {
                10.53.53.1;
        };

        dnssec-validation auto;
        listen-on-v6 { none; };
};

Referencing the diagram:

  • A host asks the default gateway (pfSense) for test.lab.home.internal
  • pfSense observes domain override for lab.home.internal
  • pfSense asks 10.53.53.2 for the IP address of lab.home.internal
  • pfSense relays back to host

Therefore, adding 10.53.53.1 in the ACL is sufficient for this to work.

  • 10.53.53.2 is set as the lookup server for lab.home.internal
  • pfSense has 10.53.53.0/30 in its routing table on igb1.53
  • pfSense will always send packets destined for 10.53.53.2 out that interface, which is configured with 10.53.53.1



Zones and Zone Files

nano /root/generate-zones.sh

For the sake of this tutorial, I'm focusing on the internal domain of lab.home.internal. If you'd like to add dynamic DDNS to other subnets in your home lab, feel free to add them to the ZONES list in the script.

⚠️
Typically in a production environment with potentially hundreds to thousands of records, you would not want to set such a low TTL to something as 60. This would create a lot of compute and network overhead.

A TTL of 300 would still be considered very short, but more reasonable. But for the sake of our home lab, 60 is fine, as we likely won't have that many records and very few clients actually making DNS queries.
#!/bin/bash

# Configuration
LOCAL_DOMAIN="home.internal"
BIND_DIR="/etc/bind"
ZONE_DIR="/var/lib/bind"
TSIG_KEY_FILE="$BIND_DIR/pfsense.key"
NS_IP="10.53.53.2"
ADMIN_EMAIL="administrator.${LOCAL_DOMAIN}."
SERIAL=$(date +%Y%m%d01)
TTL=60
NUM_MIN=$(($TTL/60))

if [ "$NUM_MIN" -gt 1 ] ; then
    MIN_QUANTIFIER="minutes"
else
    MIN_QUANTIFIER="minute"
fi

# Forward Zones
ZONES=(
    "example1.${LOCAL_DOMAIN}"
    "example2.${LOCAL_DOMAIN}"
)

# Zone Files
# First-time config, initialze file with include directive
if ! [ -f "$BIND_DIR/named.conf.local" ] ; then
    echo "include \"$TSIG_KEY_FILE\";" > "$BIND_DIR/named.conf.local"
fi

# Append each zone to the local config
for ZONE in "${ZONES[@]}"; do

    cat << EOF >> "$BIND_DIR/named.conf.local"

zone "$ZONE" {
    type master;
    file "$ZONE_DIR/db.$ZONE";
    allow-update { key "pfsense-key"; };
    allow-query { "trusted_net"; };
};
EOF

# Zone Databases
    cat <<EOF > "$ZONE_DIR/db.$ZONE"
\$ORIGIN .
\$TTL $TTL ; $NUM_MIN $MIN_QUANTIFIER
$ZONE       IN SOA  ns1.$ZONE. $ADMIN_EMAIL (
                                $SERIAL ; serial
                                $TTL         ; refresh
                                $TTL         ; retry
                                $TTL         ; expire
                                $TTL         ; minimum
                                )
                        NS      ns1.$ZONE.

\$ORIGIN $ZONE.
ns1                     A       $NS_IP
EOF

done

chown -R bind:bind "$ZONE_DIR"
chmod 750 "$ZONE_DIR"
chmod -R 640 "$ZONE_DIR/*.jnl" 2>/dev/null
bash /root/generate-zones.sh
ℹ️
You can verify successful run by inspecting the output of cat /etc/bind/named.conf.local and ls -l /var/lib/bind/.



Configure pfSense

DHCP

⚠️
ISC DHCP backend has been deprecated. There's no word when it will be removed from pfSense. As of this writing — March 6, 2026 — Kea DHCP backend does not support Dynamic DNS registration of DHCP leases.

Internal Domains

Selected the "IAC_DEPLOY" interface, which will be used in Terraform testing
Set the internal domain as desired on the target VLAN
This demonstration just focuses on adding dynamic DNS to one subnet. You could add internal domains to multiple subnets in pfSense and as long as you have a zone in BIND, you can enable dynamic DNS on those subnets as well.

For example, you might have a cameras VLAN where you want to add the cameras.home.internal domain. You'd have to add that zone to BIND and configure Dynamic DNS as shown just below.



Dynamic DNS

  1. Log into pfSense
  2. Click Services > DHCP Server
ℹ️
Select any interface that will be assigning the lab.home.internal domain and add the configuration as shown below.
Click "Display Advanced"
Use the key found in "/etc/bind/pfsense.key"



DNS Resolver

Add the Private Domain

  1. Log into pfSense
  2. Click Services > DNS Resolver
We want to set some custom options here
⚠️
Mentioning it once again. If you want to add additional internal domains, ensure you create the zone in BIND and configure the DHCP settings accordingly in pfSense.
server:
    private-domain: home.internal
    local-zone: "lab.home.internal." transparent
    domain-insecure: home.internal
  • private-domain — indicates this domain returns private IP addresses
  • local-zone — tells unbound not to attempt outside lookup
  • domain-insecure — tells unbound to ignore DNSSEC failures for local, private domain

Click Save and Apply Changes.



Add the Domain Override

  1. Log into pfSense
  2. Click Services > DNS Resolver
  3. Scroll down to the bottom to Domain Overrides
Click "Add"
Example of domain overrides
⚠️
As pointed out earlier, with any domain override, you'll need to make sure that traffic is allowed from selected hosts / networks to the IP address of the name server(s) defined here.



Testing Dynamic DNS

Create a container with hostname "test-ct"
Put it on VLAN 302, the "IAC_DEPLOY" subnet configured in the prior steps with "lab.home.internal"
Container is started and pulled an IP in the correct subnet
Perfect!



Adding Static IPs to Dynamic DNS

Problem

There are some instances where a host has been set with a static IP configuration. One such example is my Proxmox VE nodes. In my case, the following is true:

  • Proxmox VE has been set with a static IP address on vmbr0_mgmt, which is an OVS IntPort
  • I've logged into pfSense and added a DHCP reservation for those addresses that are statically configured, so that DHCP won't hand them out
  • The Proxmox VE nodes are members of a VLAN configured with lab.home.internal and this VLAN has been configured with DHCP Dynamic DNS
ℹ️
The Proxmox VE nodes will never reach out to the DHCP server on pfSense, though, as they are statically configured. So, their IP addresses will never be added to BIND.



Solution

  1. SSH or pct enter into the BIND server
  2. Use some configuration file and cron trickery to add the records
nano /etc/bind/static-records.txt
server 127.0.0.1
debug yes

; Update Lab Zone
zone lab.home.internal.
update add proxmox.lab.home.internal. 60 A 172.16.100.6
update add proxmox-hx90.lab.home.internal. 60 A 172.16.100.12
update add proxmox-um690s.lab.home.internal. 60 A 172.16.100.15
update add proxmox-um690.lab.home.internal. 60 A 172.16.100.14
show
send

Create a "nsupdate" file to add the static IPs

/usr/bin/nsupdate -k /etc/bind/pfsense.key -v /etc/bind/static-records.txt

Use the DDNS key and the nsupdate file to add the records

crontab -e
# Dynamic DNS update to add static IP configurations
# Run every twelve hours to keep in BIND cache
0 */12 * * * /usr/bin/nsupdate -k /etc/bind/pfsense.key -v /etc/bind/static-records.txt



Overriding pfSense DDNS TTL

By default pfSense will push DHCP Dynamic DNS records with a TTL of 3600 — or 1 hour — and BIND will accept the TTL pushed by the DDNS client. In my testing, this had caused a problem with a stale record.

Synopsis of the problem

  1. You build a VM / LXC with a specific hostname and its DHCP address gets pushed by Dynamic DNS – say test.lab.home.internal.
  2. A host on the network queries test.lab.home.internal and Unbound returns the record from BIND. It's now cached with a TTL of 3600.
  3. A few minutes later, you give the host a static IP configuration at a different IP address and refresh its DHCP lease, which is pushed to BIND.
  4. Unbound still has the old record cached, because the TTL has not yet lapsed, so the new IP address of this host is not returned.
ssh admin@pfsense

SSH into pfSense

nano /etc/inc/services.inc
2924                 if (isset($dhcpifconf['ddnsupdate'])) {
2925                         $need_ddns_updates = true;
2926                         $newzone = array();
2927                         if ($dhcpifconf['ddnsdomain'] <> "") {
2928                                 $newzone['domain-name'] = $dhcpifconf['ddnsdomain'];
2929                                 $dnscfg .= "    ddns-domainname \"{$dhcpifconf['ddnsdomain']}\";\n";
2930                         } else {
2931                                 $newzone['domain-name'] = config_get_path('system/domain');
2932                         }

Before

2924                 if (isset($dhcpifconf['ddnsupdate'])) {
2925                         $need_ddns_updates = true;
2926                         $newzone = array();
2927                         if ($dhcpifconf['ddnsdomain'] <> "") {
2928                                 $newzone['domain-name'] = $dhcpifconf['ddnsdomain'];
2929                                 $dnscfg .= "    ddns-domainname \"{$dhcpifconf['ddnsdomain']}\";\n";
2930                                 $dnscfg .= "    ddns-ttl 60;\n";
2931                         } else {
2932                                 $newzone['domain-name'] = config_get_path('system/domain');
2933                         }

After: Adds "ddns-ttl 60;" on line 2930

🚨
Log into pfSense and restart the DHCP Service and the DNS Resolver.

Note: that this change may be overwritten by system upgrades, at which point, you'll need to reconfigure the service with the changes above.
Showing the TTL rolling over back to 60
Now, any time a DHCP-enabled host comes online in a subnet where Dynamic DNS registration is enabled, they will be registered with a shorter TTL, covering for any IP address changes.



Next Step

Infrastructure-as-Code with Proxmox: Public Key Infrastructure (PKI)
In this module, we will install two Smallstep CA instances, an offline Root CA and an online intermediate CA. The Intermediate CA will also serve as an ACME provisioner for clients in select VLANs.
Comments
More from 0xBEN
Infrastructure-as-Code with Proxmox
Proxmox

Infrastructure-as-Code with Proxmox

In this project, broken up into multiple modules, you will gain hands-on, interactive practice with defining and managing Infrastructure-as-Code using industry-standard DevSecOps tooling and zero-trust security principles.
Table of Contents
Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to 0xBEN.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.