Skip to content

Project 5: Infrastructure as Code Pipeline ​

Overview ​

Build a complete Infrastructure as Code (IaC) pipeline using Bicep templates and Azure DevOps/GitHub Actions. This project covers template development, modularization, testing, and automated deployment.

Difficulty: Intermediate to Advanced
Duration: 4-6 hours
Cost: ~$10-20/month (minimal - mostly free tier)

Architecture Diagram ​

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                              DEVELOPMENT WORKFLOW                                β”‚
β”‚                                                                                  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚   VS Code    │───▢│   Git Repo   │───▢│   CI/CD      │───▢│   Azure      β”‚  β”‚
β”‚  β”‚   + Bicep    β”‚    β”‚   (GitHub)   β”‚    β”‚   Pipeline   β”‚    β”‚   Deploy     β”‚  β”‚
β”‚  β”‚   Extension  β”‚    β”‚              β”‚    β”‚              β”‚    β”‚              β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚         β”‚                   β”‚                   β”‚                   β”‚           β”‚
β”‚         β–Ό                   β–Ό                   β–Ό                   β–Ό           β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚ Write Bicep  β”‚    β”‚ Push Changes β”‚    β”‚ Build & Test β”‚    β”‚ What-If &    β”‚  β”‚
β”‚  β”‚ Templates    β”‚    β”‚ to Branch    β”‚    β”‚ Templates    β”‚    β”‚ Deploy       β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                              CI/CD PIPELINE STAGES                               β”‚
β”‚                                                                                  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚                              BUILD STAGE                                    β”‚ β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚
β”‚  β”‚  β”‚  Lint    │─▢│  Build   │─▢│  Test    │─▢│  Publish │─▢│  Artifacts   β”‚ β”‚ β”‚
β”‚  β”‚  β”‚  Check   β”‚  β”‚  Bicep   β”‚  β”‚  Pester  β”‚  β”‚  ARM     β”‚  β”‚              β”‚ β”‚ β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚                                       β”‚                                          β”‚
β”‚                                       β–Ό                                          β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚                        DEPLOY TO DEV STAGE                                  β”‚ β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                     β”‚ β”‚
β”‚  β”‚  β”‚  What-If     │─▢│  Manual      │─▢│  Deploy      β”‚                     β”‚ β”‚
β”‚  β”‚  β”‚  Preview     β”‚  β”‚  Approval    β”‚  β”‚  to Dev      β”‚                     β”‚ β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                     β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚                                       β”‚                                          β”‚
β”‚                                       β–Ό                                          β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚                       DEPLOY TO PROD STAGE                                  β”‚ β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚ β”‚
β”‚  β”‚  β”‚  What-If     │─▢│  Manual      │─▢│  Deploy      │─▢│  Smoke       β”‚   β”‚ β”‚
β”‚  β”‚  β”‚  Preview     β”‚  β”‚  Approval    β”‚  β”‚  to Prod     β”‚  β”‚  Tests       β”‚   β”‚ β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                           BICEP MODULE STRUCTURE                                 β”‚
β”‚                                                                                  β”‚
β”‚  infra/                                                                          β”‚
β”‚  β”œβ”€β”€ main.bicep              # Main entry point                                  β”‚
β”‚  β”œβ”€β”€ main.parameters.dev.json   # Dev environment parameters                    β”‚
β”‚  β”œβ”€β”€ main.parameters.prod.json  # Prod environment parameters                   β”‚
β”‚  β”‚                                                                               β”‚
β”‚  β”œβ”€β”€ modules/                # Reusable modules                                  β”‚
β”‚  β”‚   β”œβ”€β”€ network/                                                                β”‚
β”‚  β”‚   β”‚   β”œβ”€β”€ vnet.bicep      # Virtual Network                                   β”‚
β”‚  β”‚   β”‚   β”œβ”€β”€ nsg.bicep       # Network Security Group                           β”‚
β”‚  β”‚   β”‚   └── bastion.bicep   # Azure Bastion                                    β”‚
β”‚  β”‚   β”‚                                                                           β”‚
β”‚  β”‚   β”œβ”€β”€ compute/                                                                β”‚
β”‚  β”‚   β”‚   β”œβ”€β”€ vm.bicep        # Virtual Machine                                   β”‚
β”‚  β”‚   β”‚   β”œβ”€β”€ vmss.bicep      # VM Scale Set                                      β”‚
β”‚  β”‚   β”‚   └── appservice.bicep # App Service                                     β”‚
β”‚  β”‚   β”‚                                                                           β”‚
β”‚  β”‚   β”œβ”€β”€ storage/                                                                β”‚
β”‚  β”‚   β”‚   └── storage.bicep   # Storage Account                                   β”‚
β”‚  β”‚   β”‚                                                                           β”‚
β”‚  β”‚   └── security/                                                               β”‚
β”‚  β”‚       └── keyvault.bicep  # Key Vault                                         β”‚
β”‚  β”‚                                                                               β”‚
β”‚  └── tests/                  # Pester tests                                      β”‚
β”‚      └── main.tests.ps1                                                          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

What You'll Learn ​

  • Write modular Bicep templates
  • Create reusable modules
  • Implement parameter files for different environments
  • Set up CI/CD pipeline with GitHub Actions
  • Use what-if deployments for validation
  • Implement approval gates

Prerequisites ​

  • Azure subscription
  • GitHub account
  • VS Code with Bicep extension
  • Azure CLI installed

Phase 1: Project Setup ​

Step 1.1: Create Project Structure ​

bash
# Create project directory
mkdir -p infra-as-code/{modules/{network,compute,storage,security},tests}
cd infra-as-code

# Initialize git
git init

# Create .gitignore
cat > .gitignore << 'EOF'
# Azure
*.parameters.local.json
.azure/

# IDE
.vscode/
.idea/

# Build artifacts
*.arm.json

# OS
.DS_Store
Thumbs.db
EOF

Step 1.2: Create Resource Group for Testing ​

bash
# Set variables
LOCATION="eastus"
RG_DEV="rg-iac-dev-eastus"
RG_PROD="rg-iac-prod-eastus"

# Create resource groups
az group create --name $RG_DEV --location $LOCATION --tags Environment=Dev
az group create --name $RG_PROD --location $LOCATION --tags Environment=Prod

Phase 2: Create Bicep Modules ​

Step 2.1: Network Module - Virtual Network ​

bash
cat > modules/network/vnet.bicep << 'EOF'
@description('Name of the virtual network')
param vnetName string

@description('Location for the virtual network')
param location string = resourceGroup().location

@description('Address prefix for the virtual network')
param addressPrefix string = '10.0.0.0/16'

@description('Array of subnet configurations')
param subnets array = [
  {
    name: 'default'
    addressPrefix: '10.0.1.0/24'
  }
]

@description('Tags for the resource')
param tags object = {}

resource vnet 'Microsoft.Network/virtualNetworks@2023-05-01' = {
  name: vnetName
  location: location
  tags: tags
  properties: {
    addressSpace: {
      addressPrefixes: [addressPrefix]
    }
    subnets: [for subnet in subnets: {
      name: subnet.name
      properties: {
        addressPrefix: subnet.addressPrefix
        networkSecurityGroup: contains(subnet, 'nsgId') ? {
          id: subnet.nsgId
        } : null
        privateEndpointNetworkPolicies: contains(subnet, 'privateEndpointNetworkPolicies') ? subnet.privateEndpointNetworkPolicies : 'Disabled'
      }
    }]
  }
}

@description('Resource ID of the virtual network')
output vnetId string = vnet.id

@description('Name of the virtual network')
output vnetName string = vnet.name

