Cloud

Azure Functions with private Storage: keep runtime, deployment, and diagnostics under control

An operational approach to securing the Azure Functions Storage account with Private Endpoint without breaking runtime behavior, deployments, DNS resolution, triggers, and operational checks.

02 Jun 2026 azure-functionsstorage-accountprivate-endpointdnsnetworkingdeployment

Securing an Azure Function with a Private Endpoint on its HTTP entry point is not enough to make the architecture private. A Function also depends on a Storage account for its runtime, triggers, internal state, packages, and sometimes logs. When that Storage account is locked down too quickly or resolved through the wrong path, the application-side symptom can be confusing: the Function starts poorly, triggers stop syncing, deployment fails, or the portal displays an error that looks like an application problem.

The scenario covered here is common. A team operates a Function App in Azure, integrated with a VNet, with a goal of reducing public exposure. The application entry point may already go through APIM, Application Gateway, or a Private Endpoint. The Storage account attached to the Function must now be placed behind Private Endpoint, with public access limited or disabled. The goal is not only to tick a network checkbox, but to keep runtime behavior, deployments, and diagnostics operable.

The difficult part is that the Storage account is not just a business backend. It is used by the Functions platform itself. Closing it without preparing platform flows can cut the runtime path that the application depends on.

Describe dependencies before closing the network

Before changing the Storage account, write down what it is actually used for. A Function App may use Storage for AzureWebJobsStorage, Storage triggers, Durable Functions, internal state, package mounting, files or tables, and additional application dependencies. These uses do not carry the same risk.

text function-storage-dependencies.txt
Function App
Name: func-orders-prod
Plan: Premium or Dedicated with VNet integration
Application entry point: internal APIM or Private Endpoint depending on the design

Platform Storage account
AzureWebJobsStorage
Blob: packages, host state, receipts depending on the runtime
Queue: triggers or internal messages depending on usage
Table: state or metadata depending on extensions
File: application content depending on deployment mode

Network objective
Public access limited or disabled
Private Endpoints for the required subservices
Private DNS proven from the Function outbound path
Deployment still possible through a controlled path

This framing avoids a frequent mistake: creating only a blob Private Endpoint because the Storage account appears to be used for packages, while a queue trigger or Durable Functions extension also needs queue and sometimes table. The right scope depends on the real usage, not on a generic model.

Separate private inbound from private outbound

A Function can be private on the inbound side while still reaching its Storage account through a public path. The opposite is also possible: it can access private Storage while still being publicly exposed. These two dimensions must be handled separately.

Inbound traffic concerns clients calling the Function. Outbound traffic concerns what the Function and its runtime must reach. To access a private Storage account, the Function needs a consistent outbound path: VNet integration, private DNS, possible routes, and Storage subservices exposed through Private Endpoint.

text inbound-outbound-model.txt
Inbound flow
Internal client or gateway
-> Application name
-> Function App or gateway in front of the Function

Runtime outbound flow
Function App
-> VNet integration
-> Resolve mystorage.blob.core.windows.net to a private IP
-> Blob Private Endpoint

Additional outbound flows depending on usage
Function App
-> mystorage.queue.core.windows.net
-> mystorage.table.core.windows.net
-> mystorage.file.core.windows.net if required

If this separation is not made, diagnostics become noisy. A successful HTTP test against the Function does not prove that the runtime can reach its Storage account. A private Storage resolution test from a hub VM does not prove that the Function uses the same DNS path.

Identify the Storage subservices that are really needed

Storage exposes several subservices with different DNS names: Blob, Queue, Table, and File. Private Endpoints are created per subservice. You should not assume that a blob endpoint makes the whole Storage account privately reachable for every usage.

bash 01-identify-storage-usage.sh
az functionapp config appsettings list -g rg-app-prod -n func-orders-prod --query "[?name=='AzureWebJobsStorage' || contains(name, 'Storage') || contains(name, 'WEBSITE_RUN_FROM_PACKAGE')].{name:name, value:value}"

az functionapp show -g rg-app-prod -n func-orders-prod --query '{name:name, state:state, kind:kind, serverFarmId:serverFarmId}'

