Skip to content

Migrate from Azure Private DNS

Azure Private DNS resolves; DTM resolves and health-gates, load-balances, and fails over. This tutorial moves your zones across without breaking existing resolution: classify what should and should not be imported, import over ARM with a dry run, prove parity while clients stay put, cut one pilot VNet, roll out in tracked waves, and only then decommission the source, in the order that cannot lose writes.

1. Classify every zone: three destinations, not one

Section titled “1. Classify every zone: three destinations, not one”

Inventory your Private DNS zones and sort them into three classes, because only one of them should be imported by hand:

  • App zones (your example.internal-style zones): import directly, the rest of this tutorial.
  • privatelink.* zones: never import these by hand. DTM’s Private Endpoint automation owns them (step 6), keeps them current as endpoints churn, and never fights your records.
  • Auto-registration zones: any zone with VNet links where registrationEnabled is true. A one-shot import of these goes stale as VMs churn; route them to tag-based VM registration or dynamic DNS instead. Detect them:
Terminal window
az network private-dns link vnet list -g rg-dns -z example.internal \
--query "[?registrationEnabled]"

2. Grant the nodes read access to the source

Section titled “2. Grant the nodes read access to the source”

The direct import runs over ARM using each node’s managed identity, which needs Reader (or a custom role granting Microsoft.Network/privateDnsZones read) on the source zones’ scope:

Terminal window
az role assignment list --assignee <node-principal-id> --scope <rg-id> -o table

The tell for a missing grant is a 502 Bad Gateway carrying “Azure Private DNS read failed: … 403 …” from ARM. (A 503 “azure private dns import is not configured” means the importer is unavailable on that node, not a permissions problem.) You also need an admin-role DTM token for the import calls.

Create the destination zone in DTM, then rehearse (migration documents the endpoint and type mapping):

Terminal window
curl -sk -X POST \
https://10.0.250.4:8443/api/v1/zones/<zone-id>/import-azure-privatedns \
-H "Authorization: Bearer $DTM_TOKEN" -H "X-DTM-CSRF: 1" \
-H "Content-Type: application/json" \
-d '{
"subscription_id": "00000000-0000-0000-0000-000000000000",
"resource_group": "rg-dns",
"private_zone_name": "example.internal",
"dry_run": true
}'
# review the created / updated / unchanged / conflicts counts before committing

4. Import, then re-run for the drift baseline

Section titled “4. Import, then re-run for the drift baseline”

Commit with dry_run: false, then run the same import again and confirm everything reports unchanged. The import is idempotent by design, and that all-unchanged second run is your drift baseline: from now on, re-running it is a cheap sweep that shows exactly what changed on the Azure side.

Clients stay on Azure-provided DNS while you prove the copy. From a VM inside a linked VNet (Azure’s resolver is reachable only from inside), compare answers per name and type:

Terminal window
for name in app db cache; do
echo "$name: DTM=$(dig @10.0.250.4 $name.example.internal +short) \
Azure=$(dig @168.63.129.16 $name.example.internal +short)"
done

Expect SOA and NS to differ by design: the import skips them, and DTM manages its own. Everything else should match exactly; a mismatch means a write landed after your import, which the drift sweep from step 4 will show.

Section titled “6. Hand the privatelink zones to the automation”
Terminal window
curl -sk -X PUT https://10.0.250.4:8443/api/v1/config/private-endpoints \
-H "Authorization: Bearer $DTM_TOKEN" -H "X-DTM-CSRF: 1" \
-H "Content-Type: application/json" -d '{"enabled": true}'
curl -sk -X POST https://10.0.250.4:8443/api/v1/discovery/private-endpoints/run \
-H "Authorization: Bearer $DTM_TOKEN" -H "X-DTM-CSRF: 1"

The run-now call saves waiting out the interval. Two facts to rely on: only the allowlisted official Microsoft privatelink.* zones are managed (the allowlist is not extensible), and the automation never overwrites an operator-created record at the same name. Prerequisite: Reader over the discovery scopes; details on Private Endpoint DNS automation.

Verify: a known private endpoint name resolves from a DTM node.

Pick the records that matter and enrich them: a health-check template and a load-balancing policy per record (health checks, methods). Prove one by stopping a backend and watching the answer flip on the probe-math schedule. This is the payoff step; a migration that ends with identical behavior was just a move.

Terminal window
az network vnet update -g rg-pilot -n vnet-pilot --dns-servers 10.0.250.4 10.0.250.5

Then verify from a VM inside it (internal names, private endpoint names, and internet recursion).

Cut remaining VNets over in waves, and tag each one dtm-dns=enforced as part of its wave: the shipped DNS-adoption policy evaluates only VNets carrying that tag, so the compliance blade doubles as your rollout tracker, and its effects are Audit or Disabled only, never Deny. One standing chore: the policy assignment’s dtmDnsServerIps parameter lists your node IPs, so re-set it after any add-node or remove-node that changes them.

The ordering here is what protects the last writes:

  1. Freeze all writes to the Azure Private DNS zones (announce it; remove write access if your process allows).
  2. Run one final import sweep and confirm the all-unchanged counts. That is the completion proof: nothing landed since your last sync.
  3. Hold a read-only rollback window (the zones still exist, unlinked writes frozen) for as long as your change process wants.
  4. Remove the VNet links, then delete the Azure zones.

Freezing after a final sweep, the tempting order, silently loses any write that lands between the sweep and the link removal.

Need a hand? Email [email protected].

Last validated: 2026-07-26