🚀 Get Started With Infrastructure-as-Code (IaC)
By Anatoly Mironov
Infrastructure-as-Code (IaC) is a widely admired principle among many professionals I meet. Yet, getting started with IaC can feel daunting—especially when working with legacy systems or under tight deadlines.
This post offers a simple, step-by-step demo to help you ease into IaC. Even partial adoption is better than none, and I’ll show you how to get started without overcomplicating things.
đź§Ş Demo Setup
Here’s the environment I’m using:
- One Azure Subscription (for simplicity)
- Azure DevOps Project
- One Azure Service Connection with Contributor access at the subscription level
- Azure Pipelines
- Azure CLI
- Bicep
- Azure Deployment Stacks
Yes, it’s very Azure-heavy—but the same principles apply to GitHub, GitLab, or other platforms.
NOTE: I assume you already have a service connection set up.
To make this guide as digestable as possible, I am assuming you already have a service connection set up.
Here is a simplified diagram of the target demo setup.

đź”§ Step 1: Create a New Pipeline
In your Azure DevOps project:
- Go to Pipelines and create a new one.
- Choose Azure Pipelines and leave the default settings.
- Click Save and Run.

It’s simple, yet powerful—there’s a lot happening behind the scenes to get your pipeline running.

🏗️ Step 2: Add Bicep and a Deployment Stack
Create a file infra/main.bicep with the following code to create a resource group:
| targetScope = 'subscription' | |
| param appName string = 'iac-demo' | |
| param location string = 'westeurope' | |
| var resourceGroupName = 'rg-${appName}' | |
| resource newRG 'Microsoft.Resources/resourceGroups@2024-11-01' = { | |
| name: resourceGroupName | |
| location: location | |
| } |
Now update your azure-pipelines.yml to deploy this Bicep file using Azure CLI:
| trigger: | |
| - main | |
| variables: | |
| azureSubscription: "demo-azure-connection" | |
| pool: | |
| vmImage: ubuntu-latest | |
| steps: | |
| - task: AzureCLI@2 | |
| displayName: "Deploy Infrastructure" | |
| inputs: | |
| azureSubscription: "$(azureSubscription)" | |
| scriptType: bash | |
| scriptLocation: inlineScript | |
| useGlobalConfig: false | |
| inlineScript: | | |
| az stack sub create \ | |
| --template-file ./infra/main.bicep \ | |
| --name stack-iac-demo \ | |
| --location westeurope \ | |
| --action-on-unmanage deleteResources \ | |
| --deny-settings-mode None |
After committing and pushing, a new resource group should be created—success!

Tip: If your pipeline gets stuck, try running the Azure CLI commands locally to debug.
az stack sub create \
--template-file ./infra/main.bicep \
--name iac-demo-002 \
--location westeurope \
--action-on-unmanage deleteResources \
--deny-settings-mode None
🌱 Step 3: Add Your First Environment – DEV
Let’s introduce environments: DEV, TEST, and PROD.
The environments will be automatically created when you refer to them in the pipeline.
Update your pipeline to include stages and a checkout step:

| trigger: | |
| - main | |
| variables: | |
| azureSubscription: "demo-azure-connection" | |
| pool: | |
| vmImage: ubuntu-latest | |
| # THIS IS NEW for STEP 3: stages | |
| stages: | |
| - stage: DeployDEV | |
| displayName: "Deploy to DEV" | |
| jobs: | |
| - deployment: DeployInfraDEV | |
| displayName: "Deploy Infrastructure to DEV" | |
| environment: "DEV" # The name is important | |
| strategy: | |
| runOnce: | |
| deploy: | |
| # END OF NEW for STEP 3: stages | |
| steps: | |
| # THIS IS NEW for STEP 3: source code | |
| - checkout: self | |
| displayName: "Checkout source code" | |
| # END OF NEW for STEP 3: source code | |
| - task: AzureCLI@2 | |
| displayName: "Deploy Infrastructure" | |
| inputs: | |
| azureSubscription: "$(azureSubscription)" | |
| scriptType: bash | |
| scriptLocation: inlineScript | |
| useGlobalConfig: false | |
| inlineScript: | | |
| az stack sub create \ | |
| --template-file ./infra/main.bicep \ | |
| --name stack-iac-demo \ | |
| --location westeurope \ | |
| --action-on-unmanage deleteResources \ | |
| --deny-settings-mode None |
đź§© Step 4: Restructure with Templates
To follow the DRY (Don’t Repeat Yourself) principle, move the repeated stage logic into a template file:
pipelines/templates/deploy.yml:
| parameters: | |
| - name: environment | |
| type: string | |
| - name: azureSubscription | |
| type: string | |
| - name: stackName | |
| type: string | |
| - name: location | |
| type: string | |
| default: "westeurope" | |
| jobs: | |
| - deployment: DeployInfra${{ parameters.environment }} | |
| displayName: "Deploy Infrastructure to ${{ parameters.environment }}" | |
| environment: "${{ parameters.environment }}" | |
| strategy: | |
| runOnce: | |
| deploy: | |
| steps: | |
| - checkout: self | |
| displayName: "Checkout source code" | |
| - task: AzureCLI@2 | |
| displayName: "Deploy Infrastructure to ${{ parameters.environment }}" | |
| inputs: | |
| azureSubscription: "${{ parameters.azureSubscription }}" | |
| scriptType: bash | |
| scriptLocation: inlineScript | |
| useGlobalConfig: false | |
| inlineScript: | | |
| az stack sub create \ | |
| --template-file ./infra/main.bicep \ | |
| --name ${{ parameters.stackName }} \ | |
| --location ${{ parameters.location }} \ | |
| --action-on-unmanage deleteResources \ | |
| --deny-settings-mode None |
With a template file, the main azure-pipelines.yml becomes lighter:
| trigger: | |
| - main | |
| variables: | |
| azureSubscription: "demo-azure-connection" | |
| pool: | |
| vmImage: ubuntu-latest | |
| stages: | |
| - stage: DeployDEV | |
| displayName: "Deploy to DEV" | |
| jobs: | |
| - template: pipelines/templates/deploy.yml | |
| parameters: | |
| environment: "DEV" | |
| azureSubscription: "$(azureSubscription)" | |
| stackName: "stack-iac-demo-dev" |
Commit and push your changes to Azure DevOps and see that it works.
đź§Ş Step 5: Parameterize Bicep
Now one step before we can add TEST and PROD is making sure everything is parameterized.
Add an environment parameter to infra/main.bicep:
| targetScope = 'subscription' | |
| // THIS IS NEW for step 5 | |
| @description('Environment name (e.g., DEV, TEST, PROD)') | |
| @allowed(['DEV', 'TEST', 'PROD']) | |
| param environment string = 'DEV' | |
| // END OF NEW for step 5 | |
| param appName string = 'iac-demo' | |
| param location string = 'westeurope' | |
| // REPLACE THIS LINE WITH THE NEXT ONE, STEP 5 | |
| // var resourceGroupName = 'rg-${appName}' | |
| param resourceGroupName string = 'rg-${appName}-${environment}' | |
| resource newRG 'Microsoft.Resources/resourceGroups@2024-11-01' = { | |
| name: resourceGroupName | |
| location: location | |
| } |
Create a parameter file for DEV: infra/parameters/DEV.bicepparam:
| using '../main.bicep' | |
| param environment = 'DEV' | |
| param appName = 'iac-demo' | |
| param location = 'westeurope' | |
| param resourceGroupName = 'rg-${appName}-${environment}' |
Update the template to include parameters in pipelines/templates/deploy.yml:
--parameters ./infra/parameters/${{ parameters.environment }}.bicepparam
| parameters: | |
| - name: environment | |
| type: string | |
| - name: azureSubscription | |
| type: string | |
| - name: stackName | |
| type: string | |
| - name: location | |
| type: string | |
| default: "westeurope" | |
| jobs: | |
| - deployment: DeployInfra${{ parameters.environment }} | |
| displayName: "Deploy Infrastructure to ${{ parameters.environment }}" | |
| environment: "${{ parameters.environment }}" | |
| strategy: | |
| runOnce: | |
| deploy: | |
| steps: | |
| - checkout: self | |
| displayName: "Checkout source code" | |
| - task: AzureCLI@2 | |
| displayName: "Deploy Infrastructure to ${{ parameters.environment }}" | |
| inputs: | |
| azureSubscription: "${{ parameters.azureSubscription }}" | |
| scriptType: bash | |
| scriptLocation: inlineScript | |
| useGlobalConfig: false | |
| inlineScript: | | |
| az stack sub create \ | |
| --template-file ./infra/main.bicep \ | |
| # THIS IS NEW for step 5: parameters | |
| --parameters ./infra/parameters/${{ parameters.environment }}.bicepparam \ | |
| # END OF NEW for step 5: parameters | |
| --name ${{ parameters.stackName }} \ | |
| --location ${{ parameters.location }} \ | |
| --action-on-unmanage deleteResources \ | |
| --deny-settings-mode None |
Try this out by pushing the changes to Azure DevOps.
đź§Ş Step 6: Add TEST and PROD
Now that we have parameterized everything, adding the TEST and PROD environments is really really simple.
Create two more parameter files:
infra/parameters/TEST.bicepparam:
| using '../main.bicep' | |
| param environment = 'TEST' | |
| param appName = 'iac-demo' | |
| param location = 'westeurope' | |
| param resourceGroupName = 'rg-${appName}-${environment}' |
infra/parameters/PROD.bicepparam:
| using '../main.bicep' | |
| param environment = 'PROD' | |
| param appName = 'iac-demo' | |
| param location = 'westeurope' | |
| param resourceGroupName = 'rg-${appName}-${environment}' |
Then add two more stages to your pipeline in azure-pipelines.yml:

