I've been using GitHub Actions to automatically backup my Cloudflare DNS records as JSON files for a while now. It's a simple but effective way to keep track of DNS changes and have a safety net in case something goes wrong. Recently, I decided to enhance this setup by also backing up my DNS records in BIND9 zone file format alongside the JSON backups.

The Problem with JSON-Only Backups#

While JSON backups from Cloudflare's API are comprehensive and contain all the metadata (like proxy status, record IDs, etc.), they have one significant limitation: vendor lock-in. If I ever need to quickly migrate away from Cloudflare to another DNS provider or set up my own DNS server, JSON format isn't directly usable.

Most DNS servers expect zone files in the standard BIND9 format, which has been the universal DNS zone file format since the early days of the internet. Having backups in this format means I can restore my DNS records to virtually any DNS server without conversion hassles.

Discovering Cloudflare's Hidden BIND9 Export#

While researching DNS backup strategies, I discovered that Cloudflare actually provides a direct BIND9 export endpoint that I hadn't known about:

GET /zones/{zone_id}/dns_records/export

This endpoint returns a proper BIND9 zone file instead of JSON, complete with SOA records, proper formatting, and all the standard DNS record types. It's not prominently featured in their documentation, but it's there and works perfectly.

Adding BIND9 Export#

The enhancement to the original script was surprisingly simple. I just needed to add one extra API call to fetch the BIND9 export. Here's my updated workflow:

name: Cloudflare DNS Backup

on:
  workflow_dispatch:
  schedule:
    - cron: "40 0 * * 0"

jobs:
  backup:
    runs-on: ubuntu-22.04
    env:
      CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
      - uses: actions/checkout@v4

      - run: |
          # Create backup directory
          mkdir -p dns-backups

          # Get list of zones (domains)
          zones=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones" \
            -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
            -H "Content-Type: application/json" | jq -r '.result[].id')

          # Loop through zones and backup DNS records
          for zone in $zones; do
            # Get zone name
            zone_name=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone" \
              -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
              -H "Content-Type: application/json" | jq -r '.result.name')
            
            echo "Backing up DNS records for $zone_name"
            
            # Get DNS records and save JSON
            curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone/dns_records" \
              -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
              -H "Content-Type: application/json" | jq > "dns-backups/$zone_name.json"
            
            # Get DNS records as BIND9 zone file and normalize timestamps
            curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone/dns_records/export" \
              -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | \
              sed 's/;; Exported:   [0-9-]* [0-9:]*/;; Exported:   [TIMESTAMP_REMOVED]/' | \
              sed 's/\([a-zA-Z0-9.-]*\)\s*3600\s*IN\s*SOA\s*\([a-zA-Z0-9.-]*\)\s*\([a-zA-Z0-9.-]*\)\s*[0-9]* \(.*\)/\1\t3600\tIN\tSOA\t\2 \3 [SERIAL_REMOVED] \4/' \
              > "dns-backups/$zone_name.zone"
          done

      - uses: stefanzweifel/git-auto-commit-action@v5

Handling the Timestamp Problem#

One issue I discovered was that Cloudflare's BIND9 export includes timestamps and incremental serial numbers that change on every export, even when the actual DNS records haven't changed. This would cause unnecessary commits every time the workflow ran.

The zone files looked like this:

 ;;
 ;; Domain:     example.com.
-;; Exported:   2025-08-14 04:50:19
+;; Exported:   2025-08-14 08:38:48
 ;;

-example.com	3600	IN	SOA	ns1.cloudflare.com. dns.cloudflare.com. 2050681361 10000 2400 604800 3600
+example.com	3600	IN	SOA	ns1.cloudflare.com. dns.cloudflare.com. 2050682732 10000 2400 604800 3600

To solve this, I added some sed processing to:

  • Replace the export timestamp with [TIMESTAMP_REMOVED]
  • Replace the SOA serial number with [SERIAL_REMOVED]

This ensures that zone files only show changes when actual DNS records are modified, not just because the backup ran at a different time.

Preparing Zone Files for Emergency Import#

The zone files in my backup contain placeholders that need to be replaced before they can be imported into a DNS server:

  • [TIMESTAMP_REMOVED] should be replaced with the current date and time
  • [SERIAL_REMOVED] should be replaced with a valid SOA serial number

For emergency use, you can quickly prepare the files with a simple script:

#!/bin/bash
# Emergency DNS restore preparation script

CURRENT_SERIAL=$(date +%Y%m%d01)  # Format: YYYYMMDD01
CURRENT_TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')

for zonefile in dns-backups/*.zone; do
    echo "Preparing $zonefile for import..."

    # Create import-ready version
    sed "s/\[SERIAL_REMOVED\]/$CURRENT_SERIAL/g" "$zonefile" | \
    sed "s/\[TIMESTAMP_REMOVED\]/$CURRENT_TIMESTAMP/g" > "ready-to-import-$(basename "$zonefile")"
done

echo "Zone files ready for import in ready-to-import-*.zone files"

Alternatively, you can use simple values like:

  • Serial number: 1 (a DNS server might readily accept this and increment from there)
  • Timestamp: current date and time when you're doing the import

The key is that these placeholders prevent unnecessary commits while keeping the zone files in a format that's easily convertible to production-ready files when needed.

The Benefits#

Now I have a dual-format backup strategy. The benefits of JSON backups:

  • Complete Cloudflare metadata
  • Proxy status information
  • Record IDs and creation dates
  • Perfect for restoring to Cloudflare

The benefits of BIND9 backups:

  • Universal compatibility
  • Can be imported into any DNS server
  • Standard format understood by all DNS tools
  • Emergency-ready with proper SOA records

The workflow now automatically maintains both formats in version control, so I can easily see what changed and when. If I ever need to quickly migrate away from Cloudflare or set up emergency DNS, I have immediately usable BIND9 zone files ready to go. Enjoy!