Until now, in the series, we have built a pipeline that produces an artifact and promotes it in a controlled way through dev, staging, and production, with approval gates. But we elegantly skipped over one question: where exactly do the Azure resources we deploy to come from?
If the answer is "we made them manually from the portal," then you have a modern pipeline that deploys on an infrastructure created by clicks. And that's where the problems start.
A manually created App Service cannot be versioned, cannot be recreated identically, and no one knows, six months later, what settings it has and why.
🎯 What Infrastructure as Code means
Infrastructure as Code (IaC) means you define your infrastructure – App Service, database, storage, networking – in code files that you keep in git, alongside the application.
The benefits are exactly what you expect from code:
-
Versioning – you see in git who changed a setting and when.
-
Reproducibility – you recreate the entire environment identically, anytime, in a few minutes.
-
Review – an infrastructure change goes through a pull request like any other code.
-
No drift – what’s in the files is the source of truth, not what someone clicked in the portal three months ago.
💠 Why Bicep
On Azure, you have a few options. ARM templates in JSON are powerful but painful to write and read. Terraform is excellent and multi-cloud, but it requires managing a separate state file.
Bicep is Azure’s native language for IaC and strikes a very good balance:
-
Clean, declarative syntax – much more readable than ARM JSON.
-
Transpiles to ARM – under the hood, ARM still runs, so you don’t lose anything from the platform.
-
No state file to manage – Azure already knows the state of the resources.
-
Idempotent – you run it ten times, the result is the same.
🧱 Essential syntax: a main.bicep file
A Bicep file has four basic pieces: parameters (param), variables (var), resources (resource), and outputs (output). Here is an example that creates an App Service Plan and a Web App for .NET 8:
@description('Base name of the application')
param appName string
@description('Azure region')
param location string = resourceGroup().location
@allowed([ 'dev', 'staging', 'prod' ])
param environment string
var planName = '${appName}-plan-${environment}'
var webAppName = '${appName}-${environment}'
resource plan 'Microsoft.Web/serverfarms@2023-12-01' = {
name: planName
location: location
sku: {
name: environment == 'prod' ? 'P1v3' : 'B1'
}
}
resource webApp 'Microsoft.Web/sites@2023-12-01' = {
name: webAppName
location: location
properties: {
serverFarmId: plan.id
httpsOnly: true
siteConfig: {
netFrameworkVersion: 'v8.0'
alwaysOn: environment == 'prod'
}
}
}
output webAppName string = webApp.name
output webAppUrl string = 'https://${webApp.properties.defaultHostName}'
Notice how natural it reads. The environment parameter controls the differences between environments: in prod we request a P1v3 plan with alwaysOn, otherwise a cheaper B1. The same file, different environments – exactly the philosophy from part 2, but now applied to infrastructure.
⚙️ How to apply it from the command line
A Bicep deployment is applied to a resource group. First, you create the group, then you run the deployment:
az group create --name rg-myapp-prod --location westeurope
az deployment group create \
--resource-group rg-myapp-prod \
--template-file ./infra/main.bicep \
--parameters appName=myapp environment=prod
Run the second command again. Nothing new happens – the resources already exist in the requested state. That is idempotency, and it is the reason you can put IaC in a pipeline without fear: it always applies the desired state, not "create again".
🔍 See what changes before applying: what-if
Before applying anything in production, you want to know exactly what will change. Bicep gives you a preview:
az deployment group what-if \
--resource-group rg-myapp-prod \
--template-file ./infra/main.bicep \
--parameters appName=myapp environment=prod
what-if shows you what resources would be created, modified, or deleted – like a git diff, but for infrastructure. It’s the kind of step you put before an approval gate.
🔗 IaC in pipeline: provision before deploy
In the pipeline, you add a job that applies the Bicep before deploying the application. This way, infrastructure and application are delivered together, from the same commit:
provision-infra:
runs-on: ubuntu-latest
needs: build-test
environment:
name: production
steps:
- uses: actions/checkout@v4
- name: Azure login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy Bicep
uses: azure/arm-deploy@v2
with:
resourceGroupName: rg-myapp-prod
template: ./infra/main.bicep
parameters: appName=myapp environment=prod
You see the azure/login step here without any password – just client-id, tenant-id, and subscription-id. How this authentication works without a client secret, via OIDC, is exactly the subject of part 5. For now, just remember that it’s possible and that it’s the correct way.
✅ Some good habits
-
One resource group per environment (rg-myapp-dev, rg-myapp-prod) – clean isolation between environments.
-
Break large files into reusable Bicep modules (one module for the database, one for the web app).
-
Keep a clear naming convention, derived from appName + environment.
-
Use outputs (webAppName, webAppUrl) in the next steps of the pipeline.
-
Run what-if before production – no infrastructure deploy blindly.
Conclusion
With Bicep, infrastructure is no longer a series of clicks that no one remembers, but versioned, reproducible code that goes through review – delivered in the same pipeline as the application. An entire environment becomes something you recreate identically in a few minutes.
In part 4, we use exactly this foundation for a concrete objective: deployment slots in Azure App Service, to deploy with zero downtime – and the slot can also be defined in Bicep.