| trigger: | |
| - main | |
| variables: | |
| azureSubscription: 'demo-azure-connnection' | |
| pool: | |
| vmImage: ubuntu-latest | |
| stages: | |
| - stage: DeployDEV | |
| displayName: 'Deploy to DEV' | |
| jobs: | |
| - template: pipelines/templates/deploy.yml | |
| parameters: | |
| environment: 'DEV' | |
| azureSubscription: '$(azureSubscription)' | |
| stackName: 'stack-iac-demo-dev' | |
| - stage: DeployTEST | |
| displayName: 'Deploy to TEST' | |
| jobs: | |
| - template: pipelines/templates/deploy.yml | |
| parameters: | |
| environment: 'TEST' | |
| azureSubscription: '$(azureSubscription)' | |
| stackName: 'stack-iac-demo-test' | |
| - stage: DeployPROD | |
| displayName: 'Deploy to PROD' | |
| jobs: | |
| - template: pipelines/templates/deploy.yml | |
| parameters: | |
| environment: 'PROD' | |
| azureSubscription: '$(azureSubscription)' | |
| stackName: 'stack-iac-demo-prod' |
Further steps
Althought not in scope, but consider taking it further. Try to:
- Set up Approvals for environments
- Add resources to the IaC
- Deploy the apps (CI/CD)
đź§Ľ Cleanup
Since we’re using Deployment Stacks, cleanup is easy:
| # execute in bash | |
| stacks=$(az stack sub list --query "[?starts_with(name, 'stack-iac-demo')].name" -o tsv) | |
| for name in $stacks; do az stack sub delete --name "$name" --action-on-unmanage deleteAll --yes; done |
Summary
This guide walks you through setting up IaC with Azure DevOps using Bicep and Deployment Stacks. Even a minimal setup can make a big difference. From here, consider adding:
- Environment approvals
- More resources
- CI/CD for app deployment