Self-Hosted Bind 9 Dynamic DNS (DDNS) – How‑To

This guide shows how to run your own dynamic DNS using BIND 9, authenticated with TSIG and updated from a cron‑driven script on a Linux host. It includes two safe filesystem layouts, a production‑ready updater script, cron setup, and troubleshooting for common errors (like .jnl: create: permission denied).


Audience & Assumptions

  • You run an authoritative BIND 9 for your domain (example: my-domain.com).
  • You want to update a hostname (example: home.my-domain.com) to follow your changing public IP.
  • Your BIND server and the client host run Linux (Debian/Ubuntu examples shown, but adapt as needed).

1) Create a TSIG key on the BIND server

Generate a key that the client will use to authenticate dynamic updates.

sudo tsig-keygen -a hmac-sha256 ddns-home > /etc/bind/ddns-home.key
sudo chown root:bind /etc/bind/ddns-home.key
sudo chmod 640 /etc/bind/ddns-home.key

You will paste the secret from this file into the BIND config and use the same key file on the client.


2) Choose a writable zone file location

BIND must be able to create a journal file next to the zone (e.g., my-domain.com.zone.jnl) for dynamic updates.

sudo chown -R bind:bind /etc/bind/zones
sudo chmod 750 /etc/bind/zones

# Allow writes via AppArmor (Debian/Ubuntu)
echo '/etc/bind/zones/** rw,' | sudo tee -a /etc/apparmor.d/local/usr.sbin.named
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.named || sudo systemctl reload apparmor

If a stale or root‑owned .jnl exists, remove it so BIND can recreate it next to the zone.

sudo rm -f /etc/bind/zones/my-domain.com.zone.jnl 2>/dev/null || true

3) Configure the zone for dynamic updates

Edit your BIND config (e.g., named.conf.local). Replace the file path with the one you chose in Step 2.

key "ddns-home" {
  algorithm hmac-sha256;
  secret "PASTE_THE_SECRET_FROM_/etc/bind/ddns-home.key";
};

zone "my-domain.com" {
  type master;
  file "/etc/bind/my-domain.com.zone";
  // If you use DNSSEC inline signing, keep these (otherwise you may omit):
  inline-signing yes;
  auto-dnssec maintain;

  // Allow this key to update ONLY the records you intend (least privilege):
  update-policy {
    grant ddns-home name "home.my-domain.com" A AAAA;
  };
  // Alternative (broader): allow-update { key ddns-home; };
};

Reload/start BIND:

sudo systemctl start named || sudo systemctl start bind9
# or if it was running already
sudo rndc reload

Sanity check:

sudo named-checkconf -z

4) Install the updater script on your client (home network)

Create /usr/local/sbin/ddns-update.sh on the client machine that will detect the public IP and push an update via nsupdate + TSIG.

sudo tee /usr/local/sbin/ddns-update.sh > /dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

# ======= CONFIG =======
ZONE="my-domain.com"            # your DNS zone
HOST="home"                 # host label to update (FQDN = HOST.ZONE)
DNS_SERVER="ns0.my-domain.com"  # your authoritative BIND server (IP or name)
TTL=120                      # TTL for updated record(s)
KEYFILE="/root/ddns-home.key"   # TSIG key file (copy from server or generate separately)
STATE_DIR="/var/lib/ddns"   # cache last-seen IPs to avoid redundant updates
UPDATE_IPV4=true
UPDATE_IPV6=false            # set true if you want AAAA updates too
# =======================

FQDN="${HOST}.${ZONE}."
mkdir -p "${STATE_DIR}"

log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >&2; }

get_public_ipv4() {
  local ip
  ip="$(dig +time=3 +tries=1 +short myip.opendns.com @resolver1.opendns.com || true)"
  if [[ -z "$ip" ]]; then
    ip="$(dig +time=3 +tries=1 +short TXT CH whoami.cloudflare @1.1.1.1 2>/dev/null | tr -d '"' | awk '{print $1}' || true)"
  fi
  printf '%s' "$ip"
}

get_public_ipv6() {
  local ip
  ip="$(dig -6 +time=3 +tries=1 +short AAAA myip.opendns.com @resolver1.opendns.com || true)"
  if [[ -z "$ip" ]]; then
    ip="$(dig -6 +time=3 +tries=1 +short TXT CH whoami.cloudflare @2606:4700:4700::1111 2>/dev/null | tr -d '"' | awk '{print $1}' || true)"
  fi
  printf '%s' "$ip"
}