@description('Array of subnet resource IDs')
output subnetIds array = [for (subnet, i) in subnets: vnet.properties.subnets[i].id]
EOF

Step 2.2: Network Module - NSG ​

bash
cat > modules/network/nsg.bicep << 'EOF'
@description('Name of the network security group')
param nsgName string

@description('Location for the NSG')
param location string = resourceGroup().location

@description('Array of security rules')
param securityRules array = []

@description('Tags for the resource')
param tags object = {}

resource nsg 'Microsoft.Network/networkSecurityGroups@2023-05-01' = {
  name: nsgName
  location: location
  tags: tags
  properties: {
    securityRules: [for rule in securityRules: {
      name: rule.name
      properties: {
        priority: rule.priority
        direction: rule.direction
        access: rule.access
        protocol: rule.protocol
        sourcePortRange: contains(rule, 'sourcePortRange') ? rule.sourcePortRange : '*'
        destinationPortRange: contains(rule, 'destinationPortRange') ? rule.destinationPortRange : '*'
        destinationPortRanges: contains(rule, 'destinationPortRanges') ? rule.destinationPortRanges : []
        sourceAddressPrefix: contains(rule, 'sourceAddressPrefix') ? rule.sourceAddressPrefix : '*'
        destinationAddressPrefix: contains(rule, 'destinationAddressPrefix') ? rule.destinationAddressPrefix : '*'
      }
    }]
  }
}

@description('Resource ID of the NSG')
output nsgId string = nsg.id

@description('Name of the NSG')
output nsgName string = nsg.name
EOF

Step 2.3: Compute Module - Virtual Machine ​

bash
cat > modules/compute/vm.bicep << 'EOF'
@description('Name of the virtual machine')
param vmName string

@description('Location for the VM')
param location string = resourceGroup().location

@description('Size of the VM')
@allowed([
  'Standard_B2s'
  'Standard_D2s_v3'
  'Standard_D4s_v3'
])
param vmSize string = 'Standard_B2s'

@description('OS type')
@allowed([
  'Windows'
  'Linux'
])
param osType string = 'Linux'

@description('Admin username')
param adminUsername string

@description('Admin password or SSH key')
@secure()
param adminPasswordOrKey string

@description('Authentication type')
@allowed([
  'password'
  'sshPublicKey'
])
param authenticationType string = 'password'

@description('Subnet ID for the VM')
param subnetId string

@description('Tags for the resource')
param tags object = {}

var imageReference = osType == 'Windows' ? {
  publisher: 'MicrosoftWindowsServer'
  offer: 'WindowsServer'
  sku: '2022-datacenter-g2'
  version: 'latest'
} : {
  publisher: 'Canonical'
  offer: '0001-com-ubuntu-server-jammy'
  sku: '22_04-lts-gen2'
  version: 'latest'
}

var linuxConfiguration = {
  disablePasswordAuthentication: authenticationType == 'sshPublicKey'
  ssh: authenticationType == 'sshPublicKey' ? {
    publicKeys: [
      {
        path: '/home/${adminUsername}/.ssh/authorized_keys'
        keyData: adminPasswordOrKey
      }
    ]
  } : null
}

resource nic 'Microsoft.Network/networkInterfaces@2023-05-01' = {
  name: '${vmName}-nic'
  location: location
  tags: tags
  properties: {
    ipConfigurations: [
      {
        name: 'ipconfig1'
        properties: {
          subnet: {
            id: subnetId
          }
          privateIPAllocationMethod: 'Dynamic'
        }
      }
    ]
  }
}

