- 16 hours ago
- 9 min read
Azure Private Subnets: Replacing Default Outbound Access with NAT Gateway
Build a private Azure subnet with no implicit internet access, prove outbound failure, add StandardV2 NAT Gateway, validate deterministic egress and SNAT behavior, and analyze native flow logs.

Introduction
For years, an Azure virtual machine without a public IP could still reach the internet through default outbound access. That platform-managed path was convenient, but it was implicit, non-deterministic and unsuitable as a deliberate production egress design.
Azure changed the default on March 31, 2026. When newer Azure APIs create a virtual network, its subnets default to private and require an explicit outbound method. Existing virtual networks are not retroactively changed. The engineering lesson is bigger than a default: outbound connectivity should be an intentional architecture decision.
This workshop starts with an explicitly private subnet. A private Ubuntu workload VM has no public IP, no inbound management port and no default outbound access. We prove that Azure DNS, WireServer and the VM agent still work while public HTTPS and a controlled observer connection fail. Then we attach a zone-redundant StandardV2 NAT Gateway, run two real 64-connection SNAT rounds, query Azure Monitor metrics and match the traffic in native NAT flow logs.
PowerShell 7 orchestrates Azure CLI and az rest. Every stage is rerunnable, and raw addresses, identifiers, observer responses, keys and KQL rows stay outside the downloadable bundle.
Timing and level
Level: intermediate to advanced.
Guided deployment: about 75–120 minutes.
Core deployment: about 15–25 minutes.
Native NAT flow logs are captured in one-minute windows and typically appear within three minutes; the lab uses a bounded 15-minute poll.
Portal evidence and cleanup: about 20–30 minutes.
Cost warning
This lab creates two Standard_B1s Ubuntu VMs, two Standard SSD OS disks, one Standard observer public IP, one StandardV2 NAT public IP, a StandardV2 NAT Gateway and a Log Analytics workspace. Native StandardV2 NAT flow logging also carries a prorated diagnostic charge documented by Microsoft as USD 4 per month while enabled. Log ingestion and the running VMs add small usage charges.
StandardV2 costs the same as Standard NAT Gateway, but the diagnostic fee is separate. Run cleanup after evidence capture and publication. A NAT Gateway is billed while provisioned even if the test VMs are stopped.
What you will prove
By the end of the workshop, you will have evidence that:
preflight checked authentication, providers, two B1s placements, quota, public-IP capacity, permissions, CLI/API support and name collisions without mutating Azure;
the workload NIC has no public IP and the subnet has defaultOutboundAccess=false;
Azure DNS, WireServer and Run Command still function before internet egress exists;
public HTTPS and observer TCP genuinely fail before explicit SNAT;
StandardV2 NAT Gateway becomes the subnet's outbound path without a user-defined route;
all connections observed after association use the reserved NAT address;
each active 64-connection round receives unique translated source ports;
an immediate second round does not reuse a translated port for the same destination;
Azure Monitor records attempted SNAT connections, zero failed connections and zero unexpected drops;
NatGatewayFlowlogsV1 contains a real row matching the workload, observer and NAT addresses;
the existing regional Network Watcher is untouched.
Architecture
private workload VNet isolated observer VNet
10.90.0.0/16 10.91.0.0/16
private subnet
defaultOutboundAccess=false
|
v
Ubuntu workload VM --X--> public HTTPS / observer (before NAT)
no public IP
no inbound SSH
|
v
zone-redundant StandardV2 NAT Gateway + reserved address
|
+-----------------------> controlled observer on TCP 8443/8444
| source restricted to NAT address
+-----------------------> internet
|
v
native NatGatewayFlowlogsV1 --> Log Analytics
Azure Monitor metrics --------> Attempted / Failed / Dropped
The observer is deliberately placed in another VNet and reached through its public endpoint. Its NSG accepts only TCP 8443 and 8444 from the reserved NAT address; SSH remains blocked. A Python standard-library service returns the source address and translated source port that it sees. The exchange contains only short random test nonces.
Bundle layout
Start-AzurePrivateSubnetWorkshop.ps1
Get-AzurePrivateSubnetEvidence.ps1
Remove-AzurePrivateSubnetWorkshop.ps1
PrivateSubnetWorkshop.Common.ps1
observer/Install-ObserverService.sh
workload/Test-BeforeNat.sh
workload/Invoke-SnatTest.sh
kql/nat-gateway-flow-queries.kql
kql/nat-gateway-metrics.ps1
evidence/
screenshots/
Private runtime data is written beside the bundle under work/azure-private-subnets-nat-gateway. The ZIP never includes that directory.
1. Run the read-only preflight
$subscriptionId = az account show --query id --output tsv
./Start-AzurePrivateSubnetWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-Location westeurope `
-WorkloadVmSize Standard_B1s `
-ObserverVmSize Standard_B1s `
-ConnectionCount 64 `
-FlowLogTimeoutMinutes 15 `
-Stage Preflight
Preflight performs no Azure mutation. It fails closed if the fixed workshop resource group already exists without matching private runtime state. It also refuses to register providers: they must already be registered.
The tested run found 32 free regional cores, 20 free B-series-family cores, ten public-IP slots, B1s availability and no resource-group collision.
2. Build the foundation and controlled observer
./Start-AzurePrivateSubnetWorkshop.ps1 -SubscriptionId $subscriptionId -Stage Foundation
./Start-AzurePrivateSubnetWorkshop.ps1 -SubscriptionId $subscriptionId -Stage Observer
Foundation creates the tagged resource group, Log Analytics workspace, workload and observer VNets, deny-by-default NSGs and a private SSH key used only for VM provisioning. No SSH rule is created.
Observer reserves the StandardV2 public IP before creating the NAT Gateway. That lets the observer NSG be restricted to the final deterministic source from the beginning. A separate Standard public IP is attached to the observer VM, and Run Command starts the Python service on 8443 and 8444.
class ObserverHandler(socketserver.BaseRequestHandler):
def handle(self):
peer_ip, peer_port = self.client_address
response = {
"peerIp": peer_ip,
"peerPort": peer_port,
"listenerPort": self.server.server_address[1]
}
self.request.sendall((json.dumps(response) + "\n").encode())
The full service keeps each test connection open until the client releases the round. That means source-port uniqueness is measured while all 64 translations are active, not after sockets have already closed.
3. Create the explicitly private subnet
./Start-AzurePrivateSubnetWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-Stage PrivateSubnet
The important creation flag is explicit:
az network vnet subnet create `
--resource-group rg-private-egress-deepdive-we `
--vnet-name vnet-private-egress-we `
--name snet-private-workload `
--address-prefixes 10.90.1.0/24 `
--network-security-group nsg-private-workload `
--default-outbound-access false
The VM is built from Ubuntu 24.04 LTS with a private NIC only. Azure can still provision it because platform communication does not depend on public internet SNAT.


4. Prove the boundary before NAT
./Start-AzurePrivateSubnetWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-Stage NoEgressProof
The test separates five signals:
Azure DNS lookup expected: success
WireServer TCP on platform IP expected: success
Azure Linux VM agent expected: active
public HTTPS expected: blocked
observer TCP 8443 expected: blocked
Azure's special platform virtual address provides DNS and VM-agent communication and is not treated like ordinary internet egress. A successful Run Command therefore does not prove that the VM has internet access. Conversely, seeing the system 0.0.0.0/0 Internet route does not mean implicit SNAT exists. The private-subnet setting removes default outbound access; the route remains available for an explicit method such as NAT Gateway.

5. Attach StandardV2 NAT Gateway and native logs
./Start-AzurePrivateSubnetWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-Stage NatGateway
The script creates a StandardV2 NAT Gateway, attaches the reserved StandardV2 public IP and associates it with the workload subnet. No UDR is required: NAT Gateway uses the subnet's system default route to perform SNAT.
az network nat gateway create `
--resource-group rg-private-egress-deepdive-we `
--name natgw-private-egress-we `
--location westeurope `
--sku StandardV2 `
--public-ip-addresses pip-natgw-egress-we `
--idle-timeout 10
Do not hard-code the native diagnostic category. The tested Azure surface exposed NatGatewayFlowlogsV1, while Microsoft documentation and other surfaces vary in capitalization. The script discovers the available category and sends it to Log Analytics with resource-specific tables enabled.



6. Run the deliberate SNAT test
./Start-AzurePrivateSubnetWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-ConnectionCount 64 `
-Stage SnatTest
This is not a single curl to an IP-check service. The workload creates 64 concurrent TCP connections to the controlled observer and holds them open. It repeats the same destination immediately for a second 64-connection round.
The verifier requires:
64 successes in round one and 64 in round two;
one observed source address, equal to the reserved NAT address;
64 unique translated source ports in each active round;
zero port overlap between the immediate rounds for the same destination.
The final check demonstrates NAT Gateway's same-destination cooldown behavior. A closed port may be available sooner for a different destination, but should not be immediately reused for the same destination tuple.
The live workshop run passed all four boundaries.