This readout is not always sufficient, but it gives the first signals. An AzureWebJobsStorage connection string points to an account. A WEBSITE_RUN_FROM_PACKAGE setting may indicate a remote package. Durable Functions requires special attention to queues, tables, and blobs depending on the configuration.

The expected output is not an abstract list, but an explicit decision.

text storage-private-endpoint-scope.txt
Required subservices for func-orders-prod
blob  : required for runtime and packages
queue : required because a queue trigger uses the same account
table : required because Durable Functions extension is active
file  : not required if no content is mounted from Azure Files

Decision
Create blob, queue, and table Private Endpoints
Do not create file until the deployment mode requires it
Document this decision in the change note

This decision makes the change readable. It also avoids creating endpoints everywhere without understanding the flows, or missing one and then diagnosing an unstable runtime.

Create Private Endpoints with separate DNS zones

Each Storage subservice uses its own private zone. For Blob, the zone is privatelink.blob.core.windows.net. For Queue, privatelink.queue.core.windows.net. For Table, privatelink.table.core.windows.net. For File, privatelink.file.core.windows.net.

bash 02-create-storage-private-endpoints.sh
export RG_NET=rg-network-prod
export RG_APP=rg-app-prod
export LOCATION=westeurope
export VNET=vnet-app-prod
export PE_SUBNET=snet-private-endpoints
export STORAGE=stfuncordersprod

STORAGE_ID=$(az storage account show -g "$RG_APP" -n "$STORAGE" --query id -o tsv)

for SERVICE in blob queue table; do
az network private-dns zone create   -g "$RG_NET"   -n "privatelink.$SERVICE.core.windows.net"

az network private-dns link vnet create   -g "$RG_NET"   -n "link-$VNET-$SERVICE"   -z "privatelink.$SERVICE.core.windows.net"   -v "$VNET"   -e false

az network private-endpoint create   -g "$RG_NET"   -n "pe-$STORAGE-$SERVICE"   -l "$LOCATION"   --vnet-name "$VNET"   --subnet "$PE_SUBNET"   --private-connection-resource-id "$STORAGE_ID"   --group-id "$SERVICE"   --connection-name "cn-$STORAGE-$SERVICE"

az network private-endpoint dns-zone-group create   -g "$RG_NET"   --endpoint-name "pe-$STORAGE-$SERVICE"   -n "dzg-$SERVICE"   --private-dns-zone "privatelink.$SERVICE.core.windows.net"   --zone-name "privatelink.$SERVICE.core.windows.net"
done

In a real landing zone, private zones may already exist in a DNS hub. In that case, do not recreate them randomly in a spoke. The important point is that the Function resolves Storage names through the private zones actually used by its VNet or resolver.

Test resolution from the Function path

DNS validation must be performed from a context that represents the Function. A VM in the same VNet can provide a first signal, but it does not replace a test from the application context when VNet integration, routes, or resolvers differ.

bash 03-check-storage-dns.sh
nslookup stfuncordersprod.blob.core.windows.net
nslookup stfuncordersprod.queue.core.windows.net
nslookup stfuncordersprod.table.core.windows.net

# Expected result
# stfuncordersprod.blob.core.windows.net
# -> stfuncordersprod.privatelink.blob.core.windows.net
# -> 10.50.20.11
#
# stfuncordersprod.queue.core.windows.net
# -> stfuncordersprod.privatelink.queue.core.windows.net
# -> 10.50.20.12

A public result is not a detail. Until resolution returns the private address from the right path, the runtime is not testing the intended design. It may still work because of a network exception, residual public access, or a path that differs from the documented one.

Do not disable public access before runtime proof

The Storage account should be closed after functional proof. Verify that the Function starts, triggers are synchronized, host logs do not show Storage failures, and the intended deployment path still works.

bash 04-runtime-checks-before-closure.sh
az functionapp show -g rg-app-prod -n func-orders-prod --query '{state:state, hostNames:hostNames, outboundIpAddresses:outboundIpAddresses}'

az functionapp function list -g rg-app-prod -n func-orders-prod --query '[].{name:name, trigger:config.bindings[0].type}'

az webapp log tail -g rg-app-prod -n func-orders-prod

The important messages are not always spectacular. Storage connection errors, trigger synchronization failures, package access failures, or host lock issues must be addressed before public access is closed. Otherwise, the network change becomes the only suspect even though several dependencies were already fragile.

Plan the deployment path

Deployment is often forgotten in private architectures. A Function can be private and work correctly, but no longer receive a package from the CI/CD agent if that agent has no network path to SCM or to the Storage account hosting the package. Decide how deployments will happen after closure.

text deployment-path-options.txt
Option 1: CI/CD agent inside the private network
The agent resolves private names
It reaches SCM or the authorized publication endpoint
Deployment secrets remain limited

Option 2: package prepared then retrieved by the Function
The package Storage path is reachable privately
The runtime can read the package at startup
The package URL or reference does not depend on forgotten public access

Option 3: controlled deployment window
Temporary exception documented
Source range limited
Exception removal verified after publication

The right choice depends on the organization. What is not acceptable is discovering afterwards that the only deployment method used a public access path that was removed without an alternative.

Close the Storage account progressively

Once the proof exists, closure can be applied. The change must be readable: network state of the account, remaining rules, Private Endpoints, DNS, then runtime test after modification.

bash 05-restrict-storage-network.sh
az storage account update -g rg-app-prod -n stfuncordersprod --public-network-access Disabled

az storage account show -g rg-app-prod -n stfuncordersprod --query '{publicNetworkAccess:publicNetworkAccess, defaultAction:networkRuleSet.defaultAction}'

az network private-endpoint-connection list -g rg-app-prod --name stfuncordersprod --type Microsoft.Storage/storageAccounts --query '[].{name:name, status:privateLinkServiceConnectionState.status}'

After closure, replay the checks that matter: Function startup, trigger execution, expected Storage read or write, deployment test if possible, and validation from a network that should no longer access the account.

Diagnose recurring failures

The most common failures follow a few patterns. The blob Private Endpoint exists, but the queue trigger uses a subservice that is not exposed. The private zone exists in the hub, but the Function VNet does not use it. The Storage account is private, but the deployment agent still sits outside the network. A Premium Function has VNet integration, but routing settings do not force the expected traffic to the private network. A firewall exception left during tests gives the impression that the private design works.

text storage-private-troubleshooting.txt
The Function no longer starts
Check AzureWebJobsStorage
Check blob, queue, and table DNS depending on extensions
Read host logs before changing permissions

Triggers do not synchronize
Check the Storage subservice used by the trigger
Validate queue or table, not only blob
Compare DNS tests from a VM and from the Function context

Deployment fails
Identify whether the agent reaches SCM
Check whether the package is read from private Storage
Document the temporary exception if one exists

Everything still works from outside
Look for residual public access
Check Storage firewall and trusted services exceptions
Test from a network explicitly not allowed

This grid keeps layers separate. It avoids turning every incident into a broad Private Endpoint debate when the issue may be a missing subservice, an inconsistent resolver, or a forgotten CI/CD path.

Keep operational evidence

A successful private change should leave a simple trace. It should be readable by a team that did not participate in the change. The evidence should include tested names, private addresses returned, covered subservices, selected deployment mode, and the result after closure.

text function-storage-private-evidence.txt
Change: private Storage for func-orders-prod
Storage account: stfuncordersprod
Privatized subservices: blob, queue, table
DNS zones: privatelink.blob/queue/table.core.windows.net
DNS test from application path: private IPs 10.50.20.11, 10.50.20.12, 10.50.20.13
Runtime: host starts after closure
Queue trigger: execution OK
Deployment: private agent confirmed
Storage public access: disabled
Unauthorized external test: expected failure

This format is more useful than an isolated screenshot. It shows that the design does not rely on an assumption and that the private control has been validated from the runtime side, not only from the network side.

Conclusion

The Storage account attached to an Azure Function is a platform dependency as much as an application resource. Securing it with Private Endpoint therefore requires more than an endpoint and a DNS zone. Identify the subservices really used, prove resolution from the Function path, verify host startup, preserve deployment, and close public access only after validation.

That discipline avoids private architectures that only work while a public exception remains open. It also produces cleaner diagnostics: when the Function fails, the team can distinguish DNS, missing Storage subservice, routing, deployment, and runtime behavior instead of reopening the network blindly.