Hub-spoke VNet topology, NSG design, Standard Load Balancer, private endpoints with split-horizon DNS, Application Gateway with WAF, and Azure Bastion — production network patterns built and debugged from scratch using Azure CLI.
Built a hub-spoke VNet topology with two networks connected via bidirectional
peering. The hub holds shared services — Bastion, DNS — while each spoke
is an independent blast radius. All VMs deployed with no public IPs;
access via az vm run-command for automation and Bastion for
interactive sessions. NSGs attached at subnet level, not NIC level, so
all future resources in the subnet inherit the same rules automatically.
VNet peering is not a single connection — it requires two separate objects:
Hub→Spoke AND Spoke→Hub. A single-direction peering shows
peeringState: Initiated, not Connected.
Traffic does not flow until both are created, both with
allowForwardedTraffic: true.
Azure SDN handles peered VNet routes at the hypervisor layer. The guest OS routing table will never show peering routes — they will always appear missing even when peering is fully functional. Always test with actual connectivity: ping a real VM IP across the peering, never the subnet gateway.
--public-ip-address "" at creation time.
Eliminates the attack surface entirely. All access routes through Bastion (interactive) or run-command
(automation).az network vnet subnet create --nsg attaches the NSG to the subnet. Protects all
resources in the subnet including future additions. Single management point vs. per-VM NIC rules.
AzureLoadBalancer,
VirtualNetwork, Internet rather than hardcoded IP ranges. Microsoft
maintains service tags automatically — hardcoded IPs break when Azure infrastructure changes.
Internet blocks public traffic only.
* blocks everything including Bastion connections through the VNet. Using *
to deny RDP/SSH would silently block Bastion.
Deployed a Standard SKU Load Balancer distributing HTTP traffic across two Ubuntu VMs running nginx, each in separate fault domains via an availability set. The Standard SKU required explicit configuration of things Basic SKU provided implicitly — health probe NSG rules and outbound SNAT. Four separate issues were encountered and resolved during this project, each genuinely educational.
http:// explicitly when testing HTTP-only backends.
AzureLoadBalancer service tag on port 80, probes are silently dropped. Both VMs show
as
Unhealthy and the LB drops all traffic. Basic SKU allowed this implicitly; Standard does not.
apt install nginx appeared to succeed but silently failed.
systemctl status nginx revealed the service didn't exist. Fix: second public IP,
frontend
IP config, outbound rule.
for i in {1..20}; do curl -s http://<LB-IP> | grep "Served by"; done | sort | uniq -c
Without outbound SNAT, apt install nginx printed connection warnings but reported
success — exit code 0. Never trust package manager exit status alone on a newly provisioned VM.
Always
verify with systemctl status nginx.
Removed Azure Blob Storage from the public internet without changing
application hostname — using a private endpoint and split-horizon DNS.
VMs inside the VNet resolve the storage hostname to a private IP
(10.0.1.6) via the private DNS zone. Public internet
clients receive a 404 WebContentNotFound response — not
a 403, by design. Azure Storage does not acknowledge the endpoint
exists when public access is disabled. A 403 would reveal the resource
is there. A 404 reveals nothing.
| Source | Resolves to | Result |
|---|---|---|
| vm-web-01 (vnet-spoke) | 10.0.1.6
|
✓ Private access |
| vm-hub-test (vnet-hub) | 10.0.1.6
|
✓ Private access |
| Public internet | Public IP (overridden) | ✗ 404 — resource not acknowledged |
privatelink.blob.core.windows.net.
Common mistake: creating the private DNS zone with a custom name. Azure requires the exact mandated name per service type or the private endpoint NIC's A record will not be auto-registered. Always verify the zone name matches the Private Link DNS zone table before deployment.
Deployed Azure Application Gateway (Standard_v2) as an L7 load balancer
with WAF policy in front of the web subnet, and Azure Bastion for
secure browser-based VM access with no public IPs required.
Both services have strict subnet requirements — Application Gateway
requires a dedicated subnet with three specific NSG rules or provisioning
fails; Bastion requires a subnet named exactly AzureBastionSubnet
with inline NSG rules.
NSG rules for AzureBastionSubnet must be defined inside
properties.securityRules: [...] on the NSG resource, not as separate child
resources.
Separate child resources deploy in parallel and may not be complete when the compliance
check runs
at
subnet attachment time.
The subnet must be named exactly AzureBastionSubnet — no CAF naming, no
variations.
Minimum size /26. Any deviation and Bastion will not provision regardless of all other
configuration
being correct.
deployBastionHost bool parameter in Bicep allows the NSG, subnet, and all
rules to
deploy in place while the host itself is only created when needed. Cost control pattern
for lab
environments.AzureBastionSubnet
range
(10.0.7.0/26). No clear error is surfaced — the connection simply fails
silently.
.1 address in each subnet (e.g., 10.0.1.1) is an Azure
infrastructure address that never responds to ICMP. A common first debugging step is pinging the gateway to
test connectivity — it will always time out even when everything is working. Always test peering by pinging
a VM's private IP.securityRules: [...] on the NSG resource deploy atomically and pass the compliance check
reliably every time.
apt install can exit 0 while silently failing to download packages when
there's no outbound internet access. The Standard LB SNAT issue was only discovered via
systemctl status nginx — the install appeared successful. Always verify service state after
package installation on a newly provisioned VM.