resource vm 'Microsoft.Compute/virtualMachines@2023-07-01' = {
  name: vmName
  location: location
  tags: tags
  properties: {
    hardwareProfile: {
      vmSize: vmSize
    }
    storageProfile: {
      imageReference: imageReference
      osDisk: {
        name: '${vmName}-osdisk'
        createOption: 'FromImage'
        managedDisk: {
          storageAccountType: 'Premium_LRS'
        }
      }
    }
    osProfile: {
      computerName: vmName
      adminUsername: adminUsername
      adminPassword: authenticationType == 'password' ? adminPasswordOrKey : null
      linuxConfiguration: osType == 'Linux' ? linuxConfiguration : null
    }
    networkProfile: {
      networkInterfaces: [
        {
          id: nic.id
        }
      ]
    }
    diagnosticsProfile: {
      bootDiagnostics: {
        enabled: true
      }
    }
  }
}

@description('Resource ID of the VM')
output vmId string = vm.id

@description('Private IP address of the VM')
output privateIpAddress string = nic.properties.ipConfigurations[0].properties.privateIPAddress
EOF

Step 2.4: Storage Module ​

bash
cat > modules/storage/storage.bicep << 'EOF'
@description('Name of the storage account')
param storageAccountName string

@description('Location for the storage account')
param location string = resourceGroup().location

@description('SKU for the storage account')
@allowed([
  'Standard_LRS'
  'Standard_GRS'
  'Standard_ZRS'
  'Premium_LRS'
])
param sku string = 'Standard_LRS'

@description('Kind of storage account')
@allowed([
  'StorageV2'
  'BlobStorage'
  'FileStorage'
])
param kind string = 'StorageV2'

@description('Enable blob soft delete')
param enableBlobSoftDelete bool = true

@description('Soft delete retention days')
param softDeleteRetentionDays int = 7

@description('Tags for the resource')
param tags object = {}

resource storage 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: storageAccountName
  location: location
  tags: tags
  sku: {
    name: sku
  }
  kind: kind
  properties: {
    supportsHttpsTrafficOnly: true
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: false
    networkAcls: {
      defaultAction: 'Allow'
      bypass: 'AzureServices'
    }
  }
}

resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-01-01' = {
  parent: storage
  name: 'default'
  properties: {
    deleteRetentionPolicy: {
      enabled: enableBlobSoftDelete
      days: softDeleteRetentionDays
    }
    containerDeleteRetentionPolicy: {
      enabled: enableBlobSoftDelete
      days: softDeleteRetentionDays
    }
  }
}

@description('Resource ID of the storage account')
output storageAccountId string = storage.id

@description('Name of the storage account')
output storageAccountName string = storage.name

@description('Primary blob endpoint')
output primaryBlobEndpoint string = storage.properties.primaryEndpoints.blob
EOF

Step 2.5: Security Module - Key Vault ​

bash
cat > modules/security/keyvault.bicep << 'EOF'
@description('Name of the Key Vault')
param keyVaultName string

@description('Location for the Key Vault')
param location string = resourceGroup().location

@description('Tenant ID')
param tenantId string = subscription().tenantId

@description('SKU for the Key Vault')
@allowed([
  'standard'
  'premium'
])
param sku string = 'standard'

@description('Enable soft delete')
param enableSoftDelete bool = true

@description('Soft delete retention days')
param softDeleteRetentionDays int = 90

@description('Enable purge protection')
param enablePurgeProtection bool = true

@description('Array of access policies')
param accessPolicies array = []

@description('Tags for the resource')
param tags object = {}

resource keyVault 'Microsoft.KeyVault/vaults@2023-02-01' = {
  name: keyVaultName
  location: location
  tags: tags
  properties: {
    tenantId: tenantId
    sku: {
      family: 'A'
      name: sku
    }
    enabledForDeployment: true
    enabledForDiskEncryption: true
    enabledForTemplateDeployment: true
    enableSoftDelete: enableSoftDelete
    softDeleteRetentionInDays: softDeleteRetentionDays
    enablePurgeProtection: enablePurgeProtection
    enableRbacAuthorization: true
    networkAcls: {
      defaultAction: 'Allow'
      bypass: 'AzureServices'
    }
    accessPolicies: accessPolicies
  }
}

