
Dev
Your Azure Private Endpoint Is Approved. So Why Does It Still Resolve a Public IP?
Dev.toUnited States · NORTH AMERICA
You disable public access on a storage account. You add a private endpoint. The portal shows the connection as Approved. You deploy the app. It fails with a timeout or a 403. Nothing in your pipeline...
You disable public access on a storage account. You add a private endpoint. The portal shows the connection as Approved. You deploy the app.
It fails with a timeout or a 403.
Nothing in your pipeline warned you. what-if showed the right resources, the deployment succeeded, and every resource is green. The problem is that these tools check resources. They do not check whether a name inside your network actually resolves to the private address.
In my experience, most private connectivity problems in Azure are not firewall problems. They are DNS problems. This post explains why, and shares a small script that checks the whole path before you deploy.
What must be true for private access to work
For a workload in a VNet to reach a PaaS service privately, all of these must hold:
Requirement What happens if it is missing
1 The private endpoint connection is Approved Connection stays pending, nothing works
2 The matching privatelink private DNS zone exists The name resolves to the public address
3 The zone is linked to the VNet doing the lookup Same: public address, even though the zone exists
4 The zone has an A record for the resource Lookup fails or falls back to public
5 If the VNet uses custom DNS, it forwards privatelink zones to Azure DNS Lookup never reaches the private zone
Number 3 is the classic trap. The zone exists, the endpoint is approved, the A record is there, and it still does not work, because the VNet that runs the lookup cannot see the zone.
A quick manual test from a VM inside the VNet:
bash
nslookup mystore.dfs.core.windows.net
If the answer is a 10.x address, DNS is fine. If it is a public address, one of the five items above is broken, and you now have to find which one.
The idea: a what-if for network paths
Finding the broken item by clicking through the portal takes a long time, especially when the zone lives in another subscription or resource group. So I wrote a script that asks the one question directly:
Can workloads in this source VNet reach this target resource privately?
It reads the configuration with the Azure CLI and reports PASS, WARN, or FAIL for each requirement, with a suggested fix.
Example output when the zone exists but is not linked to the source VNet:
text
[PASS] Public access: disabled
[PASS] Private endpoint: 1 approved connection(s)
[PASS] Source VNet DNS: uses Azure-provided DNS
[PASS] DNS zone for pe-mystore-dfs [dfs]: privatelink.dfs.core.windows.net exists
[FAIL] VNet link for pe-mystore-dfs [dfs]: privatelink.dfs.core.windows.net is not linked to the source VNet
Fix: Link the zone to the source VNet (or to the hub VNet that provides DNS resolution for it).
[PASS] A record for pe-mystore-dfs [dfs]: mystore found
The exit code is 1 when anything fails, so a pipeline can block the change.
How it works
The script makes five groups of read-only az calls:
az resource show on the target: read publicNetworkAccess and privateEndpointConnections.
az network private-endpoint show: read the group id (blob, dfs, vault, sqlServer, and so on).
A lookup table maps the group id to the expected zone, for example dfs to privatelink.dfs.core.windows.net.
az network private-dns link vnet list: is the source VNet in the zone's links?
az network private-dns record-set a list: is there a record named after the resource?
It also reads the source VNet's DNS servers. If it uses custom DNS, the script raises a warning, because it cannot see inside your DNS servers and only you can confirm they forward privatelink zones to Azure DNS (168.63.129.16) or to an Azure DNS Private Resolver.
The core check, in a few lines:
python
links = az(["network", "private-dns", "link", "vnet", "list",
"-g", zone_rg, "-z", zone]) or []
linked = any(
((l.get("virtualNetwork") or {}).get("id") or "").lower() == source_vnet_id.lower()
for l in links
)
if not linked:
report.add("FAIL", "VNet link", f"{zone} is not linked to the source VNet",
"Link the zone to the source VNet or to the hub VNet that provides DNS.")
The whole thing is one Python file with no dependencies beyond the Azure CLI. It needs only Reader access, and it changes nothing.
Run it in a pull request
Because it is read-only and fast, it works well as a gate. A sample GitHub Actions job using federated credentials (no stored secrets):
yaml
name: private-path-check
on: pull_request
permissions:
id-token: write
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Verify private path
run: |
python private_path_check.py \
--resource-id "${{ vars.TARGET_RESOURCE_ID }}" \
--source-vnet-id "${{ vars.SOURCE_VNET_ID }}"
Run it after the infrastructure is deployed to Dev, and a missing VNet link fails the pipeline in a minute, not after a day of debugging.
Why this matters more with AI-generated infrastructure
If an AI assistant writes your Bicep or Terraform, it will happily produce a private endpoint and forget the DNS zone link. The code is valid, what-if is happy, and the failure only appears at runtime. Checks like this one are the kind of gate that makes AI-generated changes safe to review: the model proposes, and an independent check verifies the result.
What it does not do
I want to be clear about the limits:
It does not test the network itself. NSG rules, firewall rules, routing, and open ports are not checked. For that, use Azure Network Watcher (connection troubleshoot and the virtual network verifier).
Hub-and-spoke setups need care. If DNS resolution runs through a hub VNet or a Private Resolver, pass the hub VNet as the source, or the "linked" check will report a false failure.
The zone table is small. It covers storage, Key Vault, SQL, and Databricks. Other services need an entry from the Microsoft Learn list of private endpoint DNS zone values.
The A record name is not always the resource name. That holds for storage, but some services register different names.
It reads configuration. It does not resolve DNS from inside the VNet. A real nslookup from a VM in the VNet is still the final proof.
