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).
.com).home.my-domain.com) to follow your changing public IP.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.
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
.jnlexists, 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
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
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.keyfrom 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
Run the updater every 5 minutes (common choice):
sudo crontab -e
Add:
*/5 * * * * /usr/local/sbin/ddns-update.sh
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;
};
sudo named-checkconf -z
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.
dig +short A home.my-domain.com @ns0.my-domain.com
... .jnl: create: permission denied / journal open failedbind user./etc/bind/..., add the AppArmor write rule and reload AppArmor..jnl next to the zone so BIND can recreate it.UPDATE_IPV6=true and confirm your network has working v6 egress.update-policy grants AAAA updates for the name.update-policy to scope updates to specific names and RR types.DNS_SERVER at a private/VPN‑reachable address and firewall off the update port from the Internet.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.