@description('Resource ID of the Key Vault')
output keyVaultId string = keyVault.id

@description('URI of the Key Vault')
output keyVaultUri string = keyVault.properties.vaultUri
EOF

Phase 3: Create Main Template ​

Step 3.1: Main Bicep File ​

bash
cat > main.bicep << 'EOF'
targetScope = 'resourceGroup'

// ============================================================================
// PARAMETERS
// ============================================================================

@description('Environment name')
@allowed([
  'dev'
  'staging'
  'prod'
])
param environment string

@description('Location for all resources')
param location string = resourceGroup().location

@description('Project name used for naming resources')
param projectName string = 'iaclab'

@description('Admin username for VMs')
param adminUsername string = 'azureadmin'

@description('Admin password for VMs')
@secure()
param adminPassword string

@description('Deploy VMs')
param deployVMs bool = true

@description('Tags to apply to all resources')
param tags object = {
  Environment: environment
  Project: projectName
  ManagedBy: 'Bicep'
}

// ============================================================================
// VARIABLES
// ============================================================================

var resourcePrefix = '${projectName}-${environment}'
var vnetName = 'vnet-${resourcePrefix}'
var nsgName = 'nsg-${resourcePrefix}'
var storageAccountName = replace('st${projectName}${environment}${uniqueString(resourceGroup().id)}', '-', '')
var keyVaultName = 'kv-${resourcePrefix}-${uniqueString(resourceGroup().id)}'

var subnets = [
  {
    name: 'snet-workloads'
    addressPrefix: '10.0.1.0/24'
    nsgId: nsgModule.outputs.nsgId
  }
  {
    name: 'AzureBastionSubnet'
    addressPrefix: '10.0.2.0/27'
  }
]

var nsgRules = [
  {
    name: 'AllowSSH'
    priority: 100
    direction: 'Inbound'
    access: 'Allow'
    protocol: 'Tcp'
    sourceAddressPrefix: '10.0.2.0/27'
    destinationPortRange: '22'
  }
  {
    name: 'AllowRDP'
    priority: 110
    direction: 'Inbound'
    access: 'Allow'
    protocol: 'Tcp'
    sourceAddressPrefix: '10.0.2.0/27'
    destinationPortRange: '3389'
  }
  {
    name: 'DenyAllInbound'
    priority: 4096
    direction: 'Inbound'
    access: 'Deny'
    protocol: '*'
    sourceAddressPrefix: '*'
    destinationAddressPrefix: '*'
  }
]

// ============================================================================
// MODULES
// ============================================================================

module nsgModule 'modules/network/nsg.bicep' = {
  name: 'deploy-nsg'
  params: {
    nsgName: nsgName
    location: location
    securityRules: nsgRules
    tags: tags
  }
}

module vnetModule 'modules/network/vnet.bicep' = {
  name: 'deploy-vnet'
  params: {
    vnetName: vnetName
    location: location
    addressPrefix: '10.0.0.0/16'
    subnets: subnets
    tags: tags
  }
  dependsOn: [
    nsgModule
  ]
}

module storageModule 'modules/storage/storage.bicep' = {
  name: 'deploy-storage'
  params: {
    storageAccountName: take(storageAccountName, 24)
    location: location
    sku: environment == 'prod' ? 'Standard_GRS' : 'Standard_LRS'
    tags: tags
  }
}

module keyVaultModule 'modules/security/keyvault.bicep' = {
  name: 'deploy-keyvault'
  params: {
    keyVaultName: take(keyVaultName, 24)
    location: location
    sku: environment == 'prod' ? 'premium' : 'standard'
    tags: tags
  }
}

module vmModule 'modules/compute/vm.bicep' = if (deployVMs) {
  name: 'deploy-vm'
  params: {
    vmName: 'vm-${resourcePrefix}'
    location: location
    vmSize: environment == 'prod' ? 'Standard_D2s_v3' : 'Standard_B2s'
    osType: 'Linux'
    adminUsername: adminUsername
    adminPasswordOrKey: adminPassword
    authenticationType: 'password'
    subnetId: vnetModule.outputs.subnetIds[0]
    tags: tags
  }
}

