Azure Infrastructure Deployment
Objective
Deploy a complete Azure infrastructure using ARM (Azure Resource Manager) templates as Infrastructure as Code (IaC). The deployment includes a Virtual Network with three subnets (web, app, data), two Ubuntu Linux VMs in the web tier behind a load balancer, Network Security Groups with layered rules, a Bastion host for secure VM access, Azure Monitor with Log Analytics workspace, and diagnostic settings on all resources. The entire stack deploys from a single ARM template parameterized for reusability across dev, staging, and production environments.
Tools & Technologies
Azure Resource Manager (ARM) templates— JSON IaC formatAzure CLI (az deployment)— template deploymentAzure Virtual Network— network isolation and segmentationAzure NSG— network security groups with rule priority systemAzure Load Balancer (Standard)— web tier high availabilityAzure Bastion— browser-based secure VM accessAzure Monitor + Log Analytics— metrics and log aggregationAzure Disk Encryption— OS and data disk encryptionARM template functions—resourceId(),parameters(),variables()
Architecture Overview
Step-by-Step Process
Designed the ARM template with a parameters file for environment-specific values, variables for derived names, and resources for each Azure service.
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"environment": {
"type": "string",
"allowedValues": ["dev", "staging", "prod"],
"defaultValue": "dev"
},
"location": { "type": "string", "defaultValue": "[resourceGroup().location]" },
"vmSize": { "type": "string", "defaultValue": "Standard_B2s" },
"adminUsername": { "type": "string" },
"adminPublicKey": { "type": "securestring" },
"vnetAddressPrefix": { "type": "string", "defaultValue": "10.0.0.0/16" }
},
"variables": {
"vnetName": "[concat('vnet-', parameters('environment'))]",
"webSubnet": "10.0.1.0/24",
"appSubnet": "10.0.2.0/24",
"dataSubnet": "10.0.3.0/24",
"bastionSubnet": "10.0.4.0/27",
"lbName": "[concat('lb-web-', parameters('environment'))]",
"logWorkspaceName": "[concat('law-', parameters('environment'), '-', uniqueString(resourceGroup().id))]"
}
}
Defined the VNet resource with inline subnet declarations and separate NSG resources with security rules. NSG-to-subnet associations use the dependsOn property.
{
"type": "Microsoft.Network/networkSecurityGroups",
"apiVersion": "2023-04-01",
"name": "nsg-web",
"location": "[parameters('location')]",
"properties": {
"securityRules": [
{
"name": "Allow-HTTP",
"properties": {
"priority": 100,
"protocol": "Tcp",
"access": "Allow",
"direction": "Inbound",
"sourceAddressPrefix": "Internet",
"sourcePortRange": "*",
"destinationAddressPrefix": "VirtualNetwork",
"destinationPortRange": "80"
}
},
{
"name": "Allow-HTTPS",
"properties": {
"priority": 110,
"protocol": "Tcp",
"access": "Allow",
"direction": "Inbound",
"sourceAddressPrefix": "Internet",
"sourcePortRange": "*",
"destinationAddressPrefix": "VirtualNetwork",
"destinationPortRange": "443"
}
},
{
"name": "Deny-All-Inbound",
"properties": {
"priority": 4096,
"protocol": "*",
"access": "Deny",
"direction": "Inbound",
"sourceAddressPrefix": "*",
"sourcePortRange": "*",
"destinationAddressPrefix": "*",
"destinationPortRange": "*"
}
}
]
}
}
Configured a Standard Load Balancer with frontend IP, backend pool, health probe, and load balancing rule. Both VMs are added to the backend pool via NIC IP configuration references.
# ARM template excerpt for LB (simplified)
# Full template uses resourceId() to wire all references
# Health probe — HTTP GET /health on port 80
"probes": [{
"name": "http-probe",
"properties": {
"protocol": "Http",
"port": 80,
"requestPath": "/health",
"intervalInSeconds": 15,
"numberOfProbes": 2
}
}],
# Load balancing rule
"loadBalancingRules": [{
"name": "http-rule",
"properties": {
"frontendIPConfiguration": { "id": "[variables('lbFrontendId')]" },
"backendAddressPool": { "id": "[variables('lbBackendId')]" },
"probe": { "id": "[variables('lbProbeId')]" },
"protocol": "Tcp",
"frontendPort": 80,
"backendPort": 80,
"enableFloatingIP": false,
"idleTimeoutInMinutes": 4
}
}]
# Deploy the ARM template
az deployment group create \
--resource-group rg-azure-infra-lab \
--template-file main.azuredeploy.json \
--parameters environment=dev \
adminUsername=azureadmin \
adminPublicKey="$(cat ~/.ssh/azure_lab.pub)"
Created a Log Analytics workspace and enabled diagnostic settings on the VNet, NSGs, Load Balancer, and VMs to forward all logs and metrics to the central workspace.
# Create Log Analytics workspace
az monitor log-analytics workspace create \
--resource-group rg-azure-infra-lab \
--workspace-name law-dev-lab \
--location canadacentral \
--sku PerGB2018 \
--retention-time 30
LAW_ID=$(az monitor log-analytics workspace show \
--resource-group rg-azure-infra-lab \
--workspace-name law-dev-lab \
--query id -o tsv)
# Enable diagnostic settings on NSG
NSG_ID=$(az network nsg show --name nsg-web \
--resource-group rg-azure-infra-lab --query id -o tsv)
az monitor diagnostic-settings create \
--name "diag-nsg-web" \
--resource $NSG_ID \
--workspace $LAW_ID \
--logs '[{"category":"NetworkSecurityGroupEvent","enabled":true},
{"category":"NetworkSecurityGroupRuleCounter","enabled":true}]'
# Enable VM diagnostics via Azure Monitor agent
az vm extension set \
--resource-group rg-azure-infra-lab \
--vm-name web-vm-01 \
--name AzureMonitorLinuxAgent \
--publisher Microsoft.Azure.Monitor \
--version 1.0
Deployed Azure Bastion in the dedicated subnet for browser-based VM access, then ran verification tests on all deployed resources.
# Create Bastion (requires AzureBastionSubnet with /27 or larger)
az network bastion create \
--name bastion-lab \
--resource-group rg-azure-infra-lab \
--vnet-name vnet-dev \
--location canadacentral \
--sku Basic
# Verify deployment — check all resources exist
az resource list --resource-group rg-azure-infra-lab \
--query "[].{Name:name, Type:type, Location:location}" \
--output table
# Verify LB backend pool health
az network lb show \
--resource-group rg-azure-infra-lab \
--name lb-web-dev \
--query "backendAddressPools[0].backendIPConfigurations[].id" \
--output table
# Test HTTP through load balancer
LB_IP=$(az network public-ip show \
--resource-group rg-azure-infra-lab \
--name pip-lb-web-dev --query ipAddress -o tsv)
curl -v http://$LB_IP/
Complete Workflow
Challenges & Solutions
- ARM template deployment failing on NSG dependency — The VNet resource was referencing the NSG ID before the NSG resource was created. Added explicit
dependsOnarrays to enforce correct creation order. - Azure Bastion subnet too small — Bastion requires a
/27or larger subnet named exactlyAzureBastionSubnet. The initial/29subnet was rejected. Redeployed with a/27CIDR. - Load balancer health probe failing — The VMs didn't have a web server installed at deployment time. The health probe path
/healthwas returning 404. Added a cloud-init script to the VM resource to install and start Apache2 on first boot. - Log Analytics workspace not receiving VM logs — The Azure Monitor Linux Agent extension was deployed but the Data Collection Rule (DCR) was not associated. Created a DCR and associated it with both VMs through the portal.
Key Takeaways
- ARM templates'
dependsOnarrays are critical for resources that reference each other — Azure deploys resources in parallel by default, which causes failures if dependencies aren't explicit. - Azure Bastion eliminates the need to expose SSH/RDP ports to the internet — it's the correct zero-trust access pattern for VM management in production environments.
- Infrastructure as Code enables repeatable, version-controlled deployments — the same ARM template can deploy dev, staging, and prod environments with environment-specific parameter files.
- Load balancer health probes should point to a dedicated health endpoint, not the root path — this allows for graceful draining of VMs during maintenance without affecting the health check.