7. Query Azure Monitor metrics correctly
NAT Gateway metrics are multidimensional. Requesting SNATConnectionCount without a wildcard dimension filter can return one unsplit aggregate. The workshop explicitly asks Azure Monitor for ConnectionState and Protocol:
az monitor metrics list `
--resource $natGatewayResourceId `
--metric SNATConnectionCount `
--aggregation Total `
--interval PT1M `
--filter "ConnectionState eq '*' and Protocol eq '*'"
The proof requires attempted connections above zero, failed connections equal to zero and PacketDropCount equal to zero. Platform health checks can add a small number of attempted connections, so the acceptance test does not pretend the metric total must equal exactly 128.

8. Match a native flow-log row
./Start-AzurePrivateSubnetWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-FlowLogTimeoutMinutes 15 `
-Stage FlowLogs
The local Azure CLI Log Analytics extension was not used for the final proof because its Python dependency was broken on the test machine. The workshop instead calls the official Log Analytics REST endpoint with az rest, which also makes response parsing deterministic by column name.
The live table used uppercase SourceIP, DestinationIP and NatGatewayIP, while the reference schema shows mixed-case SourceIp, DestinationIp and NatGatewayIp. The KQL normalizes either form:
NatGatewayFlowlogsV1
| where TimeGenerated > ago(2h)
| extend SourceIpNormalized = iff(
isnotempty(tostring(column_ifexists("SourceIP", ""))),
tostring(column_ifexists("SourceIP", "")),
tostring(column_ifexists("SourceIp", "")))
| where SourceIpNormalized == WorkloadPrivateAddress
| where DestinationIpNormalized == ObserverPublicAddress
| where NatGatewayIpNormalized == ReservedNatAddress
| summarize FlowRows=count(),
SentDrops=sum(tolong(PacketsSentDropped)),
ReceivedDrops=sum(tolong(PacketsReceivedDropped))
Raw rows and addresses remain private. Public evidence reports only that a match exists, its safe aggregate row count and that unexpected drops equal zero.