// ============================================================================
// OUTPUTS
// ============================================================================

@description('Virtual Network ID')
output vnetId string = vnetModule.outputs.vnetId

@description('Storage Account Name')
output storageAccountName string = storageModule.outputs.storageAccountName

@description('Key Vault URI')
output keyVaultUri string = keyVaultModule.outputs.keyVaultUri

@description('VM Private IP')
output vmPrivateIp string = deployVMs ? vmModule.outputs.privateIpAddress : 'N/A'
EOF

Step 3.2: Create Parameter Files ​

bash
# Dev parameters
cat > main.parameters.dev.json << 'EOF'
{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "environment": {
      "value": "dev"
    },
    "projectName": {
      "value": "iaclab"
    },
    "adminUsername": {
      "value": "azureadmin"
    },
    "adminPassword": {
      "reference": {
        "keyVault": {
          "id": "/subscriptions/<subscription-id>/resourceGroups/<rg-name>/providers/Microsoft.KeyVault/vaults/<kv-name>"
        },
        "secretName": "vm-admin-password"
      }
    },
    "deployVMs": {
      "value": true
    }
  }
}
EOF

# Prod parameters
cat > main.parameters.prod.json << 'EOF'
{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "environment": {
      "value": "prod"
    },
    "projectName": {
      "value": "iaclab"
    },
    "adminUsername": {
      "value": "azureadmin"
    },
    "adminPassword": {
      "reference": {
        "keyVault": {
          "id": "/subscriptions/<subscription-id>/resourceGroups/<rg-name>/providers/Microsoft.KeyVault/vaults/<kv-name>"
        },
        "secretName": "vm-admin-password"
      }
    },
    "deployVMs": {
      "value": true
    }
  }
}
EOF

Phase 4: Create CI/CD Pipeline ​

Step 4.1: GitHub Actions Workflow ​

bash
mkdir -p .github/workflows

cat > .github/workflows/deploy-infrastructure.yml << 'EOF'
name: Deploy Infrastructure

on:
  push:
    branches:
      - main
    paths:
      - '**.bicep'
      - '**.json'
      - '.github/workflows/deploy-infrastructure.yml'
  pull_request:
    branches:
      - main
    paths:
      - '**.bicep'
      - '**.json'
  workflow_dispatch:
    inputs:
      environment:
        description: 'Environment to deploy'
        required: true
        default: 'dev'
        type: choice
        options:
          - dev
          - prod

env:
  AZURE_RESOURCE_GROUP_DEV: rg-iac-dev-eastus
  AZURE_RESOURCE_GROUP_PROD: rg-iac-prod-eastus

permissions:
  id-token: write
  contents: read