update_record() {
  local rtype="$1" new_ip="$2" state_file="$3"

  if [[ -z "$new_ip" ]]; then
    log "No ${rtype} address detected; skipping ${rtype} update."
    return 0
  fi

  local old_ip=""
  [[ -f "$state_file" ]] && old_ip="$(cat "$state_file" || true)"

  if [[ "$new_ip" == "$old_ip" && -n "$old_ip" ]]; then
    log "${rtype}: IP unchanged (${new_ip}); no update needed."
    return 0
  fi

  log "${rtype}: updating ${FQDN} -> ${new_ip}"
  nsupdate -k "$KEYFILE" <<NSU
server ${DNS_SERVER}
zone ${ZONE}
update delete ${FQDN} ${rtype}
update add ${FQDN} ${TTL} ${rtype} ${new_ip}
send
NSU

  printf '%s' "$new_ip" > "$state_file"
}

main() {
  [[ -r "$KEYFILE" ]] || { log "Key file not readable: $KEYFILE"; exit 1; }

  if [[ "${UPDATE_IPV4}" == true ]]; then
    ip4="$(get_public_ipv4)"
    update_record "A" "${ip4}" "${STATE_DIR}/current_ipv4"
  fi

  if [[ "${UPDATE_IPV6}" == true ]]; then
    ip6="$(get_public_ipv6)"
    update_record "AAAA" "${ip6}" "${STATE_DIR}/current_ipv6"
  fi
}

main "$@"
EOF

sudo chmod 750 /usr/local/sbin/ddns-update.sh
sudo mkdir -p /var/lib/ddns
sudo chown root:bind /var/lib/ddns

Key file on the client: copy /etc/bind/ddns-home.key from the server over a secure channel (or create a separate key with the same name/secret). Keep it root‑readable only.

# On the client
sudo scp root@my-domain.com:/etc/bind/ddns-home.key /root/ddns-home.key

5) Add a cron job

Run the updater every 5 minutes (common choice):

sudo crontab -e

Add:

*/5 * * * * /usr/local/sbin/ddns-update.sh

Updating multiple hostnames

Reuse the same script by overriding HOST via environment:

*/5 * * * * HOST=home /usr/local/sbin/ddns-update.sh
*/5 * * * * HOST=vpn  /usr/local/sbin/ddns-update.sh

Remember to authorize each name in the zone’s update-policy:

update-policy {
  grant ddns-home name "home.my-domain.com" A AAAA;
  grant ddns-home name "vpn.my-domain.com"  A AAAA;
};

6) Verification & initial run

  1. Validate BIND config and zones:
sudo named-checkconf -z
  1. Kick the updater manually and watch logs:
sudo /usr/local/sbin/ddns-update.sh
sudo journalctl -u bind9 -n 100 --no-pager   # or: sudo tail -n 100 /var/log/syslog

You should see lines indicating an authorized update and no journal open failed errors.

  1. Check the live record from a resolver (allow a short TTL):
dig +short A home.my-domain.com @ns0.my-domain.com

7) Troubleshooting

Error: ... .jnl: create: permission denied / journal open failed

  • Ensure the zone file’s directory and the zone file itself are writable by the bind user.
  • If using /etc/bind/..., add the AppArmor write rule and reload AppArmor.
  • Delete any stale .jnl next to the zone so BIND can recreate it.

Updates succeed but resolvers see old IP

  • Check TTL on the RR; if very low, upstream caches may still hold the old value briefly.
  • Verify you updated the authoritative server you publish at your NS records.

Script logs say “IP unchanged” but you expect a change

  • Confirm your WAN actually has a new public IP (some ISPs CGNAT or keep it stable).
  • The script intentionally suppresses redundant updates to reduce journal churn.

IPv6 updates don’t appear

  • Set UPDATE_IPV6=true and confirm your network has working v6 egress.
  • Verify update-policy grants AAAA updates for the name.

8) Security & operational tips

  • Least privilege: use update-policy to scope updates to specific names and RR types.
  • Protect TSIG secrets: key files should be readable only by the process/user that needs them.
  • Network exposure: if possible, point DNS_SERVER at a private/VPN‑reachable address and firewall off the update port from the Internet.
  • Reasonable TTLs: 60–300 seconds balances agility and cache thrash.
  • Monitoring: alert on repeated failed updates and on journal write errors.

You’re done. Your home.my-domain.com will now track your changing WAN IP, fully under your control, no third‑party DDNS service required.

About the Author

Jim Lucas

Owner and proprietor of this establishment

Leave a Reply

Your email address will not be published. Required fields are marked *