Introduction
This guide provides a detailed walk-through for setting up a PowerDNS master-slave configuration on Debian 12, including installation, configuration, DNSSEC support, and implementation of automatic zone management and import scripts. This setup is ideal for managing multiple DNS zones with automatic replication between servers, efficient zone imports, and secure DNSSEC signing.
Pre-requisites
- SSH Access: Ensure passwordless SSH access from your master server (ns1) to your slave server (ns2). This is crucial for automated zone transfers and management. You can achieve this by setting up SSH keys.
- Network Connectivity: Verify that both servers (ns1 and ns2) can communicate with each other on the required ports (primarily port 53 for DNS and port 8081 for the API, if enabled). Use tools like ping and telnet or nc to test connectivity.
- Accurate Server IP Addresses: Have the correct IP addresses for both ns1 and ns2 readily available, as you'll need these for configuration.
Part 1: Installing PowerDNS
On both Master (ns1) and Slave (ns2) servers:
-
Update your system:
sudo apt update && sudo apt upgrade -y
-
Install PowerDNS and the MySQL backend:
sudo apt install pdns-server pdns-backend-mysql mariadb-server -y
-
Secure your MySQL installation:
sudo mysql_secure_installation
-
Create a database and user for PowerDNS:
sudo mysql -u root -p
In the MySQL prompt:
CREATE DATABASE pdns; CREATE USER 'pdns'@'localhost' IDENTIFIED BY 'your_secure_password'; GRANT ALL PRIVILEGES ON pdns.* TO 'pdns'@'localhost'; FLUSH PRIVILEGES; EXIT;
-
Import the PowerDNS schema:
sudo mysql -u root -p pdns < /usr/share/doc/pdns-backend-mysql/schema.mysql.sql
Part 2: Configuring PowerDNS
On the Master server (ns1):
-
Edit the PowerDNS configuration file:
sudo nano /etc/powerdns/pdns.conf
-
Add or modify these lines:
launch=gmysql gmysql-host=localhost gmysql-user=pdns gmysql-dbname=pdns gmysql-password=your_secure_password gmysql-dnssec=yes api=yes api-key=generate_a_secure_api_key webserver=yes webserver-port=8081 webserver-address=0.0.0.0 webserver-allow-from=127.0.0.1,ANOTHER_IP,ANOTHER_IP allow-axfr-ips=IP_OF_SLAVE_SERVER master=yes slave=no also-notify=IP_OF_SLAVE_SERVER
-
Here's how you might generate a secure API key:
openssl rand -base64 30
On the Slave server (ns2):
-
Edit the PowerDNS configuration file:
sudo nano /etc/powerdns/pdns.conf
-
Add or modify these lines:
launch=gmysql gmysql-host=localhost gmysql-user=pdns gmysql-dbname=pdns gmysql-password=your_secure_password gmysql-dnssec=yes slave=yes superslave=yes
-
Add the master server as a supermaster:
sudo mysql -u pdns -p pdns
In the MySQL prompt:
INSERT INTO supermasters (ip, nameserver, account) VALUES ('IP_OF_MASTER_SERVER', 'ns1.yourdomain.com', 'admin'); EXIT;
Part 3: Firewall Configuration
On both servers, configure the firewall to allow only necessary traffic from trusted sources. We will allow port 53 from anywhere since setup is for public Authoritative Nameservers:
# Allow DNS traffic
sudo ufw allow 53/tcp
sudo ufw allow 53/udp
# Allow API traffic on port 8081 (restricted to specific IPs)
# Replace 192.0.2.1 with the actual IP address that needs access
sudo ufw allow from 192.0.2.1 to any port 8081 proto tcp
# If multiple IPs need access, add each one separately
# sudo ufw allow from 10.0.0.1 to any port 8081 proto tcp
# Allow SSH access only from specific jumphost/trusted IPs
# Replace 198.51.100.5 with your jumphost/management server IP
sudo ufw allow from 198.51.100.5 to any port 22 proto tcp
# Deny all other SSH connections
sudo ufw deny 22/tcp
# Enable the firewall
sudo ufw enable
# Verify the rules
sudo ufw status verbose
Part 4: Starting PowerDNS
On both servers:
sudo systemctl start pdns
sudo systemctl enable pdns
Part 5: Testing the Setup
-
On the master server, create a test zone:
sudo pdnsutil create-zone example.com ns1.example.com sudo pdnsutil add-record example.com www A 192.0.2.1
-
Notify the slave:
sudo pdns_control notify example.com
-
On the slave, check if the zone transferred:
sudo pdnsutil list-zone example.com
Part 6: DNSSEC Configuration
Enabling DNSSEC for a zone (on Master server):
-
Secure a zone with DNSSEC:
sudo pdnsutil secure-zone example.com
-
CRITICAL: After securing a zone, you MUST increase the SOA serial for slave synchronization:
sudo pdnsutil increase-serial example.com sudo pdns_control notify example.com
-
Verify DNSSEC is working:
sudo pdnsutil show-zone example.com
-
Get DS records for parent zone delegation:
sudo pdnsutil export-zone-ds example.com
-
Test DNSSEC validation:
dig DNSKEY example.com @ns1.yourdomain.com dig +dnssec SOA example.com @ns1.yourdomain.com
Important DNSSEC Behavior in Master-Slave Setup:
Master (ns1) behavior:
- Generates DNSSEC signatures (RRSIG) on-the-fly during queries
- Zone database contains only base records + DNSKEY records
- No RRSIG records stored in database
- Higher CPU usage but lower storage
Slave (ns2) behavior:
- Receives pre-signed zone data via AXFR including all RRSIG records
- Stores complete signed zone in database
- Very fast query responses (no on-the-fly signing)
- Higher storage usage but lower CPU
This is the preferred setup for master-slave configurations as it provides:
- Load distribution (master handles signing, slave handles fast queries)
- Redundancy (if master fails, slave continues serving signed responses)
- Optimal performance (slave can serve high query volumes efficiently)
Restoring DNSSEC-enabled Zones
Complete zone restoration process:
-
Restore zone data:
pdnsutil load-zone example.com example.com.txt
-
Import DNSSEC keys (if .key files exist):
# Import each key file (key ID will be in filename) pdnsutil import-zone-key example.com example.com.key3
-
Rectify the zone:
pdnsutil rectify-zone example.com
-
Increase serial for slave sync:
pdnsutil increase-serial example.com pdns_control notify example.com
-
Update DS records at your registrar:
# Use content from example.com.ds file cat example.com.ds
Copy the DS records and update them at your domain registrar.
-
Verify restoration:
pdnsutil show-zone example.com dig DNSKEY example.com @localhost dig +dnssec SOA example.com @localhost
Important DNSSEC Notes:
- DS Records: After securing a zone, you must update the DS records at your domain registrar using the output from
export-zone-ds
- Serial Management: Always increase SOA serial after DNSSEC operations to ensure slave synchronization
- Key Management: PowerDNS automatically handles key rotation and signing
- Slave Synchronization: DNSSEC keys and signatures are automatically transferred to slave servers
- Validation: Use online DNSSEC validators to verify your setup is working correctly
- Backup Strategy: Always backup both zone data AND DNSSEC keys for complete restoration capability
Part 7: Implementing the Zone Management Script
-
Install jq on the master server:
sudo apt install jq -y
-
Create the script file:
sudo nano /usr/local/bin/pdns_zone_monitor.sh
-
Paste the following content:
#!/bin/bash # Configuration API_KEY="API_KEY" API_URL="http://IP_OF_MASTER_SERVER:8081" NS1="ns1.example.com" NS2="ns2.example.com" LOG_FILE="/var/log/pdns_zone_monitor.log" STATE_FILE="/var/log/pdns_processed_zones.txt" SERIAL_FILE="/var/log/pdns_zone_serials.txt" # Function to log messages log_message() { echo "$(date): $1" >> $LOG_FILE } # Function to get zone serial from API get_zone_serial() { local zone=$1 curl -s -H "X-API-Key: $API_KEY" "$API_URL/api/v1/servers/localhost/zones/$zone" | jq -r '.serial // empty' } # Function to fix SOA and NS records for a zone fix_zone_records() { local zone=$1 local serial=$(date +%Y%m%d%H) # Update SOA and NS records pdnsutil set-meta "$zone" SOA-EDIT-API INCREMENT-WEEKS pdnsutil set-kind "$zone" MASTER pdnsutil add-record "$zone" @ SOA "$NS1 hostmaster.$zone $serial 10800 3600 604800 3600" pdnsutil add-record "$zone" @ NS "$NS1" pdnsutil add-record "$zone" @ NS "$NS2" # Increase serial and notify slaves pdnsutil increase-serial "$zone" pdns_control notify "$zone" # Small delay to ensure changes are committed sleep 2 # Trigger retrieval on ns2 ssh root@ns2 "pdns_control retrieve $zone" log_message "Fixed records for zone $zone and triggered retrieval on ns2" } # Function to sync zone to ns2 sync_zone_to_ns2() { local zone=$1 # Notify and retrieve pdns_control notify "$zone" sleep 2 ssh root@ns2 "pdns_control retrieve $zone" log_message "Synced zone $zone to ns2" } # Function to remove zone from ns2 remove_zone_from_ns2() { local zone=$1 ssh root@ns2 "pdnsutil delete-zone $zone" log_message "Removed zone $zone from ns2" } # Function to clean ns2 by removing zones not present on ns1 clean_ns2() { log_message "Starting cleanup process on ns2" local primary_zones=$(pdnsutil list-all-zones | awk '{print $1}') local secondary_zones=$(ssh root@ns2 "pdnsutil list-all-zones" | awk '{print $1}') log_message "Primary zones on ns1: $primary_zones" log_message "Secondary zones on ns2: $secondary_zones" # Ensure the zones are compared correctly primary_zones_array=($primary_zones) secondary_zones_array=($secondary_zones) for zone in "${secondary_zones_array[@]}"; do if [[ ! " ${primary_zones_array[*]} " =~ " $zone " ]]; then log_message "Cleaning up zone $zone from ns2" remove_zone_from_ns2 "$zone" fi done } # Load processed zones if [ -f "$STATE_FILE" ]; then mapfile -t processed_zones < "$STATE_FILE" else touch "$STATE_FILE" processed_zones=() fi # Load zone serials declare -A zone_serials if [ -f "$SERIAL_FILE" ]; then while IFS='=' read -r zone serial; do zone_serials["$zone"]="$serial" done < "$SERIAL_FILE" fi # Get list of current zones current_zones=$(curl -s -H "X-API-Key: $API_KEY" "$API_URL/api/v1/servers/localhost/zones" | jq -r '.[].name') # Convert current_zones to an array current_zones_array=($current_zones) # Main loop while true; do # Get new list of zones new_zones=$(curl -s -H "X-API-Key: $API_KEY" "$API_URL/api/v1/servers/localhost/zones" | jq -r '.[].name') # Convert new_zones to an array new_zones_array=($new_zones) # Check for new zones and process them for zone in "${new_zones_array[@]}"; do if [[ ! " ${current_zones_array[*]} " =~ " $zone " ]] && [[ ! " ${processed_zones[*]} " =~ " $zone " ]]; then log_message "New zone detected: $zone" fix_zone_records "$zone" echo "$zone" >> "$STATE_FILE" processed_zones+=("$zone") # Store initial serial serial=$(get_zone_serial "$zone") if [ -n "$serial" ]; then zone_serials["$zone"]="$serial" fi fi done # Check for serial number changes in existing zones for zone in "${new_zones_array[@]}"; do current_serial=$(get_zone_serial "$zone") if [ -n "$current_serial" ]; then stored_serial="${zone_serials[$zone]}" # If serial changed or no stored serial, sync the zone if [ -z "$stored_serial" ] || [ "$current_serial" != "$stored_serial" ]; then log_message "Serial change detected for $zone: $stored_serial -> $current_serial" sync_zone_to_ns2 "$zone" zone_serials["$zone"]="$current_serial" fi fi done # Check for removed zones and process them for zone in "${current_zones_array[@]}"; do if [[ ! " ${new_zones_array[*]} " =~ " $zone " ]]; then log_message "Zone removed: $zone" remove_zone_from_ns2 "$zone" processed_zones=("${processed_zones[@]/$zone}") sed -i "\|$zone|d" "$STATE_FILE" unset zone_serials["$zone"] fi done # Save current serials to file > "$SERIAL_FILE" for zone in "${!zone_serials[@]}"; do echo "$zone=${zone_serials[$zone]}" >> "$SERIAL_FILE" done # Clean ns2 to remove any zones not present on ns1 clean_ns2 # Update current zones list current_zones_array=("${new_zones_array[@]}") # Wait for 1 minute before checking again sleep 60 done
If you use the ArkHost PowerDNS Manager for WHMCS, what should you do?
Remove or Comment Out the Zone Record Additions:You should remove or comment out these lines from thefix_zone_records
function, since the module handles initial zone records creation:# pdnsutil add-record "$zone" @ SOA "$NS1 hostmaster.$zone $serial 10800 3600 604800 3600"
# pdnsutil add-record "$zone" @ NS "$NS1"
# pdnsutil add-record "$zone" @ NS "$NS2" -
Make the script executable:
sudo chmod +x /usr/local/bin/pdns_zone_monitor.sh
-
Create a systemd service file:
sudo nano /etc/systemd/system/pdns-zone-monitor.service
-
Add the following content:
[Unit] Description=PowerDNS Zone Monitor After=pdns.service [Service] ExecStart=/usr/local/bin/pdns_zone_monitor.sh Restart=always User=root [Install] WantedBy=multi-user.target
-
Enable and start the service:
sudo systemctl daemon-reload sudo systemctl enable pdns-zone-monitor sudo systemctl start pdns-zone-monitor
Part 8: Implementing the Zone Import Script
-
Create the PowerDNS importer script:
sudo nano /usr/local/bin/powerdns_importer.py
-
Paste the following content:
#!/usr/bin/env python3 import os import sys import subprocess import logging # Set up logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') def run_command(command): try: result = subprocess.run(command, capture_output=True, text=True, check=True) return result.stdout.strip() except subprocess.CalledProcessError as e: logging.error(f"Error running command {' '.join(command)}. Error: {e.stderr.strip()}") return None def import_zone(zone_file): with open(zone_file, 'r') as f: lines = f.readlines() zone_name = None ttl = "3600" for line in lines: line = line.strip() if line.startswith('$ORIGIN'): zone_name = line.split()[1].rstrip('.') break if not zone_name: logging.error(f"Could not determine zone name from {zone_file}") return False try: # Delete the zone if it exists run_command(['pdnsutil', 'delete-zone', zone_name]) logging.info(f"Deleted existing zone: {zone_name}") except: logging.warning(f"Zone {zone_name} did not exist, continuing with creation") # Create the zone run_command(['pdnsutil', 'create-zone', zone_name]) logging.info(f"Created new zone: {zone_name}") for line in lines: line = line.strip() if not line or line.startswith('$ORIGIN'): continue if line.startswith('$TTL'): ttl = line.split()[1] continue parts = line.split(maxsplit=5) if len(parts) < 5: continue name, record_ttl, _, record_type, content = parts[:5] # Handle domain name correctly if name == zone_name or name == "@": name = "@" else: name = name.rstrip('.') if name.endswith(zone_name): name = name[:-len(zone_name)-1] # Remove zone name from the end if not name: name = "@" if record_type == 'MX': if len(parts) == 6: priority, mx = parts[4], parts[5] else: priority, mx = "10", parts[4] content = f"{priority} {mx}" elif record_type == 'TXT': content = f'"{" ".join(parts[4:])}"' elif record_type in ['CNAME', 'NS', 'MX'] and not content.endswith('.'): content = f"{content}." result = run_command(['pdnsutil', 'add-record', zone_name, name, record_type, record_ttl, content]) if result is not None: logging.info(f"Added {record_type} record: {name} {record_ttl} IN {record_type} {content}") else: logging.error(f"Failed to add record: {line}") # Rectify the zone run_command(['pdnsutil', 'rectify-zone', zone_name]) logging.info(f"Rectified zone: {zone_name}") return True def sync_to_ns2(zone_name): try: run_command(['pdns_control', 'notify', zone_name]) logging.info(f"Triggered notification for {zone_name} to ns2") return True except Exception as e: logging.error(f"Error syncing {zone_name} to ns2: {str(e)}") return False def main(): if len(sys.argv) < 2: print("Usage: python powerdns_importer.py <input_directory>") sys.exit(1) input_directory = sys.argv[1] if not os.path.isdir(input_directory): logging.error(f"Error: {input_directory} is not a valid directory") sys.exit(1) for filename in os.listdir(input_directory): if filename.endswith('.txt'): zone_file = os.path.join(input_directory, filename) logging.info(f"Processing {zone_file}") if import_zone(zone_file): sync_to_ns2(os.path.splitext(filename)[0]) if __name__ == "__main__": main()
-
Make the script executable:
sudo chmod +x /usr/local/bin/powerdns_importer.py
Zone Migration from CloudFlare
If you're migrating from CloudFlare to PowerDNS, see our separate guide:
Migrating DNS Zones from CloudFlare to PowerDNS
This covers:
- Exporting all zones from CloudFlare via API
- Converting to PowerDNS-compatible format
- Bulk import process using the PowerDNS importer script
Part 9: Implementing the Slave Update Script
-
Create the update all slaves script:
sudo nano /usr/local/bin/update_all_slaves.py
-
Paste the following content:
#!/usr/bin/env python3 import subprocess import logging # Set up logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') def run_command(command): try: result = subprocess.run(command, capture_output=True, text=True, check=True) return result.stdout.strip() except subprocess.CalledProcessError as e: logging.error(f"Error running command {' '.join(command)}. Error: {e.stderr.strip()}") return None def update_all_slaves(): try: # Get list of all zones zones_output = run_command(['pdnsutil', 'list-all-zones']) if zones_output is None: logging.error("Failed to get list of zones") return False zones = zones_output.split('\n') # Notify each zone for zone in zones: result = run_command(['pdns_control', 'notify', zone]) if result is not None: logging.info(f"Triggered notification for {zone} to slaves") else: logging.warning(f"Failed to notify for zone: {zone}") logging.info("Completed notifications for all zones") return True except Exception as e: logging.error(f"Error updating all slaves: {str(e)}") return False if __name__ == "__main__": if update_all_slaves(): logging.info("Successfully updated all slave servers") else: logging.error("Failed to update all slave servers")
-
Make the script executable:
sudo chmod +x /usr/local/bin/update_all_slaves.py
Part 10: Using the Scripts
- To monitor and manage zones automatically: The
pdns_zone_monitor.sh
script will run as a service, continuously monitoring for new zones and managing them. -
To import zones from text files:
python3 /usr/local/bin/powerdns_importer.py /path/to/zone/files/directory
-
To update all slave servers:
python3 /usr/local/bin/update_all_slaves.py
-
To enable DNSSEC for a zone:
sudo pdnsutil secure-zone example.com sudo pdnsutil export-zone-ds example.com
Part 11: Monitoring and Maintenance
-
Check the status of PowerDNS:
sudo systemctl status pdns
-
Monitor the zone management script:
sudo journalctl -u pdns-zone-monitor -f
-
View the script's log:
tail -f /var/log/pdns_zone_monitor.log
-
Regularly backup your PowerDNS database:
sudo mysqldump pdns > pdns_backup_$(date +%Y%m%d).sql
-
Check DNSSEC status for all zones:
sudo pdnsutil check-all-zones
Part 12: Zone Backups with DNSSEC Support
Create the enhanced backup script that includes DNSSEC keys:
sudo nano /root/scripts/zone_backups.py
import subprocess
import os
import re
from datetime import datetime
def run_pdnsutil(command):
result = subprocess.run(['pdnsutil'] + command.split(),
capture_output=True,
text=True)
return result.stdout.strip()
# Get the current date and time
current_date = datetime.now().strftime("%Y-%m-%d")
# Get all zones
zones = run_pdnsutil('list-all-zones').split('\n')
zones = [zone.strip() for zone in zones if zone.strip()]
# Create output directory
output_dir = f'/root/backups/output_{current_date}'
os.makedirs(output_dir, exist_ok=True)
# Backup each zone
for zone in zones:
print(f"Backing up zone {zone}")
# Backup zone records
content = run_pdnsutil(f'list-zone {zone}')
with open(os.path.join(output_dir, f'{zone}.txt'), 'w') as f:
f.write(content)
# Check for DNSSEC keys
zone_info = run_pdnsutil(f'show-zone {zone}')
# Extract key IDs using regex
key_ids = re.findall(r'ID = (\d+)', zone_info)
if key_ids:
print(f" DNSSEC keys found for {zone}: {key_ids}")
# Export each key
for key_id in key_ids:
key_content = run_pdnsutil(f'export-zone-key {zone} {key_id}')
if key_content.strip():
with open(os.path.join(output_dir, f'{zone}.key{key_id}'), 'w') as f:
f.write(key_content)
# Export DS records
ds_records = run_pdnsutil(f'export-zone-ds {zone}')
if ds_records.strip():
with open(os.path.join(output_dir, f'{zone}.ds'), 'w') as f:
f.write(ds_records)
print(f"Backup complete. Files saved in {output_dir}.")
# Call the transfer and cleanup bash script
subprocess.run(['/root/scripts/transfer_cleanup.sh'], check=True)
print("Backup transfer and cleanup process initiated.")
Set up the cron job:
crontab -e
0 4 * * * /usr/bin/python3 /root/scripts/zone_backups.py
Restoring from DNSSEC Backups
To restore a zone with DNSSEC:
# Load zone
pdnsutil load-zone example.com example.com.txt
# Import DNSSEC key (if .key files exist)
pdnsutil import-zone-key example.com example.com.key3
# Rectify zone
pdnsutil rectify-zone example.com
# Update DS records at registrar using content from example.com.ds file
Your backup files will now include:
- example.com.txt - DNS records
- example.com.key3 - Private DNSSEC key
- example.com.ds - DS records for parent zone