Milestone 1: VNet & VM Setup
Objective
Establish the network and compute foundation for a multi-tier Azure environment. This first milestone creates a Virtual Network with three purpose-built subnets (web, app, management), deploys two Ubuntu 22.04 VMs in the web and app tiers with public SSH access restricted to a jump host pattern, assigns static private IPs, configures basic NSG rules, and verifies cross-subnet connectivity. All steps use Azure CLI for reproducibility.
Tools & Technologies
Azure Virtual Network— isolated network environmentAzure Subnets— logical network segmentationAzure Virtual Machines— Ubuntu 22.04 LTS computeAzure Network Interface (NIC)— VM network attachmentsAzure Public IP— internet-facing address for jump hostAzure NSG— inbound/outbound security rulesAzure CLI 2.x— command-line resource managementcloud-init— VM first-boot configuration scriptSSH— VM access and connectivity verification
Architecture Overview
Step-by-Step Process
Created the resource group and VNet with the three subnets in a single CLI command, establishing the network foundation before any compute resources.
# Environment variables
RG="rg-milestone1"
LOCATION="canadacentral"
VNET="vnet-lab"
# Create resource group
az group create --name $RG --location $LOCATION
# Create VNet with address space
az network vnet create \
--resource-group $RG \
--name $VNET \
--address-prefix 10.10.0.0/16 \
--subnet-name snet-web \
--subnet-prefix 10.10.1.0/24
# Add remaining subnets
az network vnet subnet create \
--resource-group $RG --vnet-name $VNET \
--name snet-app --address-prefix 10.10.2.0/24
az network vnet subnet create \
--resource-group $RG --vnet-name $VNET \
--name snet-mgmt --address-prefix 10.10.3.0/24
# Verify
az network vnet show --resource-group $RG --name $VNET \
--query "subnets[].{Name:name, Prefix:addressPrefix}" --output table
Created NSGs for each subnet tier with appropriate inbound rules: web tier allows HTTP/HTTPS from internet, app tier allows only traffic from web subnet, management allows SSH only from a known IP.
# Web NSG — allow HTTP/HTTPS from internet
az network nsg create --resource-group $RG --name nsg-web
az network nsg rule create --resource-group $RG --nsg-name nsg-web \
--name Allow-HTTP --priority 100 --direction Inbound \
--protocol Tcp --destination-port-range 80 --access Allow \
--source-address-prefix Internet
az network nsg rule create --resource-group $RG --nsg-name nsg-web \
--name Allow-HTTPS --priority 110 --direction Inbound \
--protocol Tcp --destination-port-range 443 --access Allow \
--source-address-prefix Internet
# App NSG — allow only from web subnet
az network nsg create --resource-group $RG --name nsg-app
az network nsg rule create --resource-group $RG --nsg-name nsg-app \
--name Allow-From-Web --priority 100 --direction Inbound \
--protocol Tcp --destination-port-range 8080 --access Allow \
--source-address-prefix 10.10.1.0/24
# Mgmt NSG — SSH from admin IP only
az network nsg create --resource-group $RG --name nsg-mgmt
az network nsg rule create --resource-group $RG --nsg-name nsg-mgmt \
--name Allow-SSH-Admin --priority 100 --direction Inbound \
--protocol Tcp --destination-port-range 22 --access Allow \
--source-address-prefix "YOUR_ADMIN_IP/32"
# Associate NSGs with subnets
for subnet in snet-web:nsg-web snet-app:nsg-app snet-mgmt:nsg-mgmt; do
SN="${subnet%%:*}"
NSG="${subnet##*:}"
az network vnet subnet update --resource-group $RG --vnet-name $VNET \
--name $SN --network-security-group $NSG
done
Deployed three VMs with static private IP assignments, the correct subnet placement, and cloud-init scripts to install their respective applications on first boot.
# Jump host VM (with public IP, management subnet)
az network public-ip create --resource-group $RG --name pip-jump --sku Standard
az vm create \
--resource-group $RG \
--name jump-vm \
--image Ubuntu2204 \
--size Standard_B1s \
--vnet-name $VNET \
--subnet snet-mgmt \
--nsg nsg-mgmt \
--public-ip-address pip-jump \
--private-ip-address 10.10.3.4 \
--admin-username azureadmin \
--ssh-key-values ~/.ssh/azure_lab.pub
# Web VM (no public IP, web subnet)
az vm create \
--resource-group $RG \
--name web-vm \
--image Ubuntu2204 \
--size Standard_B2s \
--vnet-name $VNET \
--subnet snet-web \
--nsg nsg-web \
--public-ip-address '""' \
--private-ip-address 10.10.1.4 \
--admin-username azureadmin \
--ssh-key-values ~/.ssh/azure_lab.pub \
--custom-data @cloud-init-web.yaml
# App VM (no public IP, app subnet)
az vm create \
--resource-group $RG \
--name app-vm \
--image Ubuntu2204 \
--size Standard_B2s \
--vnet-name $VNET \
--subnet snet-app \
--nsg nsg-app \
--public-ip-address '""' \
--private-ip-address 10.10.2.4 \
--admin-username azureadmin \
--ssh-key-values ~/.ssh/azure_lab.pub
Used cloud-init to automatically install Apache2 on the web VM at first boot, eliminating the need for manual post-deployment configuration.
# cloud-init-web.yaml
#cloud-config
package_update: true
package_upgrade: false
packages:
- apache2
- curl
runcmd:
- systemctl enable apache2
- systemctl start apache2
- echo "<h1>Web VM - $(hostname) - $(date)</h1>" > /var/www/html/index.html
- echo "healthy" > /var/www/html/health
write_files:
- path: /etc/motd
content: |
=================================
Azure Lab - Web Tier VM
Environment: Milestone 1
=================================
Verified inter-subnet routing, SSH jump host access, and web server response. Confirmed NSG rules were blocking unauthorized access attempts.
# Get jump host public IP
JUMP_IP=$(az network public-ip show --resource-group $RG \
--name pip-jump --query ipAddress -o tsv)
# SSH into jump host
ssh azureadmin@$JUMP_IP
# From jump host — SSH to internal VMs
ssh -i ~/.ssh/azure_lab [email protected] # web-vm
ssh -i ~/.ssh/azure_lab [email protected] # app-vm
# From jump host — test web server
curl -v http://10.10.1.4/
curl -v http://10.10.1.4/health
# Expected: "healthy"
# From jump host — test app port NOT accessible from mgmt subnet
# (NSG app only allows from 10.10.1.0/24)
curl http://10.10.2.4:8080 --max-time 3
# Expected: timeout (blocked by NSG)
# From web VM — test app connectivity (allowed)
ssh [email protected] "curl http://10.10.2.4:8080 --max-time 5"
Complete Workflow
Challenges & Solutions
- VM deployment failing with NSG conflict — Creating a VM with an existing NSG specified while also having the
--nsgparameter point to a different name caused a conflict. Used--nsg ""to avoid auto-creating a new NSG when a pre-configured one is being associated. - Static private IP conflicting with DHCP range — Azure reserves the first four IPs in each subnet (.0-.3). Setting
--private-ip-address 10.10.1.1was rejected. Changed to10.10.1.4which is the first usable address. - cloud-init not running on first boot — The
--custom-dataparameter expects base64-encoded data or a file prefixed with@. Using@cloud-init-web.yamlworks correctly. - Cross-subnet SSH failing despite NSG allowing it — Azure has an implicit
AllowVnetInBoundrule at priority 65000. The inter-VM SSH was actually working, but the local SSH known_hosts was rejecting the connection due to a stale key. Cleared withssh-keygen -R 10.10.2.4.
Key Takeaways
- Azure's implicit
AllowVnetInBoundrule at priority 65000 allows all intra-VNet traffic by default — explicit deny rules at lower priority numbers must be added to restrict cross-subnet traffic. - Jump host / bastion patterns are essential for production — never expose SSH directly on application tier VMs to the public internet.
- cloud-init is the preferred method for VM bootstrapping in Azure — it runs reliably at first boot and eliminates the need for post-deployment manual configuration steps.
- Static private IP assignment in Azure requires using addresses from .4 onward in each subnet, as Azure reserves .0 (network), .1 (gateway), .2 and .3 (Azure internal services).