jobs:
  # ============================================================================
  # BUILD & VALIDATE
  # ============================================================================
  build:
    name: Build & Validate
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Install Bicep CLI
        run: |
          az bicep install
          az bicep version
      
      - name: Lint Bicep files
        run: |
          az bicep lint --file main.bicep
      
      - name: Build Bicep to ARM
        run: |
          az bicep build --file main.bicep --outfile main.arm.json
      
      - name: Upload ARM template
        uses: actions/upload-artifact@v4
        with:
          name: arm-templates
          path: |
            main.arm.json
            main.parameters.*.json

  # ============================================================================
  # DEPLOY TO DEV
  # ============================================================================
  deploy-dev:
    name: Deploy to Dev
    needs: build
    runs-on: ubuntu-latest
    environment: dev
    if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'dev')
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Download ARM template
        uses: actions/download-artifact@v4
        with:
          name: arm-templates
      
      - name: Azure Login
        uses: azure/login@v1
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      
      - name: What-If Deployment
        run: |
          az deployment group what-if \
            --resource-group ${{ env.AZURE_RESOURCE_GROUP_DEV }} \
            --template-file main.bicep \
            --parameters environment=dev adminPassword=${{ secrets.VM_ADMIN_PASSWORD }}
      
      - name: Deploy to Dev
        run: |
          az deployment group create \
            --resource-group ${{ env.AZURE_RESOURCE_GROUP_DEV }} \
            --template-file main.bicep \
            --parameters environment=dev adminPassword=${{ secrets.VM_ADMIN_PASSWORD }} \
            --name "deploy-$(date +%Y%m%d-%H%M%S)"

  # ============================================================================
  # DEPLOY TO PROD
  # ============================================================================
  deploy-prod:
    name: Deploy to Prod
    needs: deploy-dev
    runs-on: ubuntu-latest
    environment: prod
    if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'prod')
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Download ARM template
        uses: actions/download-artifact@v4
        with:
          name: arm-templates
      
      - name: Azure Login
        uses: azure/login@v1
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      
      - name: What-If Deployment
        run: |
          az deployment group what-if \
            --resource-group ${{ env.AZURE_RESOURCE_GROUP_PROD }} \
            --template-file main.bicep \
            --parameters environment=prod adminPassword=${{ secrets.VM_ADMIN_PASSWORD }}
      
      - name: Deploy to Prod
        run: |
          az deployment group create \
            --resource-group ${{ env.AZURE_RESOURCE_GROUP_PROD }} \
            --template-file main.bicep \
            --parameters environment=prod adminPassword=${{ secrets.VM_ADMIN_PASSWORD }} \
            --name "deploy-$(date +%Y%m%d-%H%M%S)"
EOF

Step 4.2: Configure GitHub Secrets ​

bash
# Required secrets in GitHub repository:
# Settings β†’ Secrets and variables β†’ Actions β†’ New repository secret

# AZURE_CLIENT_ID: Service Principal App ID
# AZURE_TENANT_ID: Azure AD Tenant ID
# AZURE_SUBSCRIPTION_ID: Azure Subscription ID
# VM_ADMIN_PASSWORD: Password for VMs

# Create service principal for GitHub Actions
az ad sp create-for-rbac \
  --name "github-actions-iac" \
  --role Contributor \
  --scopes /subscriptions/<subscription-id>/resourceGroups/$RG_DEV \
           /subscriptions/<subscription-id>/resourceGroups/$RG_PROD \
  --sdk-auth

Step 4.3: Configure GitHub Environments ​

  1. Go to Repository β†’ Settings β†’ Environments
  2. Create dev environment
    • No protection rules (auto-deploy)
  3. Create prod environment
    • Required reviewers: Add approvers
    • Wait timer: 5 minutes (optional)

Phase 5: Testing Bicep Templates ​

Step 5.1: Create Pester Tests ​

bash
cat > tests/main.tests.ps1 << 'EOF'
BeforeAll {
    # Build Bicep to ARM JSON for testing
    az bicep build --file ../main.bicep --outfile ../main.arm.json
    $template = Get-Content -Path ../main.arm.json | ConvertFrom-Json
}

