ℹ️ Many blog posts do not include full scripts. If you require a complete version, please use the Support section in the menu.
Disclaimer: I do not accept responsibility for any issues arising from scripts being run without adequate understanding. It is the user's responsibility to review and assess any code before execution. More information

Migrating DNS Between Registrars: My Experience Moving From Porkbun to Cloudflare


If you need to move you NS domain server records between registrar A and registrar B in this case from  Porkbun to Cloudflare, not the actual IPS tag but only the NS records as your get more control on Cloudflare DNS directly that via another provider like Porkbun.

At first glance, it seemed like pointing the domain at Cloudflare’s name servers would be straightforward. In reality, migrating DNS between services, even cloud-based ones, is more complex than simply changing NS records.

The Challenge

The main challenge was ensuring that every DNS record in Porkbun was correctly recreated in Cloudflare. I needed to avoid downtime, misconfigured MX records, missing TXT entries, or TTL mismatches. My goal was to compare the records between the two services programmatically rather than manually checking each record in the dashboards.

Even though Cloudflare provides an option to attempt importing records from the existing registrar, this process often misses some records, leaving the transfer incomplete. I have seen this happen with TXT, SRV, and other less common record types, which makes relying solely on the automatic import risky.

Understanding Zone Transfers

Full zone transfers are not supported in this scenario. Zone transfers, also called AXFR, are designed for internal use when you have a primary DNS server and one or more secondary servers. They allow secondary servers to automatically replicate all the records from the primary, ensuring consistency and redundancy.

Exposing a full zone transfer publicly would reveal all internal DNS details, including mail servers and subdomains, which is a significant security risk. That’s why public AXFR is blocked by services like Cloudflare and Porkbun. Internally, zone transfers simplify replication and maintain accurate DNS data across multiple servers, but they are not a tool for migrating between independent external providers.

My Solution: Programmatic Validation

To address the challenge, I wrote a PowerShell script that queries all DNS records from Porkbun using their API and reads an exported zone file from Cloudflare. The script compares every record by type, value, and TTL, highlighting discrepancies, missing records, and mismatched values.

The HTML Report

The script generates a clean HTML report that I can view in any browser. The top section summarizes total differences, including records missing from Cloudflare, records missing from Porkbun, and records that exist in both but differ in value or TTL. The main table is sortable and color-coded, making it easy to see which records need attention. Long TXT or SRV records are displayed with hover-over tooltips, preserving readability.

Code : Export Porkbun and compare to Cloudflare

# ============================================
# DNS Migration Validation - Compare of DNS   
# ============================================

# -------------------------------
# Step 1: Load Porkbun Records (API)
# -------------------------------
$ApiKey = "<api-key>"
$SecretKey = "<secret_key>"
$Domain = "croucher.cloud"

$Body = @{
    apikey = $ApiKey
    secretapikey = $SecretKey
    domain = $Domain
} | ConvertTo-Json

$PorkbunResponse = Invoke-RestMethod -Uri "https://porkbun.com/api/json/v3/dns/list/$Domain" -Method POST -Body $Body -ContentType "application/json"

$PorkbunRecords = $PorkbunResponse.records | ForEach-Object {
    [PSCustomObject]@{
        Name  = $_.name
        Type  = $_.type
        TTL   = $_.ttl
        Value = $_.content
    }
}

# -------------------------------
# Step 2: Load Cloudflare Zone Export
# -------------------------------
$CloudflareFile = ".\CloudflareZone.txt"
$CloudflareRecords = @()

foreach ($line in Get-Content $CloudflareFile) {
    if ($line -match "^\s*;" -or $line -match "^\s*$") { continue }
    $tokens = $line -split "\s+"
    if ($tokens.Count -ge 5) {
        $CloudflareRecords += [PSCustomObject]@{
            Name  = $tokens[0]
            TTL   = $tokens[1]
            Type  = $tokens[3]
            Value = $tokens[4..($tokens.Count-1)] -join " "
        }
    }
}

# -------------------------------
# Step 3: Compare Records
# -------------------------------
$Comparison = @()

# Check Porkbun -> Cloudflare
foreach ($record in $PorkbunRecords) {
    $match = $CloudflareRecords | Where-Object { $_.Name -eq $record.Name -and $_.Type -eq $record.Type }

    if ($match) {
        $diffs = @()
        if ($match.Value -ne $record.Value) { $diffs += "Value differs" }
        if ($match.TTL -ne $record.TTL) { $diffs += "TTL differs" }

        if ($diffs.Count -gt 0) {
            $Comparison += [PSCustomObject]@{
                Name       = $record.Name
                Type       = $record.Type
                Porkbun    = $record.Value
                Cloudflare = $match.Value
                TTL_PB     = $record.TTL
                TTL_CF     = $match.TTL
                Status     = ($diffs -join ", ")
            }
        }
    } else {
        $Comparison += [PSCustomObject]@{
            Name       = $record.Name
            Type       = $record.Type
            Porkbun    = $record.Value
            Cloudflare = ""
            TTL_PB     = $record.TTL
            TTL_CF     = ""
            Status     = "Missing in Cloudflare"
        }
    }
}

# Check Cloudflare -> Porkbun missing
foreach ($record in $CloudflareRecords) {
    $match = $PorkbunRecords | Where-Object { $_.Name -eq $record.Name -and $_.Type -eq $record.Type }
    if (-not $match) {
        $Comparison += [PSCustomObject]@{
            Name       = $record.Name
            Type       = $record.Type
            Porkbun    = ""
            Cloudflare = $record.Value
            TTL_PB     = ""
            TTL_CF     = $record.TTL
            Status     = "Missing in Porkbun"
        }
    }
}

