AZURE · TERRAFORM

Locking Down Azure Storage with Private Endpoints

A Terraform build that disables public access on a Storage Account entirely, and reaches it only through a Private Endpoint and Private DNS inside a spoke VNet.

By Ravindrakumar Narayanan · Microsoft Learn credentials · · 6 min read

Key takeaways

This post covers the reasoning and the evidence. If you want to build it yourself, the step-by-step build guide runs from the state backend through to teardown, with the exact commands at each step.

Why disable public access at all

A new Storage Account accepts traffic from any network the day you create it. Access is decided by identity and secrets: Entra ID role assignments, account keys, SAS tokens. Where the request came from never enters into it. That puts credentials in the position of being the only thing between the public internet and your data, so a leaked key or a SAS token scoped too widely is a straight path in from anywhere.

Disabling public network access adds the layer that's missing. The account stops accepting traffic over the public path however valid the credential is, and it's one setting to get right instead of an IP allow-list you have to keep accurate every time an office, a VPN or a build agent changes.

It doesn't make the account vanish, though. The hostname still resolves publicly and the endpoint still answers. It simply refuses, which is what the 403 in Test 4 shows. What changes is that the only usable route to the data now runs through a Private Endpoint in a specific subnet of a specific VNet.

One boundary to be clear about: this closes the data path, not the management path. Control-plane operations through Azure Resource Manager still work over the internet and are governed by Azure RBAC, not by the network. Reading the account's properties, rotating its keys, or switching this setting back on all still work from anywhere.

What actually makes this work

On its own, disabling public access leaves you with an unreachable storage account. Three more pieces have to come together before it works again over the private path:

Drop any one of the three and it breaks. Either the endpoint can't be found by name, or it exists and nothing resolves to it.

Private endpoint architecture for an Azure Storage Account A spoke VNet addressed 10.20.0.0/16 contains the subnet snet-pe (10.20.2.0/24) holding a private endpoint at 10.20.2.4, which connects to a storage account with public access disabled. A private DNS zone named privatelink.blob.core.windows.net holds an A record pointing to 10.20.2.4. Traffic arriving from the public internet is refused with HTTP 403. Spoke VNet — 10.20.0.0/16 snet-pe — 10.20.2.0/24 Private Endpoint 10.20.2.4 Storage Account Public access: Disabled Private DNS Zone privatelink.blob.core.windows.net A record → 10.20.2.4 Public Internet Access: 403 ✕ blocked

Provider gotchas hit along the way

I pinned azurerm ~>5.1 and Terraform resolved 5.5.0. Four things caught me out on the way through, and none of them fails with an obvious message the first time:

Duplicate required_providers: Terraform allows one required_providers block per module, even when your config is split across files. I had it in both versions.tf and providers.tf, and terraform init failed straight away. Keep it in one file.
azurerm_private_dns_zone_virtual_network_link schema change: this resource no longer accepts resource_group_name and private_dns_zone_name. It now takes a single private_dns_zone_id argument instead:
resource "azurerm_private_dns_zone_virtual_network_link" "blob" {
  name                 = "link-blob-spoke"
  private_dns_zone_id  = azurerm_private_dns_zone.blob.id
  virtual_network_id   = var.spoke_vnet_id
  registration_enabled = false
}
public_network_access_enabled is on the way out: on azurerm_storage_account it's being replaced by the string-valued public_network_access. This build uses the newer form, public_network_access = "Disabled". If you're writing against v5, use the same.
azurerm_storage_container takes an ID, not a name: if you extend this module with a container, it wants storage_account_id, not the older storage_account_name. Several storage sub-resources switched the same way in v5, so it's the first thing to check when an older snippet stops working.

Validation — five checks, all passing

Every claim above is checked against the deployed resources rather than assumed. The storage account name (stprivsbrk48) comes from a random_string suffix, so yours will differ.

