Why This Article Isn't About the App
MYRIX is an AI-native competitive action-strategy game built with Flutter and the Flame engine — real, playable, running today on Firebase Hosting. But this article isn't about the game. It's about a question every previous article in this series has quietly skipped: how does traffic actually get to a VM, and how much of that path should be exposed to the internet at all?
Every prior Azure article in this series (Container Apps, App Service, a plain VM) put the compute resource directly behind a public IP or a managed platform's own ingress. This one does neither. MYRIX's container runs on a VM with no public IP whatsoever — not for the app, not for SSH. Every drop of traffic that reaches it, whether that's a player's browser or an engineer's terminal, passes through a piece of Azure networking built specifically to carry it there. That's the actual subject: VNets, subnets, peering, Bastion, and a Load Balancer health probe that does real monitoring work before Azure Monitor ever gets involved.
Before Step 1, the concepts this design leans on:
- Hub-spoke topology — one central VNet (the hub) holds shared infrastructure (here, Azure Bastion); one or more spoke VNets hold workloads (here, the MYRIX VM). Spokes don't talk to each other directly; everything routes through, or is managed from, the hub. It's the same pattern a real Azure landing zone uses at much larger scale — this article builds the smallest version of it that's still structurally real.
- VNet peering — a private, Microsoft-backbone connection between two VNets, as if they were one network, without any traffic touching the public internet. Peering is not automatically symmetric: each VNet needs its own peering resource pointing at the other.
- Azure Bastion — a managed jump-host service. Instead of giving a VM a public IP so you can SSH into it, Bastion sits in the hub VNet with its own public IP, and tunnels an SSH session from your terminal, through Azure's backbone, to the VM's private IP. The VM's SSH port is never internet-facing.
- Load Balancer health probe — a Standard Load Balancer doesn't just forward traffic; it repeatedly checks a URL path on each backend instance and only routes traffic to instances that are currently passing. This is networking-layer health monitoring, distinct from and earlier than anything Azure Monitor does.
Here's the full shape of it before any command runs:
INTERNET
|
v
+----------------------------+
| Standard Load |
| Balancer |
| :80 -> /healthz probe |
+----------------------------+
|
v
+----------------------------+
| SPOKE VNET |
| 10.1.0.0/16 |
| |
| snet-web 10.1.0.0/24 |
| | |
| v |
| +------------------+ |
| | vm-myrix | |
| | private IP | |
| | :80 :22 | |
| +------------------+ |
+----------------------------+
^
|
VNet Peering
|
+----------------------------+
| HUB VNET |
| 10.0.0.0/16 |
| |
| AzureBastionSubnet |
| 10.0.0.0/26 |
| | |
| v |
| Azure Bastion |
+----------------------------+
^
|
Engineer SSH
(via Bastion tunnel)
MONITORING
vm-myrix -> AMA -> DCR -> Log Analytics
Two paths reach this deployment, and they never cross: internet traffic comes in only through the Load Balancer on port 80; management access comes in only through Bastion, tunneled to port 22. Neither path ever touches a public IP that belongs to the VM itself — it doesn't have one.
Every command below was run against a live subscription and verified before being written here — including one real failure, caught and fixed during the container build, documented in Step 5 rather than smoothed over.
Step 1 — Resource group and two non-overlapping VNets
RG=myrix-net-rg
LOC=eastus
az group create --name $RG --location $LOC
az network vnet create --resource-group $RG --name vnet-hub --location $LOC \
--address-prefixes 10.0.0.0/16 \
--subnet-name AzureBastionSubnet --subnet-prefixes 10.0.0.0/26
az network vnet create --resource-group $RG --name vnet-spoke --location $LOC \
--address-prefixes 10.1.0.0/16 \
--subnet-name snet-web --subnet-prefixes 10.1.0.0/24
Why the subnet must be named exactly AzureBastionSubnet: Azure Bastion refuses to deploy into a subnet with any other name — this isn't a convention, it's a hard requirement enforced at deployment time.
Why the two VNets use non-overlapping address ranges (10.0.0.0/16 vs 10.1.0.0/16): peered VNets route traffic between each other based on IP address ranges. If both VNets used the same range, Azure would have no way to tell which network a given private IP actually belongs to, and peering would fail outright.
How to check that without doing binary math: the number after the slash (the CIDR prefix) says how many of the 32 bits in an IPv4 address are fixed as the "network" part — the rest are free to vary as host addresses. A smaller number after the slash means fewer fixed bits and more available addresses: /16 fixes the first 16 bits (the first two octets) and leaves 65,536 addresses free; /26, used for AzureBastionSubnet below, fixes 26 bits and leaves only 64 — plenty for a subnet that doesn't host application VMs.
For two /16 ranges, the easiest way to confirm they don't overlap is to just compare the second octet: 10.0.0.0/16 covers every address from 10.0.0.0 to 10.0.255.255; 10.1.0.0/16 covers 10.1.0.0 to 10.1.255.255. Those never share an address, because the second octet (0 vs 1) already differs — the last two octets don't even need checking. The general rule for planning a set of VNets this way: give each one a different value in whichever octet your prefix fixes. With /16s starting 10.x.0.0, that's up to 256 non-overlapping VNets (10.0 through 10.255) without ever needing a calculator — just confirm the fixed part of the address differs between any two ranges you're about to peer.
Step 2 — Peer the VNets in both directions
az network vnet peering create --resource-group $RG --name hub-to-spoke \
--vnet-name vnet-hub --remote-vnet vnet-spoke --allow-vnet-access
az network vnet peering create --resource-group $RG --name spoke-to-hub \
--vnet-name vnet-spoke --remote-vnet vnet-hub --allow-vnet-access
Why this is two commands, not one: peering is defined per-VNet, not per-pair. hub-to-spoke tells vnet-hub it can reach vnet-spoke; without the second command, that permission would only exist in one direction, and a resource in the spoke would have no route back to anything in the hub — including, critically, Bastion.
Step 3 — An NSG that only trusts Bastion for SSH
az network nsg create --resource-group $RG --name nsg-web
az network nsg rule create --resource-group $RG --nsg-name nsg-web -n Allow-HTTP-Inbound \
--priority 100 --direction Inbound --access Allow --protocol Tcp \
--source-address-prefixes Internet --destination-port-ranges 80
az network nsg rule create --resource-group $RG --nsg-name nsg-web -n Allow-SSH-From-Bastion \
--priority 110 --direction Inbound --access Allow --protocol Tcp \
--source-address-prefixes 10.0.0.0/26 --destination-port-ranges 22
az network vnet subnet update --resource-group $RG --vnet-name vnet-spoke --name snet-web \
--network-security-group nsg-web
Why the SSH rule's source is 10.0.0.0/26 and not Internet: that CIDR is exactly the AzureBastionSubnet range from Step 1 — nothing else. Since the VM has no public IP at all (confirmed in Step 4), this rule is less about stopping internet attackers, who couldn't reach port 22 either way, and more about making the intended access path explicit and enforced at the network layer rather than relying on "well, there's no public IP" as the only control.
A second real bug, caught only after the Load Balancer was wired up in Step 6: curling the Load Balancer's public IP on /healthz timed out completely, even though the container was confirmed running and answering locally on the VM. The cause was a second NSG this article hadn't accounted for — az vm create in Step 4, run without an explicit --nsg flag, silently provisions its own default NSG directly on the VM's network interface, separate from nsg-web attached to the subnet here. That NIC-level NSG only had a default SSH rule; traffic on port 80 was allowed by nsg-web at the subnet but blocked by the NIC's own NSG, and Azure evaluates both — a request only gets through if every NSG in the path allows it. The fix was replacing the NIC's auto-created NSG with nsg-web itself, so the same rule set governs both layers:
az network nic update --resource-group $RG --name vm-myrixVMNic --network-security-group nsg-web
curl http://<lb-public-ip>/healthz returned ok immediately after. The lesson generalizes past this one command: a VM's effective inbound rules are the intersection of every NSG attached anywhere along the path — subnet and NIC both — not just the one you remembered attaching.
Step 4 — Azure Bastion, and a VM with no public IP
az network public-ip create --resource-group $RG --name pip-bastion --sku Standard --location $LOC
az network bastion create --resource-group $RG --name bastion-myrix --location $LOC \
--vnet-name vnet-hub --public-ip-address pip-bastion
Bastion takes several minutes to provision — it's a managed PaaS resource, not a VM you can inspect mid-deploy.
az vm create --resource-group $RG --name vm-myrix \
--image Ubuntu2204 --size Standard_B2s \
--vnet-name vnet-spoke --subnet snet-web \
--public-ip-address "" \
--admin-username azureuser --generate-ssh-keys \
--location $LOC
Why --public-ip-address "" matters more than it looks: az vm create allocates a public IP by default if you don't override it. Passing an explicit empty string is the only way to opt out — forgetting this flag would silently undo the entire point of putting Bastion in front of the VM.
To actually reach the VM for management, Bastion needs its native-client tunneling feature turned on (off by default) before a local terminal can tunnel through it:
az network bastion update --resource-group $RG --name bastion-myrix --enable-tunneling true
az network bastion tunnel --resource-group $RG --name bastion-myrix \
--target-resource-id $(az vm show --resource-group $RG --name vm-myrix --query id -o tsv) \
--resource-port 22 --port 2222
That second command blocks, holding open a local port 2222 that forwards through Bastion to the VM's SSH port. In a second terminal, ssh azureuser@localhost -p 2222 lands on the VM — having gone through Azure's backbone the entire way, never touching a public IP that belongs to the VM.
Step 5 — Building the image, and a real bug caught by the build itself
ACR_NAME=myrixacr$RANDOM
az acr create --resource-group $RG --name $ACR_NAME --sku Basic --admin-enabled false
az acr build --registry $ACR_NAME --image myrix:latest .
The Dockerfile is a two-stage build — a cirruslabs/flutter image runs flutter build web --release, and an nginx:alpine runtime stage serves the compiled output:
FROM ghcr.io/cirruslabs/flutter:3.38.4 AS build
WORKDIR /app
COPY pubspec.yaml pubspec.lock ./
RUN flutter pub get
COPY . .
RUN flutter build web --release
FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/build/web /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
The first az acr build run actually failed, with a real Dart compile error:
lib/components/gem_component.dart:143:63:
Error: 'Color' isn't a type.
lib/components/gem_component.dart:145:18:
Error: The method 'Path' isn't defined for the type 'GemComponent'.
That's confusing on its face — gem_component.dart clearly imports package:flutter/material.dart, which exports both Color and Path, and the exact same file compiled cleanly on a local flutter build web --release run before this. The difference was the build context sent to ACR: there was no .dockerignore, so COPY . . copied the local machine's own .dart_tool/package_config.json — a file flutter pub get had already generated fresh inside the container one step earlier — clobbering it with a version full of absolute paths to this Mac's local Flutter SDK and pub cache. Inside the container, those paths don't exist, package resolution silently breaks, and the compiler reports material.dart's own exports as undefined types. The fix was a .dockerignore excluding .dart_tool, build, and .git:
.dart_tool
build
.git
.idea
*.iml
Rebuilding after that fix succeeded. This is the same class of lesson as the ORIN article's import.meta bug — a failure that only exists because two build environments (local machine, remote container) silently diverged, and the fix is making sure they can't.
Step 6 — A Standard Load Balancer whose health probe is the first monitor
az network public-ip create --resource-group $RG --name pip-lb --sku Standard --location $LOC
az network lb create --resource-group $RG --name lb-myrix --location $LOC --sku Standard \
--public-ip-address pip-lb --frontend-ip-name feip-myrix --backend-pool-name beap-myrix
az network lb probe create --resource-group $RG --lb-name lb-myrix -n probe-healthz \
--protocol Http --port 80 --path /healthz
az network lb rule create --resource-group $RG --lb-name lb-myrix -n rule-http \
--protocol Tcp --frontend-port 80 --backend-port 80 \
--frontend-ip-name feip-myrix --backend-pool-name beap-myrix --probe-name probe-healthz
NIC_NAME=$(basename $(az vm show --resource-group $RG --name vm-myrix --query "networkProfile.networkInterfaces[0].id" -o tsv))
az network nic ip-config address-pool add --resource-group $RG --nic-name $NIC_NAME \
--ip-config-name ipconfig-vm-myrix --lb-name lb-myrix --address-pool beap-myrix
The nginx.conf shipped in the container defines the probe's target explicitly:
location /healthz {
access_log off;
return 200 "ok\n";
}
Why this is a networking concept and not just a monitoring one: the Load Balancer polls GET /healthz on the VM every few seconds by default, entirely on its own, with no Azure Monitor alert or human involved. An instance that stops answering gets pulled out of rotation automatically — this is the first layer of health handling, and it happens at the load-balancing layer before any metric, log, or alert ever fires. This is also the only path that exposes anything about MYRIX to the public internet at all: port 80, and only port 80, on the Load Balancer's public IP, forwarded to the VM's private IP inside the spoke VNet.
Step 7 — VM-level monitoring: Log Analytics, the Azure Monitor Agent, and a Data Collection Rule
az monitor log-analytics workspace create --resource-group $RG --workspace-name law-myrix
az vm extension set --resource-group $RG --vm-name vm-myrix \
--name AzureMonitorLinuxAgent --publisher Microsoft.Azure.Monitor
Installing the extension alone doesn't send anything anywhere. The Azure Monitor Agent needs to be told what to collect and where to send it — that's a separate resource, a Data Collection Rule (DCR), which has to exist and be explicitly associated with the VM before any telemetry actually reaches the workspace:
cat > dcr-myrix.json <<EOF
{
"location": "$LOC",
"properties": {
"dataSources": {
"performanceCounters": [
{
"name": "perfCounterDataSource",
"streams": ["Microsoft-Perf"],
"samplingFrequencyInSeconds": 60,
"counterSpecifiers": [
"\\Processor(_Total)\\% Processor Time",
"\\Memory(*)\\% Used Memory"
]
}
]
},
"destinations": {
"logAnalytics": [
{ "workspaceResourceId": "$(az monitor log-analytics workspace show --resource-group $RG --workspace-name law-myrix --query id -o tsv)", "name": "law-destination" }
]
},
"dataFlows": [
{ "streams": ["Microsoft-Perf"], "destinations": ["law-destination"] }
]
}
}
EOF
az monitor data-collection rule create --resource-group $RG --name dcr-myrix --rule-file dcr-myrix.json
az monitor data-collection rule association create --name dcr-assoc-myrix \
--rule-id $(az monitor data-collection rule show --resource-group $RG --name dcr-myrix --query id -o tsv) \
--resource $(az vm show --resource-group $RG --name vm-myrix --query id -o tsv)
The actual chain is VM → Azure Monitor Agent → Data Collection Rule → Log Analytics workspace, not "install the agent, data appears." The DCR here defines one stream (Microsoft-Perf, carrying CPU and memory counters) and one destination (law-myrix); a production setup would typically add Syslog or additional counter streams to the same rule rather than creating a second one.
Why this is a second, separate layer from Step 6's probe: the Load Balancer's probe answers one binary question — "is port 80 responding right now?" It has no idea whether the VM is at 95% memory, whether disk space is running low, or what the CPU trend looked like over the last six hours. This DCR-routed telemetry is what turns "up or down" into a queryable history in Log Analytics.
Step 8 — Alerts on both signals
VM_ID=$(az vm show --resource-group $RG --name vm-myrix --query id -o tsv)
az monitor metrics alert create --resource-group $RG --name alert-vm-cpu-high \
--scopes $VM_ID --condition "avg Percentage CPU > 85" \
--description "MYRIX VM sustained high CPU" --window-size 5m --evaluation-frequency 5m
LB_ID=$(az network lb show --resource-group $RG --name lb-myrix --query id -o tsv)
az monitor metrics alert create --resource-group $RG --name alert-lb-unhealthy \
--scopes $LB_ID --condition "avg DipAvailability < 100" \
--description "MYRIX backend failing /healthz probe" --window-size 5m --evaluation-frequency 1m
Why DipAvailability is the more urgent alert of the two: it's the Load Balancer's own metric for "percentage of backend instances currently passing their health probe." Alerting on it dropping below 100% catches the exact moment /healthz stops answering — independent of whether CPU, memory, or any other VM-level metric still looks perfectly normal. A container can hang or deadlock without ever spiking CPU; this alert catches that failure mode, the VM-level one doesn't.
Closing Thoughts
Every other article in this series exposed a compute resource to the internet more or less directly — a Container App's own ingress, an App Service's default hostname, a VM with a public IP and an open port. This one deliberately didn't. The only thing reachable from the internet at all is port 80 on a Load Balancer's public IP; the VM itself, and the SSH access needed to manage it, sit entirely inside a private network that only Bastion can reach. That's not extra complexity for its own sake — it's what "networking concepts" actually buys an application: a much smaller, more deliberate surface area than "give everything a public IP and lock it down with firewall rules after the fact."
GitHub Repository: myrix — the real Flutter/Flame game repository, now with the Dockerfile, nginx health endpoint, and this article's complete networking setup documented in docs/AZURE_NETWORKING.md.
Reviewed against current Azure CLI (az network vnet, az network bastion, az network lb, az monitor) as of September 2026.
Azure Networking · VNet Peering · Azure Bastion · Load Balancer · Azure Monitor · Hub-Spoke Topology
Originally published on my portfolio.
Top comments (0)