Shore Up
A clerk updating many address ledgers with the same correction, stamping each with a new version number, then checking every ledger against a checklist before filing them back on the shelf
Linux

Bulk-Update BIND Zone Files and Reload Safely with a Script

Ketan Aagja9 min read
No ratings yet

Before you run this

This script applies one literal find-and-replace across every zone file in a directory (for example, retiring an old IP or NS name), bumps each changed zone's SOA serial so slaves pick up the change, validates every touched zone, and reloads BIND only if all of them pass. If any zone fails validation it restores the backups and aborts before touching the running server.

It needs root — it writes into /etc/bind and calls rndc, so run it with sudo. It changes and, briefly, could disrupt DNS: an edited zone that reloads with a wrong record answers wrongly for everything that depends on it. The edits themselves are reversible — the script copies every zone file into a timestamped backup directory first, and I show the restore command at the end — but a bad answer served in the meantime is not something you can un-serve to a resolver that already cached it.

Read the script before you run it. Test it on a lab or hidden-master server, or point ZONE_DIR at a copy of your zone files first and inspect the result, before you run it against production. Keep the default DRY_RUN=1 for the first pass so it only shows you what it would change.

This assumes plain (unsigned) zones, or the unsigned source files of inline-signed zones. If you edit DNSSEC-signed zone files directly, or manage zones with dynamic updates (nsupdate), do not hand-edit them — those have their own tooling (nsdiff/nsupdate, rndc signing). Check the BIND ARM for those cases.

What I'm assuming

  • Debian 12 / Ubuntu 22.04, package bind9, utilities from bind9-utils (named-checkconf, named-checkzone in $PATH).

  • Zone files live in one directory, named db.<zone> — e.g. /etc/bind/zones/db.example.com. The script derives the zone name from that filename, so this convention matters.

  • Each zone's SOA serial is written in YYYYMMDDnn form on a line carrying a ; serial comment, which is the Debian default and the common convention:

                            2024031501      ; serial
    
  • rndc is already working (Debian sets up rndc.key on install; test with sudo rndc status).

If your service unit is named.service rather than the bind9.service alias, that only matters for start/stop — we reload through rndc, not systemd.

The script

Save this as bulk-zone-update.sh, edit the four settings at the top, and run it once with DRY_RUN=1 before flipping it to 0.

#!/usr/bin/env bash
#
# bulk-zone-update.sh — apply a literal find/replace across BIND zone files,
# bump each changed zone's SOA serial, validate, and reload only if all pass.
#
set -euo pipefail

### ---- edit these ----
ZONE_DIR="/etc/bind/zones"     # directory holding your db.<zone> files
OLD="10.0.0.5"                 # text to find   (treated as a sed pattern)
NEW="10.0.0.6"                 # text to write  (treated as a sed replacement)
DRY_RUN=1                      # 1 = preview only; 0 = actually change + reload
### --------------------

BACKUP_DIR="/var/backups/bind-zones/$(date +%Y%m%d-%H%M%S)"
CHANGED=()

# db.example.com -> example.com
zone_name() { basename "$1" | sed -E 's/^db\.//'; }

bump_serial() {
  local file="$1" today current new
  today=$(date +%Y%m%d)
  # serial must be a 10-digit number on a line marked "; serial"
  current=$(grep -iE ';[[:space:]]*serial' "$file" | grep -oE '[0-9]{10}' | head -n1)
  if [ -z "$current" ]; then
    echo "  ! no 'NNNNNNNNNN ; serial' line in $file — bump it by hand" >&2
    return 1
  fi
  if [ "$current" -ge "${today}00" ]; then
    new=$((current + 1))            # already bumped today: just increment
  else
    new="${today}00"                # first change today
  fi
  sed -i -E "s/\b${current}\b([[:space:]]*;[[:space:]]*[Ss]erial)/${new}\1/" "$file"
  echo "  serial $current -> $new"
}

mkdir -p "$BACKUP_DIR"
cp -a "$ZONE_DIR"/db.* "$BACKUP_DIR"/
echo "Backup: $BACKUP_DIR"

# find every zone that contains OLD
for f in "$ZONE_DIR"/db.*; do
  [ -f "$f" ] || continue
  if grep -qF "$OLD" "$f"; then
    echo "Match in $(basename "$f"):"
    grep -nF "$OLD" "$f" | sed 's/^/    /'
    CHANGED+=("$f")
  fi
done

if [ "${#CHANGED[@]}" -eq 0 ]; then
  echo "No files contain '$OLD'. Nothing to do."; exit 0
fi

if [ "$DRY_RUN" -eq 1 ]; then
  echo "DRY RUN — nothing changed, BIND not reloaded."; exit 0
fi

# apply the change and bump serials
for f in "${CHANGED[@]}"; do
  echo "Updating $(basename "$f")"
  sed -i "s|${OLD}|${NEW}|g" "$f"
  bump_serial "$f"
done

# validate before touching the running server
echo "Checking named.conf..."
named-checkconf

for f in "${CHANGED[@]}"; do
  z=$(zone_name "$f")
  echo "  named-checkzone $z"
  if ! named-checkzone "$z" "$f" >/dev/null; then
    echo "VALIDATION FAILED for $z — restoring backups, NOT reloading." >&2
    cp -a "$BACKUP_DIR"/db.* "$ZONE_DIR"/
    exit 1
  fi
done

echo "All zones valid. Reloading BIND..."
rndc reload
echo "Done. Backup kept at $BACKUP_DIR"

Two honest cautions about the matching. OLD and NEW go straight into sed, so OLD is a regular expression and NEW is a replacement: an IP like 10.0.0.5 works because the literal dots also match dots, but if your search text contains ., /, &, or \, escape it or the change can over-match. When in doubt, keep DRY_RUN=1 and read what it reports before committing. The script only touches files that already contain OLD, so unrelated zones are left alone.

Verify it worked

First, confirm BIND actually loaded the new serial for a changed zone. rndc zonestatus shows the loaded serial and the last load time:

sudo rndc zonestatus example.com

Then query the local server directly and check both the record and the SOA serial:

dig @127.0.0.1 example.com SOA +short      # serial should be the new value
dig @127.0.0.1 host.example.com A +short   # should return NEW, not OLD

If anything looks wrong, check the log for load errors:

sudo journalctl -u named --since "5 min ago"   # or bind9.service, or /var/log/syslog

A silently unchanged answer usually means the serial didn't advance, so BIND kept the old copy — check that the ; serial line matched the pattern.

Undo

Because every zone file was copied first, rolling back is a file copy and a reload. Use the timestamped directory the script printed:

sudo cp -a /var/backups/bind-zones/YYYYMMDD-HHMMSS/db.* /etc/bind/zones/
sudo named-checkconf
sudo rndc reload

Replace YYYYMMDD-HHMMSS with your actual backup directory. Restoring reverts the serials too, which is fine on your master, but if slaves already pulled the newer (higher) serial they will not step backwards to a lower one — you would need to bump the serial forward again after restoring the old records. That's the one place a "rollback" isn't purely mechanical, so keep it in mind on a zone with live secondaries.

For the exact meaning of rndc reload, named-checkzone, and serial handling, see the BIND 9 Administrator Reference Manual (ISC's official documentation) rather than trusting any single flag from memory.

Written by
Ketan Aagja

Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.

More about the author →

Was this article helpful?

Tap a star — no sign-in needed.

Be the first to rate this article.