TestWhat it provesResult
Public network access Storage account rejects public network access entirely Disabled
Private endpoint status Endpoint connection is live and approved Approved (Auto-Approved)
Private DNS resolution Storage name resolves to a private IP inside snet-pe A record → 10.20.2.4
External access blocked A direct request from outside the VNet is denied HTTP 403
Idempotency A second terraform plan shows no drift No changes

Test 1 — Public network access is disabled

Ask the CLI directly:

az storage account show `
  --name $(terraform output -raw storage_account_name) `
  --resource-group rg-private-storage-validation `
  --query "publicNetworkAccess"
"Disabled"
Azure CLI output showing the publicNetworkAccess property of the storage account returning Disabled
CLI: publicNetworkAccess returns "Disabled".

The portal's Networking blade says the same thing, and throws in a detail the CLI didn't: Private endpoint connections: 1. Network routing is Microsoft network routing, endpoint type Standard.

Azure portal Networking blade showing Public network access set to Disabled and one private endpoint connection
Portal: Public network access — Disabled, with 1 private endpoint connection.

Test 2 — Private endpoint is approved

The endpoint pe-stprivsbrk48-blob shows a connection state of Approved, described as Auto-Approved. That comes from is_manual_connection = false in the Terraform. The storage account and the endpoint sit in the same subscription, so nobody has to approve it by hand.

Azure portal private endpoints list showing pe-stprivsbrk48-blob with connection state Approved and description Auto-Approved
Portal: private endpoint connection state — Approved / Auto-Approved.

Test 3 — The private DNS A record was created automatically

This is the one I most wanted to see. The private DNS zone privatelink.blob.core.windows.net holds an A record for stprivsbrk48 pointing at 10.20.2.4, an address inside the snet-pe range (10.20.2.0/24), exactly where the endpoint's NIC landed.

Nobody created that record by hand. The private_dns_zone_group block on the private endpoint did it. Leave that block out and you end up with a zone, a link and an endpoint that never get connected by name.

Azure portal private DNS zone record sets showing an A record for stprivsbrk48 resolving to 10.20.2.4 alongside the default SOA record
Portal: A record for the storage account resolving to 10.20.2.4, alongside the zone's default SOA record.

Test 4 — Public access is blocked from outside the VNet

A request straight from my laptop, outside the VNet. The name still resolves, the request still gets refused:

$SA = $(terraform output -raw storage_account_name)
curl -s -o /dev/null -w "%{http_code}\n" "https://${SA}.blob.core.windows.net/?comp=list"
403

Worth being precise here. From the internet's point of view the endpoint isn't gone, it's refusing. DNS still answers and TLS still negotiates. The 403 comes back because the account no longer accepts traffic arriving over the public path.

Terminal showing a curl request against the blob endpoint returning HTTP 403
A request from outside the VNet returns HTTP 403.

Test 5 — Idempotency

A second plan against the deployed infrastructure refreshes all 13 resources in state and finds nothing to change:

terraform plan
No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your
configuration and found no differences, so no changes are needed.

Run this even when everything looks fine. A plan that isn't clean means the config produces a different result on every run, and that's a bug, not noise.

Terminal showing terraform plan refreshing all resources and reporting No changes, infrastructure matches the configuration
All 13 resources refreshed; no drift detected.

Cost control

I left out the VM and Azure Bastion on purpose. Both bill by the hour just for existing, and neither is needed to prove this pattern. A trimmed hub-and-spoke network, just the subnet the private endpoint needs, plus the storage account itself, covers all five checks above. Private endpoints, private DNS zones, VNets and NSGs cost next to nothing while idle, and terraform destroy clears the lot.

The trade-off: with no VM in the spoke, I couldn't run nslookup from inside the VNet and watch the name resolve to 10.20.2.4. Test 3 proves the record exists and is correct, but confirming resolution from a client inside the network would need a temporary B-series VM. A few pence for an hour, if you want that last step.

Explore the code

The full Terraform build is on GitHub: modules, variable wiring, and the fixes for all four gotchas above. The build guide alongside it walks through the whole thing step by step, including the state backend setup and the teardown.

View the code on GitHub → Read the build guide ← Back to all blogs