9. Validate and collect sanitized evidence
./Start-AzurePrivateSubnetWorkshop.ps1 -SubscriptionId $subscriptionId -Stage Validate
./Get-AzurePrivateSubnetEvidence.ps1 -SubscriptionId $subscriptionId
Validation rereads the subnet, NIC, NAT Gateway, diagnostic setting and Network Watcher inventory. The public evidence file contains no subscription or tenant IDs, resource IDs, public or private addresses, SSH material, raw observer records or raw KQL rows.
You can also run every deployment and test stage in order:
./Start-AzurePrivateSubnetWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-Stage All
Troubleshooting map
| Symptom | Check | Likely fix | |---|---|---| | VM deployment stalls | VM agent and platform access | Confirm the NSG does not block Azure platform traffic | | Public HTTPS works before NAT | Subnet defaultOutboundAccess | Recreate the subnet explicitly private; do not reuse a legacy subnet | | Observer fails after NAT | Observer NSG source and service | Confirm the reserved NAT address is the only allowed source and the Python service is active | | Source is not deterministic | Subnet NAT association | Confirm the workload subnet references the intended NAT Gateway | | Metrics are unsplit | Metric dimensions | Add wildcard filters for ConnectionState and Protocol | | Diagnostic setting rejects JSON | CLI JSON shape | Pass a one-element JSON array, not a collapsed object | | KQL cannot resolve SourceIp | Live table casing | Use column_ifexists to normalize SourceIP and SourceIp | | Flow rows are delayed | One-minute capture window | Wait up to the bounded 15-minute timeout | | Run Command output loses its marker | Output too large | Validate raw connections in memory and emit a compact summary |
Production hardening
This lab proves one explicit-egress pattern; production design still needs policy and operations around it.
Use Azure Policy to audit private subnets without an approved outbound path.
Decide whether NAT Gateway, Azure Firewall, a secured virtual hub or forced tunneling is the correct egress control for each subnet.
Add DNS controls, threat filtering and destination governance where NAT alone is insufficient.
Alert on failed SNAT connections, packet drops, datapath availability and port-utilization trends.
Send native logs to a workspace with retention, access control and cost governance appropriate for the environment.
Use multiple NAT public IPs or a public IP prefix when scale requires more SNAT ports.
Keep management private through Run Command, Bastion, private connectivity or an approved privileged-access path.
Avoid adding a 0.0.0.0/0 UDR to a virtual appliance unless forced tunneling is intentional; it bypasses NAT Gateway.
Treat deterministic egress as an allow-list primitive, not as a firewall or exfiltration control by itself.
Evidence
The downloadable ZIP contains the reusable scripts, observer and workload code, KQL, sanitized evidence, screenshots, this article, the publishing brief and the live-test report. It excludes private runtime state.
The tested result was:
Pre-NAT Azure DNS / WireServer / VM agent PASS
Pre-NAT public HTTPS BLOCKED AS EXPECTED
Pre-NAT observer TCP BLOCKED AS EXPECTED
Round 1 64 / 64
Round 2 64 / 64
Unique active translated ports 64 + 64
Immediate same-destination reuse 0
Deterministic reserved source PASS
Attempted SNAT metric PRESENT
Failed connections 0
Dropped packets 0
Matching native flow-log row PRESENT
Network Watcher baseline UNCHANGED
Download the workshop
The archive includes the orchestrator, cleanup and evidence scripts, observer and workload code, KQL, all sanitized screenshots, the live-test report, and the integrity manifest. Private runtime data is excluded.
Cleanup
Cleanup validates the fixed resource-group name and both ownership tags before deletion. It removes only the workshop resource group, waits until Azure reports it absent, preserves the regional Network Watcher, writes sanitized cleanup evidence and removes the exact private runtime directory.
./Remove-AzurePrivateSubnetWorkshop.ps1 `
-SubscriptionId $subscriptionId `
-Force
The bundle and published article remain under outputs; private keys, addresses, IDs and raw records are removed with work/azure-private-subnets-nat-gateway.
Final result
You now have a repeatable proof that a private Azure subnet can keep Azure platform communication while denying implicit public egress, and that StandardV2 NAT Gateway restores outbound connectivity with a stable source, observable SNAT behavior, metrics and native flow logs.
The important design change is not merely attaching a NAT Gateway. It is moving from accidental outbound access to an explicit, testable and monitored egress contract.
Comments