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"
# ============================================
# 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
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.