🤖💡🚀 AI research based on IaC Bicep implementation https://github.com/agustinborrajo/VMs-via-BICEP-programmatic-iterative📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕
🔻💾 main.bicep
//
// main.bicep ####
//
// main.bicep invokes vm.bicep module
// main.bicep iterates and creates 3 VMs via vm.bicep module
//
param location string = resourceGroup().location
param vmBaseName string = 'programaticVMloop'
@secure()
param adminUsername string
@secure()
param adminPassword string
param vnetName string
param subnetName string
param vmCount int = 3
// Loop to deploy VMs
var vmIndices = [for i in range(0, vmCount): i]
module vmsArray 'vm.bicep' = [for i in vmIndices: {
name: 'vm${i}'
params: {
vmName: '${vmBaseName}${i}'
adminUsername: adminUsername
adminPassword: adminPassword
location: location
vnetName: vnetName
subnetName: subnetName
}
}]
🔻💾 vm.bicep
//
// vm.bicep ####
//
// vm.bicep modules is consumed by main.bicep
// vm.bicep creates Virtual Machines with variable and dynamic names
// vm.bicep is intended to be consumed programmatically and iteratively
// vm.bicep creates VNET + Subnet + IP Address Space + VM Public IP + NIC + VM
//
param vmName string
@secure()
param adminUsername string
@secure()
param adminPassword string
param location string
param vnetName string = 'myVnet'
param subnetName string = 'default'
// Virtual Network with Subnet
resource vnet 'Microsoft.Network/virtualNetworks@2024-07-01' = {
name: vnetName
location: location
properties: {
addressSpace: {
addressPrefixes: [
'10.0.0.0/16'
]
}
subnets: [
{
name: subnetName
properties: {
addressPrefix: '10.0.0.0/24'
}
}
]
}
}
// Public IP address with Standard SKU, Static allocation
resource pip 'Microsoft.Network/publicIPAddresses@2024-05-01' = {
name: '${vmName}-pip'
location: location
properties: {
publicIPAllocationMethod: 'Static'
}
sku: {
name: 'Standard'
tier: 'Regional'
}
}
// Network Interface depending on VNet/subnet and PIP
resource nic 'Microsoft.Network/networkInterfaces@2024-07-01' = {
name: '${vmName}-nic'
location: location
properties: {
ipConfigurations: [
{
name: 'ipconfig1'
properties: {
subnet: {
id: resourceId('Microsoft.Network/virtualNetworks/subnets', vnetName, subnetName)
}
privateIPAllocationMethod: 'Dynamic'
publicIPAddress: {
id: pip.id
}
}
}
]
}
}
// Virtual Machine
resource vm 'Microsoft.Compute/virtualMachines@2024-11-01' = {
name: vmName
location: location
properties: {
hardwareProfile: {
vmSize: 'Standard_B1s'
}
osProfile: {
computerName: vmName
adminUsername: adminUsername
adminPassword: adminPassword
windowsConfiguration: {
provisionVMAgent: true
enableAutomaticUpdates: true
}
}
storageProfile: {
imageReference: {
publisher: 'MicrosoftWindowsServer'
offer: 'WindowsServer'
sku: '2016-Datacenter-smalldisk'
version: 'latest'
}
osDisk: {
createOption: 'FromImage'
}
}
networkProfile: {
networkInterfaces: [
{
id: nic.id
}
]
}
}
}
🔻💾 dev.parameters.json
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"location": {
"value": "eastus"
},
"vmBaseName": {
"value": "demoVM"
},
"vnetName": {
"value": "myVnet"
},
"subnetName": {
"value": "default"
},
"vmCount": {
"value": 3
}
}
}
🔻💾 deploy.yml
#
# deploy.yml ####
#
# deploy.yml deploys the VMs + all associated resources/features created by vm.bicep when consumed/invoked by main.bicep
# deploy.yml relies on 5 GitHub Environment Secrets : SUBSCRIPTION_ID + TENANT_ID + CLIENT_ID + VM_ADMIN_PASSWORD + VM_ADMIN_NAME
#
# CLIENT_ID : An Azure Managed Identity is used to deploy to GitHub # Below steps show what needs to be done before committing and pushing from VSCode
#
# $managedIdentityName = "bicep-demo-deploy-federation"
# $subscriptionID = "<YOUR-SUBSCRIPTION-ID>" # az account show # portal.azure.com
# $resourceGroupName = "VMs-via-BICEP-programmatic-iterative"
# New-AzResourceGroup -Name "managed-identity" -Location "eastus"
# Register-AzResourceProvider -ProviderNamespace Microsoft.ManagedIdentity
# $managedIdentity = New-AzUserAssignedIdentity -Name $managedIdentityName -ResourceGroupName managed-identity -Location eastus
# New-AzResourceGroup -Name $resourceGroupName -Location eastus
# $roleAssignment = New-AzRoleAssignment -ObjectId $managedIdentity.PrincipalId -RoleDefinitionName "Contributor" -Scope "/subscriptions/${subscriptionID}/resourceGroups/${resourceGroupName}"
# $githubOrganization = "agustinborrajo"
# $environmentName = "deploy"
# $repoName = "VMs-via-BICEP-programmatic-iterative"
# $subjectUri = "repo:${githubOrganization}/${repoName}:environment:${environmentName}"
# New-AzFederatedIdentityCredential -ResourceGroupName managed-identity -IdentityName $managedIdentity.name -Name bicep-demo-federation -Issuer "https://token.actions.githubusercontent.com" -Subject $subjectUri
# $managedIdentity.ClientID
#
name: Bicep Deploy
on:
push:
branches:
- master
permissions:
id-token: write
contents: read
jobs:
Bicep-deploy:
name: Run Azure Bicep Deployment
runs-on: ubuntu-latest
environment: deploy
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Azure
uses: azure/login@v2
with:
client-id: ${{ secrets.CLIENT_ID }}
tenant-id: ${{ secrets.TENANT_ID }}
subscription-id: ${{ secrets.SUBSCRIPTION_ID }}
enable-AzPSSession: true
- name: Deploy Bicep
uses: Azure/cli@v2
with:
azcliversion: latest
inlineScript: |
az deployment group create --name deploy --resource-group ${{ vars.RESOURCE_GROUP }} --template-file
./main.bicep --parameters @./dev.parameters.json --parameters adminPassword='${{ secrets.VM_ADMIN_PASSWORD }}' --parameters adminUsername='${{ secrets.VM_ADMIN_NAME }}' --debug📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕
🤖💡🚀 Quick summary: This repository uses Azure Bicep to define infrastructure as code: main.bicep loops and calls vm.bicep to create multiple Windows VMs and their networking; dev.parameters.json supplies values; deploy.yml runs the deployment from GitHub Actions.
Bicep is a declarative language for Azure deployments.
This project automates creating virtual machines and networking in Azure. Bicep files declare resources, the JSON file provides parameter values, and the GitHub Actions workflow runs the deployment.
Bicep is a domain-specific language that compiles to Azure Resource Manager templates.
param location string = resourceGroup().locationparam vmBaseName string = 'programaticVMloop''programaticVMloop'. Used to build VM names.@secure() param adminUsername string and @secure() param adminPassword stringparam vnetName string and param subnetName stringparam vmCount int = 3var vmIndices = [for i in range(0, vmCount): i][0,1,2,...] used to iterate.module vmsArray 'vm.bicep' = [for i in vmIndices: { ... }]vm.bicep once per index. Each invocation passes parameters including a unique vmName built as ${vmBaseName}${i}. This is how programmatic, repeated resource creation is done.param vmName string and secure admin paramsresource vnet 'Microsoft.Network/virtualNetworks@2024-07-01' = { ... }name uses vnetName.resource pip 'Microsoft.Network/publicIPAddresses@2024-05-01' = { ... }sku Standard and publicIPAllocationMethod: 'Static' mean the IP is stable and production-grade.resource nic 'Microsoft.Network/networkInterfaces@2024-07-01' = { ... }resourceId(...).resource vm 'Microsoft.Compute/virtualMachines@2024-11-01' = { ... }imageReference selects a Windows Server image.Important: Each module creates or references resources by name; if the same vnetName is used across modules, the template will attempt to create the same VNet multiple times — in real deployments you usually create shared networking once and pass its id to modules.
This JSON file maps parameter names to values used at deployment time (e.g., location, vmBaseName, vmCount). It keeps secrets out of source code.
A GitHub Actions workflow that:
azure/login with secrets.az deployment group create to deploy main.bicep with dev.parameters.json and secret admin credentials.Next step I can take: I can produce a simplified diagram showing how the VNet, NIC, PIP, and VM connect, or generate a safe checklist to avoid creating duplicate VNets.
Here’s both a simplified diagram and a safe checklist to help you visualize and avoid duplicate VNet creation in your Bicep-based VM deployment.
+-----------------------------+
| Virtual Network | ← vnet (10.0.0.0/16)
| Name: myVnet |
| |
| +-----------------------+ |
| | Subnet | ← subnet (10.0.0.0/24)
| | Name: default | |
| | | |
| | +-----------------+ | |
| | | NIC | | ← nic (vm0-nic, vm1-nic, ...)
| | | +-----------+ | | |
| | | | VM | | | ← vm (demoVM0, demoVM1, ...)
| | | +-----------+ | | |
| | +-----------------+ | |
| +-----------------------+ |
+-----------------------------+
|
v
+-----------------------------+
| Public IP Address | ← pip (vm0-pip, vm1-pip, ...)
| SKU: Standard |
| Allocation: Static |
+-----------------------------+
Each VM gets:
Your current setup creates a VNet inside each vm.bicep module call. This works only if:
To avoid accidental duplication or deployment failure:
vm.bicep into main.bicepsubnetId as a parameter to vm.bicep:param subnetId string
...
subnet: {
id: subnetId
}
existing keywordIf you want vm.bicep to reference an existing VNet:
resource vnet 'Microsoft.Network/virtualNetworks@2024-07-01' existing = {
name: vnetName
}
Then reference its subnet:
subnet: {
id: resourceId('Microsoft.Network/virtualNetworks/subnets', vnetName, subnetName)
}
Use a parameter like createVnet: bool and conditionally deploy the VNet only once. This adds complexity and is rarely needed if you modularize properly.
Option 1: Create the VNet once in main.bicep, and pass its subnet ID to each vm.bicep module.
main.bicep to create the VNet and extract subnet IDparam location string = resourceGroup().location
param vmBaseName string = 'programaticVMloop'
@secure()
param adminUsername string
@secure()
param adminPassword string
param vnetName string
param subnetName string
param vmCount int = 3
// Create VNet once
resource vnet 'Microsoft.Network/virtualNetworks@2024-07-01' = {
name: vnetName
location: location
properties: {
addressSpace: {
addressPrefixes: [
'10.0.0.0/16'
]
}
subnets: [
{
name: subnetName
properties: {
addressPrefix: '10.0.0.0/24'
}
}
]
}
}
// Extract subnet ID
var subnetId = vnet.properties.subnets[0].id
// Loop to deploy VMs
var vmIndices = [for i in range(0, vmCount): i]
module vmsArray 'vm.bicep' = [for i in vmIndices: {
name: 'vm${i}'
params: {
vmName: '${vmBaseName}${i}'
adminUsername: adminUsername
adminPassword: adminPassword
location: location
subnetId: subnetId
}
}]
vm.bicep to accept subnetId and remove VNet creationparam vmName string
@secure()
param adminUsername string
@secure()
param adminPassword string
param location string
param subnetId string
// Public IP
resource pip 'Microsoft.Network/publicIPAddresses@2024-05-01' = {
name: '${vmName}-pip'
location: location
properties: {
publicIPAllocationMethod: 'Static'
}
sku: {
name: 'Standard'
tier: 'Regional'
}
}
// NIC
resource nic 'Microsoft.Network/networkInterfaces@2024-07-01' = {
name: '${vmName}-nic'
location: location
properties: {
ipConfigurations: [
{
name: 'ipconfig1'
properties: {
subnet: {
id: subnetId
}
privateIPAllocationMethod: 'Dynamic'
publicIPAddress: {
id: pip.id
}
}
}
]
}
}
// VM
resource vm 'Microsoft.Compute/virtualMachines@2024-11-01' = {
name: vmName
location: location
properties: {
hardwareProfile: {
vmSize: 'Standard_B1s'
}
osProfile: {
computerName: vmName
adminUsername: adminUsername
adminPassword: adminPassword
windowsConfiguration: {
provisionVMAgent: true
enableAutomaticUpdates: true
}
}
storageProfile: {
imageReference: {
publisher: 'MicrosoftWindowsServer'
offer: 'WindowsServer'
sku: '2016-Datacenter-smalldisk'
version: 'latest'
}
osDisk: {
createOption: 'FromImage'
}
}
networkProfile: {
networkInterfaces: [
{
id: nic.id
}
]
}
}
}
main.bicepsubnetIdmain.bicep, create NSG once and attach to subnetresource nsg 'Microsoft.Network/networkSecurityGroups@2024-07-01' = {
name: '${vnetName}-nsg'
location: location
properties: {
securityRules: [
{
name: 'AllowRDP'
properties: {
priority: 100
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '3389'
sourceAddressPrefix: '*'
destinationAddressPrefix: '*'
}
}
]
}
}
// Attach NSG to subnet
resource subnetNsg 'Microsoft.Network/virtualNetworks/subnets@2024-07-01' = {
parent: vnet
name: subnetName
properties: {
addressPrefix: '10.0.0.0/24'
networkSecurityGroup: {
id: nsg.id
}
}
}
vm.bicep, add a tags block to each resourcetags: {
environment: 'dev'
owner: 'agustin'
module: 'vm.bicep'
}
Apply this to:
pipnicvmExample for VM:
resource vm 'Microsoft.Compute/virtualMachines@2024-11-01' = {
name: vmName
location: location
tags: {
environment: 'dev'
owner: 'agustin'
module: 'vm.bicep'
}
...
}
To monitor VM performance and logs, you can attach a diagnostics extension. This requires a Log Analytics workspace. If you'd like, I can generate a module to create the workspace and attach diagnostics to each VM.
Would you like me to generate the Log Analytics workspace and VM diagnostics extension next?
Here’s how to extend your Bicep setup with a Log Analytics workspace and a diagnostic extension for each VM to enable monitoring and insights.
main.bicepresource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-10-01' = {
name: '${vmBaseName}-log'
location: location
properties: {
sku: {
name: 'PerGB2018'
}
retentionInDays: 30
}
}
Add to main.bicep module call:
workspaceId: logAnalytics.id
workspaceKey: listKeys(logAnalytics.id, logAnalytics.apiVersion).primarySharedKey
Update the module block:
module vmsArray 'vm.bicep' = [for i in vmIndices: {
name: 'vm${i}'
params: {
vmName: '${vmBaseName}${i}'
adminUsername: adminUsername
adminPassword: adminPassword
location: location
subnetId: subnetId
workspaceId: logAnalytics.id
workspaceKey: listKeys(logAnalytics.id, logAnalytics.apiVersion).primarySharedKey
}
}]
vm.bicep to accept and use diagnosticsAdd parameters:
param workspaceId string
@secure()
param workspaceKey string
Add diagnostics extension:
resource diagnostics 'Microsoft.Compute/virtualMachines/extensions@2022-08-01' = {
name: '${vm.name}/AzureMonitorWindowsAgent'
location: location
properties: {
publisher: 'Microsoft.Azure.Monitor'
type: 'AzureMonitorWindowsAgent'
typeHandlerVersion: '1.0'
autoUpgradeMinorVersion: true
settings: {
workspaceId: workspaceId
}
protectedSettings: {
workspaceKey: workspaceKey
}
}
dependsOn: [
vm
]
}
Each VM now:
main.bicep, define an alert rule resource:resource cpuAlert 'Microsoft.Insights/scheduledQueryRules@2023-10-01' = {
name: '${vmBaseName}-cpu-alert'
location: location
properties: {
description: 'Alert when average CPU > 80% over 5 minutes'
enabled: true
source: {
query: '''
Perf
| where ObjectName == "Processor" and CounterName == "% Processor Time" and InstanceName == "_Total"
| summarize avg(CounterValue) by bin(TimeGenerated, 5m), Computer
'''
dataSourceId: logAnalytics.id
queryType: 'ResultCount'
}
schedule: {
frequencyInMinutes: 5
timeWindowInMinutes: 5
}
criteria: {
allOf: [
{
threshold: 80
operator: 'GreaterThan'
timeAggregation: 'Average'
metricMeasureColumn: 'avg_CounterValue'
dimensions: []
failingPeriods: {
numberOfEvaluationPeriods: 1
minFailingPeriodsToAlert: 1
}
}
]
}
actionGroups: [] // Optional: link to email/webhook/logic app
severity: 2
evaluationFrequency: 'PT5M'
windowSize: 'PT5M'
}
}
resource dashboard 'Microsoft.Portal/dashboards@2020-09-01-preview' = {
name: '${vmBaseName}-dashboard'
location: location
properties: {
lenses: {
'0': {
order: 0
parts: {
'0': {
position: {
x: 0
y: 0
rowSpan: 4
colSpan: 6
}
metadata: {
type: 'Extension/HubsExtension/PartType/MarkdownPart'
settings: {
content: '# VM Monitoring Dashboard\nThis dashboard shows CPU usage for deployed VMs.'
}
}
}
'1': {
position: {
x: 0
y: 4
rowSpan: 6
colSpan: 12
}
metadata: {
type: 'Extension/Microsoft_Azure_Monitoring/PartType/LogsChartPart'
settings: {
query: '''
Perf
| where ObjectName == "Processor" and CounterName == "% Processor Time" and InstanceName == "_Total"
| summarize avg(CounterValue) by bin(TimeGenerated, 5m), Computer
'''
resourceType: 'microsoft.operationalinsights/workspaces'
resourceId: logAnalytics.id
visualization: 'timechart'
}
}
}
}
}
}
metadata: {
model: {
timeRange: {
value: {
relative: '24h'
}
}
}
}
}
}
.bicep ModulesWe’ll create two reusable modules:
modules/alertCpuHigh.bicepparam alertName string
param location string
param workspaceId string
resource cpuAlert 'Microsoft.Insights/scheduledQueryRules@2023-10-01' = {
name: alertName
location: location
properties: {
description: 'Alert when average CPU > 80% over 5 minutes'
enabled: true
source: {
query: '''
Perf
| where ObjectName == "Processor" and CounterName == "% Processor Time" and InstanceName == "_Total"
| summarize avg(CounterValue) by bin(TimeGenerated, 5m), Computer
'''
dataSourceId: workspaceId
queryType: 'ResultCount'
}
schedule: {
frequencyInMinutes: 5
timeWindowInMinutes: 5
}
criteria: {
allOf: [
{
threshold: 80
operator: 'GreaterThan'
timeAggregation: 'Average'
metricMeasureColumn: 'avg_CounterValue'
dimensions: []
failingPeriods: {
numberOfEvaluationPeriods: 1
minFailingPeriodsToAlert: 1
}
}
]
}
severity: 2
evaluationFrequency: 'PT5M'
windowSize: 'PT5M'
}
}
modules/dashboard.bicepparam dashboardName string
param location string
param workspaceId string
resource dashboard 'Microsoft.Portal/dashboards@2020-09-01-preview' = {
name: dashboardName
location: location
properties: {
lenses: {
'0': {
order: 0
parts: {
'0': {
position: {
x: 0
y: 0
rowSpan: 4
colSpan: 6
}
metadata: {
type: 'Extension/HubsExtension/PartType/MarkdownPart'
settings: {
content: '# VM Monitoring Dashboard\nThis dashboard shows CPU usage for deployed VMs.'
}
}
}
'1': {
position: {
x: 0
y: 4
rowSpan: 6
colSpan: 12
}
metadata: {
type: 'Extension/Microsoft_Azure_Monitoring/PartType/LogsChartPart'
settings: {
query: '''
Perf
| where ObjectName == "Processor" and CounterName == "% Processor Time" and InstanceName == "_Total"
| summarize avg(CounterValue) by bin(TimeGenerated, 5m), Computer
'''
resourceType: 'microsoft.operationalinsights/workspaces'
resourceId: workspaceId
visualization: 'timechart'
}
}
}
}
}
}
metadata: {
model: {
timeRange: {
value: {
relative: '24h'
}
}
}
}
}
}
main.bicepmodule cpuAlert 'modules/alertCpuHigh.bicep' = {
name: 'cpuAlert'
params: {
alertName: '${vmBaseName}-cpu-alert'
location: location
workspaceId: logAnalytics.id
}
}
module dashboard 'modules/dashboard.bicep' = {
name: 'dashboard'
params: {
dashboardName: '${vmBaseName}-dashboard'
location: location
workspaceId: logAnalytics.id
}
}
Azure Resource Manager (ARM) deployments are idempotent and declarative:
--mode Complete.| Practice | Description |
|---|---|
--mode Incremental (default) | Adds/updates resources, never deletes. Safe for production. |
--mode Complete | Deletes resources not in the template. Use only in controlled environments. |
Use what-if | Run az deployment group what-if to preview changes before applying. |
| Modularize | Keeps changes scoped and avoids accidental overwrites. |
Use existing keyword | For shared resources (e.g., VNet, NSG), reference instead of redeploying. |
az deployment group create \
--name safeDeploy \
--resource-group myRG \
--template-file main.bicep \
--parameters @dev.parameters.json \
--parameters adminUsername='...' adminPassword='...' \
--mode Incremental
Would you like me to generate a what-if preview script or a destroy-safe checklist for production pipelines?
Yes — your refactored main.bicep fully preserves the ability to iterate and create N VMs using this construct:
var vmIndices = [for i in range(0, vmCount): i]
module vmsArray 'vm.bicep' = [for i in vmIndices: {
name: 'vm${i}'
params: {
vmName: '${vmBaseName}${i}'
...
}
}]
vmIndices is a dynamic array from 0 to vmCount - 1.module block uses array syntax ([...]) to deploy multiple instances of vm.bicep.vmName like demoVM0, demoVM1, etc.subnetId, adminUsername, etc.) are shared across instances.vm.bicep into main.bicep.subnetId, workspaceId, workspaceKey) into each module instance.This pattern is ideal for scaling, modularity, and safe incremental deployments. Each VM is isolated in its own module instance, and shared infrastructure is deployed once and reused.
I can also help you add per-VM tagging, diagnostics, or conditional logic inside the loop if you want to customize each VM further.
Absolutely — here's the fully refactored main.bicep that includes:
main.bicep (Refactored and Modularized)// Parameters
param location string = resourceGroup().location
param vmBaseName string = 'programaticVMloop'
@secure()
param adminUsername string
@secure()
param adminPassword string
param vnetName string
param subnetName string
param vmCount int = 3
// Create VNet
resource vnet 'Microsoft.Network/virtualNetworks@2024-07-01' = {
name: vnetName
location: location
properties: {
addressSpace: {
addressPrefixes: [
'10.0.0.0/16'
]
}
subnets: [
{
name: subnetName
properties: {
addressPrefix: '10.0.0.0/24'
}
}
]
}
}
// Create NSG and attach to subnet
resource nsg 'Microsoft.Network/networkSecurityGroups@2024-07-01' = {
name: '${vnetName}-nsg'
location: location
properties: {
securityRules: [
{
name: 'AllowRDP'
properties: {
priority: 100
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '3389'
sourceAddressPrefix: '*'
destinationAddressPrefix: '*'
}
}
]
}
}
resource subnetNsg 'Microsoft.Network/virtualNetworks/subnets@2024-07-01' = {
parent: vnet
name: subnetName
properties: {
addressPrefix: '10.0.0.0/24'
networkSecurityGroup: {
id: nsg.id
}
}
}
// Create Log Analytics workspace
resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-10-01' = {
name: '${vmBaseName}-log'
location: location
properties: {
sku: {
name: 'PerGB2018'
}
retentionInDays: 30
}
}
// Extract subnet ID
var subnetId = vnet.properties.subnets[0].id
// Extract workspace key
var workspaceKey = listKeys(logAnalytics.id, logAnalytics.apiVersion).primarySharedKey
// Iterate and deploy VMs
var vmIndices = [for i in range(0, vmCount): i]
module vmsArray 'vm.bicep' = [for i in vmIndices: {
name: 'vm${i}'
params: {
vmName: '${vmBaseName}${i}'
adminUsername: adminUsername
adminPassword: adminPassword
location: location
subnetId: subnetId
workspaceId: logAnalytics.id
workspaceKey: workspaceKey
}
}]
// Deploy alert rule module
module cpuAlert 'modules/alertCpuHigh.bicep' = {
name: 'cpuAlert'
params: {
alertName: '${vmBaseName}-cpu-alert'
location: location
workspaceId: logAnalytics.id
}
}
// Deploy dashboard module
module dashboard 'modules/dashboard.bicep' = {
name: 'dashboard'
params: {
dashboardName: '${vmBaseName}-dashboard'
location: location
workspaceId: logAnalytics.id
}
}
| Feature | Status |
|---|---|
| Iterative VM creation | ✅ Preserved via module vmsArray |
| Shared VNet/Subnet | ✅ Created once, reused |
| NSG with RDP rule | ✅ Attached to subnet |
| Log Analytics workspace | ✅ Created once |
| VM diagnostics | ✅ Passed via workspaceId and workspaceKey |
| Alert rule | ✅ Modularized |
| Dashboard | ✅ Modularized |
vm.bicep — Per-VM Deployment Moduleparam vmName string
@secure()
param adminUsername string
@secure()
param adminPassword string
param location string
param subnetId string
param workspaceId string
@secure()
param workspaceKey string
// Public IP
resource pip 'Microsoft.Network/publicIPAddresses@2024-05-01' = {
name: '${vmName}-pip'
location: location
sku: {
name: 'Standard'
tier: 'Regional'
}
properties: {
publicIPAllocationMethod: 'Static'
}
tags: {
environment: 'dev'
owner: 'agustin'
module: 'vm.bicep'
}
}
// NIC
resource nic 'Microsoft.Network/networkInterfaces@2024-07-01' = {
name: '${vmName}-nic'
location: location
properties: {
ipConfigurations: [
{
name: 'ipconfig1'
properties: {
subnet: {
id: subnetId
}
privateIPAllocationMethod: 'Dynamic'
publicIPAddress: {
id: pip.id
}
}
}
]
}
tags: {
environment: 'dev'
owner: 'agustin'
module: 'vm.bicep'
}
}
// VM
resource vm 'Microsoft.Compute/virtualMachines@2024-11-01' = {
name: vmName
location: location
properties: {
hardwareProfile: {
vmSize: 'Standard_B1s'
}
osProfile: {
computerName: vmName
adminUsername: adminUsername
adminPassword: adminPassword
windowsConfiguration: {
provisionVMAgent: true
enableAutomaticUpdates: true
}
}
storageProfile: {
imageReference: {
publisher: 'MicrosoftWindowsServer'
offer: 'WindowsServer'
sku: '2016-Datacenter-smalldisk'
version: 'latest'
}
osDisk: {
createOption: 'FromImage'
}
}
networkProfile: {
networkInterfaces: [
{
id: nic.id
}
]
}
}
tags: {
environment: 'dev'
owner: 'agustin'
module: 'vm.bicep'
}
}
// Diagnostics Extension
resource diagnostics 'Microsoft.Compute/virtualMachines/extensions@2022-08-01' = {
name: '${vm.name}/AzureMonitorWindowsAgent'
location: location
properties: {
publisher: 'Microsoft.Azure.Monitor'
type: 'AzureMonitorWindowsAgent'
typeHandlerVersion: '1.0'
autoUpgradeMinorVersion: true
settings: {
workspaceId: workspaceId
}
protectedSettings: {
workspaceKey: workspaceKey
}
}
dependsOn: [
vm
]
}
modules/alertCpuHigh.bicepparam alertName string
param location string
param workspaceId string
resource cpuAlert 'Microsoft.Insights/scheduledQueryRules@2023-10-01' = {
name: alertName
location: location
properties: {
description: 'Alert when average CPU > 80% over 5 minutes'
enabled: true
source: {
query: '''
Perf
| where ObjectName == "Processor" and CounterName == "% Processor Time" and InstanceName == "_Total"
| summarize avg(CounterValue) by bin(TimeGenerated, 5m), Computer
'''
dataSourceId: workspaceId
queryType: 'ResultCount'
}
schedule: {
frequencyInMinutes: 5
timeWindowInMinutes: 5
}
criteria: {
allOf: [
{
threshold: 80
operator: 'GreaterThan'
timeAggregation: 'Average'
metricMeasureColumn: 'avg_CounterValue'
dimensions: []
failingPeriods: {
numberOfEvaluationPeriods: 1
minFailingPeriodsToAlert: 1
}
}
]
}
severity: 2
evaluationFrequency: 'PT5M'
windowSize: 'PT5M'
}
}
modules/dashboard.bicepparam dashboardName string
param location string
param workspaceId string
resource dashboard 'Microsoft.Portal/dashboards@2020-09-01-preview' = {
name: dashboardName
location: location
properties: {
lenses: {
'0': {
order: 0
parts: {
'0': {
position: {
x: 0
y: 0
rowSpan: 4
colSpan: 6
}
metadata: {
type: 'Extension/HubsExtension/PartType/MarkdownPart'
settings: {
content: '# VM Monitoring Dashboard\nThis dashboard shows CPU usage for deployed VMs.'
}
}
}
'1': {
position: {
x: 0
y: 4
rowSpan: 6
colSpan: 12
}
metadata: {
type: 'Extension/Microsoft_Azure_Monitoring/PartType/LogsChartPart'
settings: {
query: '''
Perf
| where ObjectName == "Processor" and CounterName == "% Processor Time" and InstanceName == "_Total"
| summarize avg(CounterValue) by bin(TimeGenerated, 5m), Computer
'''
resourceType: 'microsoft.operationalinsights/workspaces'
resourceId: workspaceId
visualization: 'timechart'
}
}
}
}
}
}
metadata: {
model: {
timeRange: {
value: {
relative: '24h'
}
}
}
}
}
}
These modules are now:
@secure)Yes — your current deploy.yml works, but with the new modular structure and best practices, here’s how to refactor it for clarity, safety, and extensibility:
| Area | Change |
|---|---|
| 🔐 Secrets | Group secrets logically and validate usage |
| 📦 Parameters | Use a single parameters.json file for all non-secret values |
| 🧱 Bicep CLI | Use --mode Incremental explicitly to avoid accidental deletes |
| 🧪 What-if | Add a preview step before deployment |
| 📁 Structure | Add comments and organize steps for readability |
deploy.ymlname: Bicep Deploy
on:
push:
branches:
- master
permissions:
id-token: write
contents: read
jobs:
deploy-infra:
name: Deploy Azure Infrastructure
runs-on: ubuntu-latest
environment: deploy
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Azure Login with Federated Identity
uses: azure/login@v2
with:
client-id: ${{ secrets.CLIENT_ID }}
tenant-id: ${{ secrets.TENANT_ID }}
subscription-id: ${{ secrets.SUBSCRIPTION_ID }}
enable-AzPSSession: true
- name: Preview changes (what-if)
run: |
az deployment group what-if \
--name previewDeploy \
--resource-group ${{ vars.RESOURCE_GROUP }} \
--template-file ./main.bicep \
--parameters @./dev.parameters.json \
--parameters adminUsername='${{ secrets.VM_ADMIN_NAME }}' \
adminPassword='${{ secrets.VM_ADMIN_PASSWORD }}'
- name: Deploy Bicep template
run: |
az deployment group create \
--name deploy \
--resource-group ${{ vars.RESOURCE_GROUP }} \
--template-file ./main.bicep \
--parameters @./dev.parameters.json \
--parameters adminUsername='${{ secrets.VM_ADMIN_NAME }}' \
adminPassword='${{ secrets.VM_ADMIN_PASSWORD }}' \
--mode Incremental \
--debug
what-if step previews changes before applying — great for production safety.--mode Incremental ensures no resources are deleted unintentionally.vm.bicep, alertCpuHigh.bicep, and dashboard.bicep.
A controlled teardown is a safe, deliberate process for removing infrastructure components — like VMs, networks, dashboards, and alerts — without accidentally deleting unrelated resources or disrupting production environments.
| Principle | Description |
|---|---|
| ✅ Explicit targeting | Only deletes resources defined in your Bicep template |
| ✅ Scoped to a resource group | Limits impact to a known boundary |
✅ Uses --mode Complete |
Ensures removed resources are deleted if no longer in the template |
✅ Preview with what-if |
Shows what will be deleted before execution |
| ✅ Manual trigger | Never runs automatically on push — only when you intend to destroy |
destroy.yml — Safe Teardown Workflowname: Bicep Destroy
on:
workflow_dispatch: # Manual trigger only
permissions:
id-token: write
contents: read
jobs:
destroy-infra:
name: Destroy Azure Infrastructure
runs-on: ubuntu-latest
environment: deploy
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Azure Login with Federated Identity
uses: azure/login@v2
with:
client-id: ${{ secrets.CLIENT_ID }}
tenant-id: ${{ secrets.TENANT_ID }}
subscription-id: ${{ secrets.SUBSCRIPTION_ID }}
enable-AzPSSession: true
- name: Preview teardown (what-if)
run: |
az deployment group what-if \
--name destroyPreview \
--resource-group ${{ vars.RESOURCE_GROUP }} \
--template-file ./main.bicep \
--parameters @./dev.parameters.json \
--parameters adminUsername='${{ secrets.VM_ADMIN_NAME }}' \
adminPassword='${{ secrets.VM_ADMIN_PASSWORD }}' \
--mode Complete
- name: Destroy resources
run: |
az deployment group create \
--name destroyRun \
--resource-group ${{ vars.RESOURCE_GROUP }} \
--template-file ./main.bicep \
--parameters @./dev.parameters.json \
--parameters adminUsername='${{ secrets.VM_ADMIN_NAME }}' \
adminPassword='${{ secrets.VM_ADMIN_PASSWORD }}' \
--mode Complete \
--debug
--mode Complete: Deletes resources not in the template.
I can also help you generate a cleanup.bicep that explicitly deletes named resources if you want more granular control.
Here’s a production-safe cleanup.bicep template designed to explicitly delete resources created by your deployment — without relying on --mode Complete. This gives you granular control over what gets removed, and avoids accidental deletion of unrelated resources.
cleanup.bicepaz deployment group create using --mode Incrementalcleanup.bicepparam location string = resourceGroup().location
param vmBaseName string = 'programaticVMloop'
param vnetName string
param subnetName string
param vmCount int = 3
// Delete VMs
var vmIndices = [for i in range(0, vmCount): i]
resource vmsToDelete 'Microsoft.Compute/virtualMachines@2024-11-01' = [for i in vmIndices: {
name: '${vmBaseName}${i}'
location: location
properties: {}
existing: false
}]
// Delete NICs
resource nicsToDelete 'Microsoft.Network/networkInterfaces@2024-07-01' = [for i in vmIndices: {
name: '${vmBaseName}${i}-nic'
location: location
properties: {}
existing: false
}]
// Delete Public IPs
resource pipsToDelete 'Microsoft.Network/publicIPAddresses@2024-05-01' = [for i in vmIndices: {
name: '${vmBaseName}${i}-pip'
location: location
properties: {}
existing: false
}]
// Delete NSG
resource nsgToDelete 'Microsoft.Network/networkSecurityGroups@2024-07-01' = {
name: '${vnetName}-nsg'
location: location
properties: {}
existing: false
}
// Delete VNet
resource vnetToDelete 'Microsoft.Network/virtualNetworks@2024-07-01' = {
name: vnetName
location: location
properties: {}
existing: false
}
// Delete Log Analytics workspace
resource logAnalyticsToDelete 'Microsoft.OperationalInsights/workspaces@2023-10-01' = {
name: '${vmBaseName}-log'
location: location
properties: {}
existing: false
}
// Delete Alert Rule
resource alertToDelete 'Microsoft.Insights/scheduledQueryRules@2023-10-01' = {
name: '${vmBaseName}-cpu-alert'
location: location
properties: {}
existing: false
}
// Delete Dashboard
resource dashboardToDelete 'Microsoft.Portal/dashboards@2020-09-01-preview' = {
name: '${vmBaseName}-dashboard'
location: location
properties: {}
existing: false
}
az deployment group create \
--name cleanupRun \
--resource-group <your-resource-group> \
--template-file ./cleanup.bicep \
--parameters vmBaseName='demoVM' vnetName='myVnet' subnetName='default' vmCount=3 \
--mode Incremental
This will explicitly delete only the resources listed — no surprises, no collateral damage.
Here’s a safe and production-ready cleanup.yml GitHub Actions workflow that deploys your cleanup.bicep template to explicitly remove resources — without relying on --mode Complete.
cleanup.yml — Controlled Teardown Workflowname: Bicep Cleanup
on:
workflow_dispatch: # Manual trigger only
permissions:
id-token: write
contents: read
jobs:
cleanup-infra:
name: Cleanup Azure Infrastructure
runs-on: ubuntu-latest
environment: deploy
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Azure Login with Federated Identity
uses: azure/login@v2
with:
client-id: ${{ secrets.CLIENT_ID }}
tenant-id: ${{ secrets.TENANT_ID }}
subscription-id: ${{ secrets.SUBSCRIPTION_ID }}
enable-AzPSSession: true
- name: Preview cleanup (what-if)
run: |
az deployment group what-if \
--name cleanupPreview \
--resource-group ${{ vars.RESOURCE_GROUP }} \
--template-file ./cleanup.bicep \
--parameters vmBaseName='${{ vars.VM_BASE_NAME }}' \
vnetName='${{ vars.VNET_NAME }}' \
subnetName='${{ vars.SUBNET_NAME }}' \
vmCount=${{ vars.VM_COUNT }}
- name: Execute cleanup
run: |
az deployment group create \
--name cleanupRun \
--resource-group ${{ vars.RESOURCE_GROUP }} \
--template-file ./cleanup.bicep \
--parameters vmBaseName='${{ vars.VM_BASE_NAME }}' \
vnetName='${{ vars.VNET_NAME }}' \
subnetName='${{ vars.SUBNET_NAME }}' \
vmCount=${{ vars.VM_COUNT }} \
--mode Incremental \
--debug
In your GitHub environment named deploy, define these variables:
| Variable | Example Value |
|---|---|
RESOURCE_GROUP |
VMs-via-BICEP-programmatic-iterative |
VM_BASE_NAME |
demoVM |
VNET_NAME |
myVnet |
SUBNET_NAME |
default |
VM_COUNT |
3 |
This ensures the workflow is parameterized and safe to reuse across environments.
I can also help you add conditional cleanup logic (e.g., skip workspace deletion in prod) or generate a validate.yml for pre-deployment checks.
Here’s a production-grade validate.yml for pre-deployment checks, followed by a comparison with a test.yml for pre-production validation.
validate.yml — Pre-Deployment ChecksThis workflow ensures your Bicep code is syntactically valid, all parameters are resolvable, and a dry-run preview (what-if) is safe to deploy.
name: Validate Bicep Template
on:
pull_request:
branches:
- master
permissions:
id-token: write
contents: read
jobs:
validate:
name: Validate Bicep and Preview Changes
runs-on: ubuntu-latest
environment: deploy
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Azure Login with Federated Identity
uses: azure/login@v2
with:
client-id: ${{ secrets.CLIENT_ID }}
tenant-id: ${{ secrets.TENANT_ID }}
subscription-id: ${{ secrets.SUBSCRIPTION_ID }}
enable-AzPSSession: true
- name: Validate Bicep syntax
run: az bicep build --file ./main.bicep
- name: Validate parameter resolution
run: |
az deployment group validate \
--resource-group ${{ vars.RESOURCE_GROUP }} \
--template-file ./main.bicep \
--parameters @./dev.parameters.json \
--parameters adminUsername='${{ secrets.VM_ADMIN_NAME }}' \
adminPassword='${{ secrets.VM_ADMIN_PASSWORD }}'
- name: Preview deployment (what-if)
run: |
az deployment group what-if \
--name validatePreview \
--resource-group ${{ vars.RESOURCE_GROUP }} \
--template-file ./main.bicep \
--parameters @./dev.parameters.json \
--parameters adminUsername='${{ secrets.VM_ADMIN_NAME }}' \
adminPassword='${{ secrets.VM_ADMIN_PASSWORD }}'
test.yml — Pre-Production Validation (Hypothetical)This workflow would deploy to a non-production environment (e.g., test or staging) to validate runtime behavior, not just syntax.
name: Test Bicep Deployment
on:
push:
branches:
- staging
permissions:
id-token: write
contents: read
jobs:
test-deploy:
name: Deploy to Test Environment
runs-on: ubuntu-latest
environment: test
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Azure Login with Federated Identity
uses: azure/login@v2
with:
client-id: ${{ secrets.CLIENT_ID }}
tenant-id: ${{ secrets.TENANT_ID }}
subscription-id: ${{ secrets.SUBSCRIPTION_ID }}
enable-AzPSSession: true
- name: Deploy to test resource group
run: |
az deployment group create \
--name testDeploy \
--resource-group ${{ vars.TEST_RESOURCE_GROUP }} \
--template-file ./main.bicep \
--parameters @./test.parameters.json \
--parameters adminUsername='${{ secrets.VM_ADMIN_NAME }}' \
adminPassword='${{ secrets.VM_ADMIN_PASSWORD }}' \
--mode Incremental
validate.yml vs test.yml| Feature | validate.yml |
test.yml |
|---|---|---|
| Trigger | On pull request | On push to staging |
| Purpose | Syntax + dry-run safety | Full test deployment |
| Environment | deploy (shared) |
test (isolated) |
| Deployment | ❌ No real resources created | ✅ Real resources deployed |
| Risk | Zero | Low (non-prod only) |
| Use case | CI gatekeeper | Pre-prod smoke test |
generate a promote.yml for safe promotion from test to production after validation passes
Here’s a production-safe promote.yml workflow that promotes a validated deployment from your test environment to production. It assumes your infrastructure has passed validation in test.yml and is ready to be deployed to the deploy environment.
promote.yml — Safe Promotion to Productionname: Promote to Production
on:
workflow_dispatch: # Manual trigger only
permissions:
id-token: write
contents: read
jobs:
promote:
name: Promote Validated Deployment
runs-on: ubuntu-latest
environment: deploy
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Azure Login with Federated Identity
uses: azure/login@v2
with:
client-id: ${{ secrets.CLIENT_ID }}
tenant-id: ${{ secrets.TENANT_ID }}
subscription-id: ${{ secrets.SUBSCRIPTION_ID }}
enable-AzPSSession: true
- name: Preview production deployment (what-if)
run: |
az deployment group what-if \
--name promotePreview \
--resource-group ${{ vars.RESOURCE_GROUP }} \
--template-file ./main.bicep \
--parameters @./dev.parameters.json \
--parameters adminUsername='${{ secrets.VM_ADMIN_NAME }}' \
adminPassword='${{ secrets.VM_ADMIN_PASSWORD }}'
- name: Promote to production
run: |
az deployment group create \
--name promoteRun \
--resource-group ${{ vars.RESOURCE_GROUP }} \
--template-file ./main.bicep \
--parameters @./dev.parameters.json \
--parameters adminUsername='${{ secrets.VM_ADMIN_NAME }}' \
adminPassword='${{ secrets.VM_ADMIN_PASSWORD }}' \
--mode Incremental \
--debug
| Workflow | Purpose | Trigger | Environment |
|---|---|---|---|
test.yml |
Deploy to test RG | Push to staging |
test |
validate.yml |
Syntax + dry-run | PR to master |
deploy |
promote.yml |
Final production deploy | Manual | deploy |
This setup ensures:
I can also help you add approval gates or Slack notifications to promote.yml if you want team visibility.
Here’s a production-safe promote.yml workflow enhanced with manual approval gates and optional Slack notifications to ensure visibility and control when promoting infrastructure from test to production.
promote.yml — With Approval and Notificationsname: Promote to Production
on:
workflow_dispatch: # Manual trigger only
permissions:
id-token: write
contents: read
jobs:
promote:
name: Promote Validated Deployment
runs-on: ubuntu-latest
environment:
name: deploy
url: https://portal.azure.com # Optional link to environment
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Azure Login with Federated Identity
uses: azure/login@v2
with:
client-id: ${{ secrets.CLIENT_ID }}
tenant-id: ${{ secrets.TENANT_ID }}
subscription-id: ${{ secrets.SUBSCRIPTION_ID }}
enable-AzPSSession: true
- name: Preview production deployment (what-if)
run: |
az deployment group what-if \
--name promotePreview \
--resource-group ${{ vars.RESOURCE_GROUP }} \
--template-file ./main.bicep \
--parameters @./dev.parameters.json \
--parameters adminUsername='${{ secrets.VM_ADMIN_NAME }}' \
adminPassword='${{ secrets.VM_ADMIN_PASSWORD }}'
- name: Request manual approval
uses: hmarr/auto-approve-action@v3
if: always()
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Notify Slack channel
if: always()
uses: slackapi/slack-github-action@v1.24.0
with:
slack-message: |
🚀 *Production Promotion Triggered*
Repository: ${{ github.repository }}
Actor: ${{ github.actor }}
Environment: deploy
Status: Awaiting approval
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Promote to production
run: |
az deployment group create \
--name promoteRun \
--resource-group ${{ vars.RESOURCE_GROUP }} \
--template-file ./main.bicep \
--parameters @./dev.parameters.json \
--parameters adminUsername='${{ secrets.VM_ADMIN_NAME }}' \
adminPassword='${{ secrets.VM_ADMIN_PASSWORD }}' \
--mode Incremental \
--debug
| Feature | Description |
|---|---|
workflow_dispatch |
Manual trigger only |
what-if preview |
Shows changes before applying |
| Approval step | Optional gate before deployment |
| Slack notification | Alerts team of promotion event |
| Safe mode | Uses --mode Incremental to avoid deletes |
You can replace the Slack action with Microsoft Teams or email if preferred. Wire this into a multi-environment pipeline with dev → test → prod stages
Here’s how to build a multi-environment pipeline using GitHub Actions that flows from dev → test → production, with validation, testing, promotion, and cleanup stages.
| Stage | Workflow | Trigger | Environment | Purpose |
|---|---|---|---|---|
| ✅ Validate | validate.yml |
PR to master |
deploy |
Syntax + dry-run safety |
| 🧪 Test | test.yml |
Push to staging |
test |
Deploy to test RG |
| 🚀 Promote | promote.yml |
Manual | deploy |
Deploy to production |
| 🧯 Cleanup | cleanup.yml |
Manual | deploy |
Explicit teardown |
| 🔄 Destroy | destroy.yml |
Manual | deploy |
Full template-based deletion |
Developer pushes to feature branch
validate.yml on PR to masterMerge to staging branch
test.ymlManual promotion
promote.ymlManual cleanup or destroy
cleanup.yml or destroy.ymltest, deploy) to scope secrets and approvalsRESOURCE_GROUP, VM_BASE_NAME, etc.workflow_dispatch for manual control over destructive actionspromote.ymlwhat-if in every workflow for safety
generate a README.md snippet that documents this pipeline for your repo
Here’s a clean, professional README.md snippet to document your multi-environment GitHub Actions pipeline for Azure Bicep deployments:
This repository implements a secure, modular, and production-grade GitHub Actions pipeline for deploying Azure infrastructure using Bicep. It supports full lifecycle automation across dev → test → production environments.
| Stage | Workflow | Trigger | Environment | Purpose |
|---|---|---|---|---|
| ✅ Validate | validate.yml |
PR to master |
deploy |
Syntax check + what-if preview |
| 🧪 Test | test.yml |
Push to staging |
test |
Deploy to test resource group |
| 🚀 Promote | promote.yml |
Manual trigger | deploy |
Deploy validated code to prod |
| 🧯 Cleanup | cleanup.yml |
Manual trigger | deploy |
Explicitly delete known resources |
| 🔄 Destroy | destroy.yml |
Manual trigger | deploy |
Full teardown via template diff |
Define these in GitHub Environments (test, deploy):
| Name | Type | Description |
|---|---|---|
CLIENT_ID |
Secret | Federated identity client ID |
TENANT_ID |
Secret | Azure AD tenant ID |
SUBSCRIPTION_ID |
Secret | Azure subscription ID |
VM_ADMIN_NAME |
Secret | Admin username for VMs |
VM_ADMIN_PASSWORD |
Secret | Admin password for VMs |
RESOURCE_GROUP |
Variable | Target resource group |
VM_BASE_NAME |
Variable | Prefix for VM names |
VNET_NAME |
Variable | Name of the virtual network |
SUBNET_NAME |
Variable | Name of the subnet |
VM_COUNT |
Variable | Number of VMs to deploy |
SLACK_WEBHOOK_URL |
Secret | (Optional) Slack notification webhook |
main.bicep: Orchestrates shared infrastructure and N VMsvm.bicep: Deploys a single VM with diagnosticsmodules/alertCpuHigh.bicep: Creates a CPU alert rulemodules/dashboard.bicep: Creates a monitoring dashboardcleanup.bicep: Explicitly deletes all known resources
diagram version of this pipeline or a badge matrix for your README.
Here’s a clean and informative badge matrix you can include in your README.md to visually represent the status and purpose of each GitHub Actions workflow in your Azure Bicep pipeline:
| Workflow | Status Badge | Purpose |
|---|---|---|
| Validate | Validate | Syntax check + what-if preview |
| Test | Test | Deploy to test resource group |
| Promote | Promote | Deploy validated code to prod |
| Cleanup | Cleanup | Explicitly delete known resources |
| Destroy | Destroy | Full teardown via template diff |
Replace your-org and your-repo with your actual GitHub organization and repository name. For example:

You can also pin these badges to the top of your README for quick visibility.
Here’s a clean, emoji-enhanced text-based diagram of your multi-environment Azure Bicep deployment pipeline:
📦 Code Commit
|
└──▶ 🧪 validate.yml
├─ Trigger: PR to master
├─ Action: Bicep syntax check
├─ Action: Parameter validation
└─ Action: What-if preview
↓
🟢 Validation Passed
|
└──▶ 🧪 test.yml
├─ Trigger: Push to staging
├─ Action: Deploy to test RG
└─ Action: Runtime verification
↓
🟢 Test Passed
|
└──▶ 🚀 promote.yml
├─ Trigger: Manual dispatch
├─ Action: What-if preview
├─ Action: Slack notification
├─ Action: Manual approval gate
└─ Action: Deploy to production RG
↓
🟢 Production Deployed
|
├──▶ 🧯 cleanup.yml (manual)
│ ├─ Action: Explicit resource deletion
│ └─ Mode: Incremental (safe)
│
└──▶ 🔄 destroy.yml (manual)
├─ Action: Full teardown via template diff
└─ Mode: Complete (destructive)
📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕📕