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 environment
  • Azure Subnets — logical network segmentation
  • Azure Virtual Machines — Ubuntu 22.04 LTS compute
  • Azure Network Interface (NIC) — VM network attachments
  • Azure Public IP — internet-facing address for jump host
  • Azure NSG — inbound/outbound security rules
  • Azure CLI 2.x — command-line resource management
  • cloud-init — VM first-boot configuration script
  • SSH — VM access and connectivity verification

Architecture Overview

flowchart TD VNet[Azure VNet\n10.10.0.0/16\ncanadacentral] VNet --> WebSN[Web Subnet\n10.10.1.0/24] VNet --> AppSN[App Subnet\n10.10.2.0/24] VNet --> MgmtSN[Mgmt Subnet\n10.10.3.0/24] WebSN --> WebVM[web-vm\n10.10.1.4\nApache2] AppSN --> AppVM[app-vm\n10.10.2.4\nNode.js] MgmtSN --> JumpVM[jump-vm\n10.10.3.4 + Public IP\nSSH Jump Host] JumpVM -->|SSH| WebVM JumpVM -->|SSH| AppVM WebVM -->|HTTP :8080| AppVM style VNet fill:#1a1a2e,stroke:#00d4ff,color:#e0e0e0 style WebVM fill:#1a1a2e,stroke:#00d4ff,color:#e0e0e0 style AppVM fill:#1a1a2e,stroke:#00d4ff,color:#e0e0e0 style JumpVM fill:#181818,stroke:#1e1e1e,color:#888 style WebSN fill:#181818,stroke:#1e1e1e,color:#888 style AppSN fill:#181818,stroke:#1e1e1e,color:#888 style MgmtSN fill:#181818,stroke:#1e1e1e,color:#888

Step-by-Step Process

01
Resource Group & Virtual Network

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
02
NSG Creation & Rule Configuration

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
03
Virtual Machine Deployment

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
04
cloud-init Web Server Bootstrap

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
      =================================
05
Connectivity Verification

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

flowchart LR A[az group create\nResource Group] --> B[az network vnet create\n+ 3 subnets] B --> C[az network nsg create\n3 NSGs + rules] C --> D[Associate NSGs\nto subnets] D --> E[az vm create\n3 VMs with cloud-init] E --> F[Verify SSH via\nJump Host] F --> G[Test NSG rules\ncurl between VMs] style A fill:#1a1a2e,stroke:#00d4ff,color:#e0e0e0 style G fill:#1a1a2e,stroke:#00ff88,color:#e0e0e0 style B fill:#181818,stroke:#1e1e1e,color:#888 style C fill:#181818,stroke:#1e1e1e,color:#888 style D fill:#181818,stroke:#1e1e1e,color:#888 style E fill:#181818,stroke:#1e1e1e,color:#888 style F fill:#181818,stroke:#1e1e1e,color:#888

Challenges & Solutions

  • VM deployment failing with NSG conflict — Creating a VM with an existing NSG specified while also having the --nsg parameter 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.1 was rejected. Changed to 10.10.1.4 which is the first usable address.
  • cloud-init not running on first boot — The --custom-data parameter expects base64-encoded data or a file prefixed with @. Using @cloud-init-web.yaml works correctly.
  • Cross-subnet SSH failing despite NSG allowing it — Azure has an implicit AllowVnetInBound rule 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 with ssh-keygen -R 10.10.2.4.

Key Takeaways

  • Azure's implicit AllowVnetInBound rule 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).