Describe "Main Template Validation" {
    
    Context "Template Structure" {
        It "Should have required parameters" {
            $template.parameters | Should -Not -BeNullOrEmpty
            $template.parameters.environment | Should -Not -BeNullOrEmpty
            $template.parameters.adminPassword | Should -Not -BeNullOrEmpty
        }
        
        It "Should have resources defined" {
            $template.resources | Should -Not -BeNullOrEmpty
            $template.resources.Count | Should -BeGreaterThan 0
        }
        
        It "Should have outputs defined" {
            $template.outputs | Should -Not -BeNullOrEmpty
        }
    }
    
    Context "Parameter Validation" {
        It "Environment parameter should have allowed values" {
            $template.parameters.environment.allowedValues | Should -Contain "dev"
            $template.parameters.environment.allowedValues | Should -Contain "prod"
        }
        
        It "Admin password should be secure" {
            $template.parameters.adminPassword.type | Should -Be "securestring"
        }
    }
    
    Context "Resource Validation" {
        It "Should deploy NSG" {
            $nsg = $template.resources | Where-Object { $_.type -eq "Microsoft.Resources/deployments" -and $_.name -eq "deploy-nsg" }
            $nsg | Should -Not -BeNullOrEmpty
        }
        
        It "Should deploy VNet" {
            $vnet = $template.resources | Where-Object { $_.type -eq "Microsoft.Resources/deployments" -and $_.name -eq "deploy-vnet" }
            $vnet | Should -Not -BeNullOrEmpty
        }
        
        It "Should deploy Storage Account" {
            $storage = $template.resources | Where-Object { $_.type -eq "Microsoft.Resources/deployments" -and $_.name -eq "deploy-storage" }
            $storage | Should -Not -BeNullOrEmpty
        }
    }
}

Describe "Security Best Practices" {
    
    It "Storage account should enforce HTTPS" {
        $storageModule = Get-Content -Path ../modules/storage/storage.bicep -Raw
        $storageModule | Should -Match "supportsHttpsTrafficOnly.*true"
    }
    
    It "Storage account should use TLS 1.2" {
        $storageModule = Get-Content -Path ../modules/storage/storage.bicep -Raw
        $storageModule | Should -Match "minimumTlsVersion.*TLS1_2"
    }
    
    It "Key Vault should enable soft delete" {
        $kvModule = Get-Content -Path ../modules/security/keyvault.bicep -Raw
        $kvModule | Should -Match "enableSoftDelete"
    }
}

AfterAll {
    # Cleanup
    Remove-Item -Path ../main.arm.json -Force -ErrorAction SilentlyContinue
}
EOF

Step 5.2: Run Tests ​

powershell
# Install Pester if needed
Install-Module -Name Pester -Force -SkipPublisherCheck

# Run tests
cd tests
Invoke-Pester -Path ./main.tests.ps1 -Output Detailed

Phase 6: Deploy and Verify ​

Step 6.1: Manual Deployment (Testing) ​

bash
# Deploy to dev with what-if first
az deployment group what-if \
  --resource-group $RG_DEV \
  --template-file main.bicep \
  --parameters environment=dev adminPassword="YourSecurePassword123!"

# Deploy to dev
az deployment group create \
  --resource-group $RG_DEV \
  --template-file main.bicep \
  --parameters environment=dev adminPassword="YourSecurePassword123!" \
  --name "manual-deploy-$(date +%Y%m%d-%H%M%S)"

# Verify deployment
az deployment group show \
  --resource-group $RG_DEV \
  --name "manual-deploy-*" \
  --query "properties.provisioningState" -o tsv

Step 6.2: View Deployment Outputs ​

bash
# Get deployment outputs
az deployment group show \
  --resource-group $RG_DEV \
  --name "manual-deploy-*" \
  --query "properties.outputs"

Cleanup ​

bash
# Delete resource groups
az group delete --name $RG_DEV --yes --no-wait
az group delete --name $RG_PROD --yes --no-wait

# Delete service principal
az ad sp delete --id <service-principal-id>

Key Takeaways ​

  1. Modular Design: Reusable modules for consistency
  2. Parameter Files: Environment-specific configurations
  3. What-If Deployments: Preview changes before applying
  4. CI/CD Automation: Consistent, repeatable deployments
  5. Testing: Validate templates before deployment
  6. Approval Gates: Control production deployments

Next Steps ​

  • Add more modules (App Service, AKS, SQL)
  • Implement Bicep linting rules
  • Add integration tests with Azure
  • Set up Azure Policy for compliance
  • Implement drift detection

Released under the MIT License.