28 Dec 2025 - DNS
Tags: Copilot | nsupdate | TSIG
When I set out to solve the problem of frequent public IP address changes caused by short power cuts in my countryside homelab, I knew I needed a solution that was both secure and flexible. My existing approaches, like DuckDNS and paid dynamic DNS services, didn’t quite fit what I really needed, I wanted full control over my DNS records and to leverage my own domain name and DNS server.
Using Technitium DNS as my authoritative name server solution, I explored how to securely update DNS records remotely. That’s when I discovered TSIG (Transaction SIGnature) keys — a method to authenticate DNS update requests cryptographically. This meant I could automate updates without exposing my DNS server to unauthorized changes.
The core of the solution is an updater script running on an Ubuntu VM in my homelab. This script periodically fetches my current public IP address from a reliable external service (like api.ipify.org) and compares it to the last known IP. If the IP has changed, the script constructs a single atomic DNS update transaction using nsupdate with the TSIG key for authentication.
Why this approach? Using nsupdate with TSIG keys allows me to securely send dynamic DNS updates directly to my Technitium DNS server without relying on third-party services. The script deletes existing A records for all relevant hostnames and adds new ones with the updated IP, all in one transaction. This atomic update ensures consistency and avoids partial updates that could cause DNS resolution issues.
The script also includes robust error handling and retry logic. If the update fails, it retries with exponential backoff, logging all activity for troubleshooting. This reliability is crucial because DNS updates must succeed to maintain external access to my homelab services like Home Assistant and game servers.
One technical nuance I addressed was normalising hostnames into fully qualified domain names with trailing dots, ensuring compatibility with DNS standards and avoiding ambiguous record updates. I also set a reasonable TTL (600 seconds) to balance between DNS caching and update responsiveness.
Running the script every five minutes via a cron job provides near real-time updates without overwhelming the DNS server or causing excessive DNS traffic. I also secured the TSIG key file with strict permissions and scoped it to only allow updates to my specific DNS zone, minimising security risks.
Throughout this project, I leaned on AI assistance from Copilot, which helped me troubleshoot errors, refine the script, and test updates. This collaboration accelerated my learning curve and made the process faster and somewhat more enjoyable.
This journey not only solved a practical problem but also deepened my understanding of DNS security and automation. It’s a great example of how personal projects in a homelab can push professional skills forward while addressing real-world challenges.
If you’re interested in dynamic DNS automation with your own DNS server, I encourage you to explore TSIG and nsupdate — it’s a powerful combination for secure, flexible DNS management.
There is another part to this journey but I’ll share that another time. I’ve included the original updater scripts I developed as a reference, so you can see the implementation details without me walking through every line here. The beta script was the original version which we enhanced after tesing, I settled on the alpha version.
#!/usr/bin/env bash
set -euo pipefail
# Configuration - edit if needed
KEYFILE="/etc/nsupdate/tsig.key"
SERVER="IP address"
ZONE="domain-name.com"
TTL=600
HOSTS=("www" "gate") # short labels; script will normalize to FQDNs
IP="$(curl -fsS https://api.ipify.org)" # fetch current public IP
BATCH="$(mktemp /tmp/nsupdate-add.XXXXXX)"
LOG="/tmp/nsupdate-add.log"
# Ensure tools exist
command -v nsupdate >/dev/null 2>&1 || { echo "nsupdate not found"; exit 1; }
command -v dig >/dev/null 2>&1 || { echo "dig not found"; exit 1; }
# Normalize hosts to FQDNs with trailing dot
FQDNS=()
for h in "${HOSTS[@]}"; do
if [[ "$h" == *"."* ]]; then
[[ "${h}" != *"." ]] && h="${h}."
FQDNS+=("$h")
else
FQDNS+=("${h}.${ZONE}.")
fi
done
# Build nsupdate batch (add all hosts in one transaction)
{
echo "server ${SERVER}"
echo "zone ${ZONE}"
for fq in "${FQDNS[@]}"; do
echo "update delete ${fq} A"
done
for fq in "${FQDNS[@]}"; do
echo "update add ${fq} ${TTL} A ${IP}"
done
echo "send"
} > "${BATCH}"
# Run nsupdate (debug output appended to log)
echo "Running nsupdate add at $(date -u +"%Y-%m-%dT%H:%M:%SZ")" | tee "${LOG}"
nsupdate -d -k "${KEYFILE}" "${BATCH}" 2>&1 | tee -a "${LOG}"
# Verify with dig against authoritative server
echo "" | tee -a "${LOG}"
for fq in "${FQDNS[@]}"; do
echo "Querying ${fq}" | tee -a "${LOG}"
dig @"${SERVER}" "${fq}" A +short | tee -a "${LOG}"
done
# Cleanup
rm -f "${BATCH}"
echo "Done. Log: ${LOG}"
#!/usr/bin/env bash
set -euo pipefail
# Configuration - edit if needed
KEYFILE="/etc/nsupdate/tsig.key"
SERVER="IP address"
ZONE="domain-name.com"
TTL=600
# Use short labels, FQDNs, or "@" to indicate the apex
HOSTS_RAW=("www" "gate" "@")
PUBLIC_IP_URL="https://api.ipify.org"
# Tools
command -v nsupdate >/dev/null 2>&1 || { echo "nsupdate not found"; exit 1; }
command -v dig >/dev/null 2>&1 || { echo "dig not found"; exit 1; }
# Normalize hosts into fully qualified names with trailing dot
HOSTS=()
for H in "${HOSTS_RAW[@]}"; do
if [ "$H" = "@" ]; then
HOSTS+=("${ZONE}.")
elif [ "$H" = "$ZONE" ] || [ "$H" = "${ZONE}." ]; then
HOSTS+=("${ZONE}.")
elif [[ "$H" == *"."* ]]; then
[[ "${H}" != *"." ]] && H="${H}."
HOSTS+=("${H}")
else
HOSTS+=("${H}.${ZONE}.")
fi
done
# Ensure last-ip file directory exists
LAST_IP_FILE="/var/run/current_wan_ip"
mkdir -p "$(dirname "$LAST_IP_FILE")"
# Temp batch file and cleanup
BATCH="$(mktemp /tmp/nsupdate-batch.XXXXXX)"
cleanup() {
rm -f "${BATCH}" || true
}
trap cleanup EXIT
# Get current public IP
IP=$(curl -fsS "${PUBLIC_IP_URL}" || true)
if [ -z "$IP" ]; then
echo "Failed to fetch public IP"
exit 1
fi
# Validate IPv4
if ! printf '%s\n' "$IP" | grep -E -q '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; then
echo "Invalid IP returned: $IP"
exit 1
fi
# Read last IP
OLD_IP=""
if [ -f "$LAST_IP_FILE" ]; then
OLD_IP=$(cat "$LAST_IP_FILE")
fi
if [ "$IP" = "$OLD_IP" ]; then
echo "No IP change (${IP}), nothing to do."
exit 0
fi
echo "$(date -u +'%Y-%m-%dT%H:%M:%SZ') IP changed: ${OLD_IP} -> ${IP}"
# Build nsupdate batch (atomic multi-update)
{
echo "server ${SERVER}"
echo "zone ${ZONE}"
for fq in "${HOSTS[@]}"; do
# delete any existing A records for the name
echo "update delete ${fq} A"
done
for fq in "${HOSTS[@]}"; do
echo "update add ${fq} ${TTL} A ${IP}"
done
echo "send"
} > "${BATCH}"
# Run nsupdate with TSIG keyfile and retries
LOG="/tmp/nsupdate-add-with-apex.log"
MAX_RETRIES=3
SLEEP=2
for i in $(seq 1 $MAX_RETRIES); do
if nsupdate -d -k "${KEYFILE}" "${BATCH}" 2>&1 | tee -a "${LOG}"; then
echo "${IP}" > "${LAST_IP_FILE}"
echo "Update successful" | tee -a "${LOG}"
# Verify each name
for fq in "${HOSTS[@]}"; do
echo "Querying ${fq}" | tee -a "${LOG}"
dig @"${SERVER}" "${fq}" A +short | tee -a "${LOG}"
done
exit 0
fi
echo "nsupdate attempt ${i} failed, sleeping ${SLEEP}s" | tee -a "${LOG}"
sleep $SLEEP
SLEEP=$((SLEEP * 2))
done
echo "nsupdate failed after ${MAX_RETRIES} attempts" | tee -a "${LOG}"
exit 2
Resources: ipify | Technitium DNS
I don't have comments on this site as they're difficult to manage and take up too much time. I'd rather concentrate on producing content than managing comments.
Since there are no comments, feel free to contact me instead.