# -------------------------------
# Step 4: Generate Summary
# -------------------------------
$TotalRecords = $Comparison.Count
$MissingCloudflare = ($Comparison | Where-Object {$_.Status -eq "Missing in Cloudflare"}).Count
$MissingPorkbun = ($Comparison | Where-Object {$_.Status -eq "Missing in Porkbun"}).Count
$Differences = ($Comparison | Where-Object {$_.Status -match "diff"}).Count

# -------------------------------
# Step 5: Generate HTML Report
# -------------------------------
$HTMLHeader = @"
<html>
<head>
<meta charset='UTF-8'>
<title>DNS Migration Validation Report - $Domain</title>
<style>
body { font-family: Arial; background-color:#f9f9f9; color:#333; padding:20px; }
h2, h3 { color:#333; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
th { background-color: #555; color: white; cursor:pointer; }
tr:nth-child(even) { background-color: #eee; }
.status-missingCloudflare { background-color: #fdd; }
.status-missingPorkbun { background-color: #dfd; }
.status-diff { background-color: #ffd; }
td.tooltip { position: relative; }
td.tooltip:hover::after {
    content: attr(data-full);
    position: absolute;
    left: 0; top: 100%;
    background: #333; color: #fff;
    padding: 5px; border-radius: 3px;
    white-space: pre; z-index: 10;
}
</style>
</head>
<body>
<h2>DNS Migration Validation Report</h2>
<h3>Domain: $Domain</h3>
<p><b>Total discrepancies:</b> $TotalRecords | <b>Missing in Cloudflare:</b> $MissingCloudflare | <b>Missing in Porkbun:</b> $MissingPorkbun | <b>Differences:</b> $Differences</p>
<table id='dnsTable'>
<tr>
<th onclick="sortTable(0)">Name</th>
<th onclick="sortTable(1)">Type</th>
<th onclick="sortTable(2)">Porkbun Value</th>
<th onclick="sortTable(3)">Cloudflare Value</th>
<th onclick="sortTable(4)">Porkbun TTL</th>
<th onclick="sortTable(5)">Cloudflare TTL</th>
<th onclick="sortTable(6)">Status</th>
</tr>
"@

$HTMLRows = ""
foreach ($row in $Comparison) {
    $class = ""
    switch -Wildcard ($row.Status) {
        "*Missing in Cloudflare*" { $class = "status-missingCloudflare" }
        "*Missing in Porkbun*"    { $class = "status-missingPorkbun" }
        "*diff*"                   { $class = "status-diff" }
    }

    $pbValue = $row.Porkbun
    $cfValue = $row.Cloudflare

    # Use tooltip for long values (TXT/SRV)
    if ($pbValue.Length -gt 20) { $pbValue = "<td class='tooltip' data-full='$($row.Porkbun)'>$($row.Porkbun.Substring(0,20))...</td>" } else { $pbValue = "<td>$pbValue</td>" }
    if ($cfValue.Length -gt 20) { $cfValue = "<td class='tooltip' data-full='$($row.Cloudflare)'>$($row.Cloudflare.Substring(0,20))...</td>" } else { $cfValue = "<td>$cfValue</td>" }

    $HTMLRows += "<tr class='$class'>
<td>$($row.Name)</td>
<td>$($row.Type)</td>
$pbValue
$cfValue
<td>$($row.TTL_PB)</td>
<td>$($row.TTL_CF)</td>
<td>$($row.Status)</td>
</tr>`n"
}

$HTMLFooter = @"
</table>

<script>
// Simple table sorter
function sortTable(n) {
  var table, rows, switching, i, x, y, shouldSwitch, dir, switchcount = 0;
  table = document.getElementById("dnsTable");
  switching = true;
  dir = "asc"; 
  while (switching) {
    switching = false;
    rows = table.rows;
    for (i = 1; i < (rows.length - 1); i++) {
      shouldSwitch = false;
      x = rows[i].getElementsByTagName("TD")[n];
      y = rows[i + 1].getElementsByTagName("TD")[n];
      if (dir == "asc") {
        if (x.innerHTML.toLowerCase() > y.innerHTML.toLowerCase()) { shouldSwitch = true; break; }
      } else if (dir == "desc") {
        if (x.innerHTML.toLowerCase() < y.innerHTML.toLowerCase()) { shouldSwitch = true; break; }
      }
    }
    if (shouldSwitch) {
      rows[i].parentNode.insertBefore(rows[i + 1], rows[i]);
      switching = true;
      switchcount++;
    } else {
      if (switchcount == 0 && dir == "asc") { dir = "desc"; switching = true; }
    }
  }
}
</script>
</body>
</html>
"@

$HTML = $HTMLHeader + $HTMLRows + $HTMLFooter
$ReportPath = ".\DNS_Validation_Report.html"
$HTML | Out-File -FilePath $ReportPath -Encoding UTF8
Write-Host "✅ Report generated at $ReportPath"

HTML Report 

The HTML report is designed to only point out discrepancies when they are found, any records that are valid on both DNS servers will not be shown in this report: 


Benefits

Using this method, I could verify that the Cloudflare configuration matched Porkbun exactly, reducing the risk of email or service disruptions. The report is professional and easy to share with stakeholders, confirming that the migration was complete and accurate.

Conclusions

This experience reinforced that DNS migrations are rarely trivial. Automatic imports can be incomplete, and external providers do not support public AXFR for security reasons. By leveraging APIs and programmatic comparison, I achieved a level of certainty that manual checking alone would have struggled to provide. The script is now a reusable tool for future migrations, ensuring every record is accounted for regardless of the services involved.

Previous Post Next Post

نموذج الاتصال