Azure Bicep Infrastructure as Code: From Your First Resource to Safe Production Deployments
Imagine that you have built a .NET application and somebody asks, “What does it need in Azure?” You answer: an App Service, a hosting plan, Application Insights, Key Vault, a storage account, SQL Database, networking, permissions and perhaps a queue. The next question is more difficult: can another developer create the same environment reliably without following a twenty-page manual and clicking through the Azure portal?
That is the problem Infrastructure as Code solves. Azure Bicep lets us describe Azure infrastructure in readable text files, place those files in source control, review changes and deploy the same design repeatedly. This article will teach that journey from first principles. You do not need to know ARM templates, DevOps or Bicep already. We will build the mental model before the syntax, then progress from one storage account to reusable modules and guarded delivery pipelines.
The learning path is inspired by Yaser Adel Mehraban’s *Infrastructure as Code with Azure Bicep*. The attached book provides the foundation: why Bicep exists, tooling, resources, parameters, expressions, modules, outputs, local deployment, Azure DevOps, GitHub Actions and maintainability. Because Azure evolves quickly, current commands and newer capabilities have also been checked against Microsoft’s current Bicep documentation.
The objective is not to memorise keywords. By the end, you should understand what Azure Resource Manager does, how Bicep describes desired state, how dependencies are inferred, how modules create boundaries, how a pipeline validates change, and how to reason safely about production infrastructure.
1. The problem with building infrastructure by hand
Creating a resource in the Azure portal can be useful while learning. You choose a subscription, resource group, region and settings, then press Create. The problem appears when you need a second environment.
Can you remember every option you chose for development? Did production receive the same TLS settings? Was public network access disabled? Which tags were applied? Who changed the configuration three months later? If the environment is damaged, how long would rebuilding it take?
Manual provisioning creates several risks:
- inconsistency - environments that should match slowly drift apart;
- poor repeatability - rebuilding depends on somebody’s memory;
- weak review - a portal change may never pass through peer review;
- limited history - the team cannot easily see why configuration changed;
- slow delivery - people repeat the same sequence for every environment;
- recovery risk - infrastructure cannot be recreated confidently after failure.
IaC does not make infrastructure simple. Networks, identity and databases still require careful design. It makes the design visible and repeatable.
Imperative and declarative thinking
An imperative script says how to perform a task:
Create a resource group.
Create a storage account.
If creation fails, retry.
Find the storage account ID.
Apply a diagnostic setting.
A declarative file describes the result you want:
There should be a storage account with these properties.
It should send diagnostics to this workspace.
Bicep is declarative. Azure Resource Manager compares your declaration with Azure’s current state and performs the operations needed to move toward the desired state. You concentrate on what should exist, while the platform handles much of the orchestration.
This distinction is central. Bicep is not a general-purpose programming language for writing applications. It is a domain-specific language for defining Azure resources.
2. Azure Resource Manager and ARM templates
Before Bicep, we need to meet the engine beneath it: Azure Resource Manager, or ARM.
When you ask Azure to create a resource, the request passes through ARM. ARM handles authentication, authorization, policy, locks, tags, deployment history and resource organisation. A virtual network, web app or Key Vault is provided by an Azure resource provider, while ARM coordinates the request.
Azure supports JSON-based ARM templates. A template declares resources, parameters, variables, expressions and outputs. ARM templates are powerful and remain the deployment format understood by Azure. However, large JSON templates can become noisy. JSON needs quotation marks, commas and verbose expression syntax. It is easy for the business intent to disappear inside ceremony.
Bicep is a more concise authoring language. The Bicep compiler converts a .bicep file into an ARM JSON template. Azure still receives the ARM representation.
main.bicep
↓ Bicep build/CLI
ARM JSON template
↓ deployment request
Azure Resource Manager
↓
Azure resource providers
This has useful consequences:
- Bicep supports Azure resource types and API versions through ARM;
- there is no separate state file to maintain;
- the output can be inspected as normal ARM JSON;
- existing governance through Azure Policy and role-based access still applies;
- Bicep is an authoring improvement, not a replacement control plane.
Bicep compared with Terraform
Beginners often ask which tool is “better.” That is the wrong first question. Ask which problem and operating model you have.
Bicep is Azure-native, understands Azure resource schemas quickly, integrates closely with ARM and needs no external state file. Terraform is multi-cloud and has a broad provider ecosystem, but it maintains state that teams must protect and coordinate. Pulumi lets teams use general-purpose languages. Each choice has trade-offs in platform reach, team knowledge, state, governance and tooling.
If your infrastructure is predominantly Azure and your team wants native integration, Bicep is a strong default. If you require a consistent abstraction across several clouds, another tool may suit the organisational problem better.
3. Install a safe authoring environment
The easiest current setup is Visual Studio Code with Microsoft’s Bicep extension and the Azure CLI. The extension provides syntax highlighting, IntelliSense, type information, navigation, diagnostics, formatting and visualisation. Azure CLI automatically manages its own Bicep CLI instance when a Bicep command needs it.
Verify the tools:
az --version
az bicep version
Upgrade Azure CLI’s Bicep installation when appropriate:
az bicep upgrade
If you install the standalone Bicep CLI, commands use bicep build, bicep lint and similar forms. Through Azure CLI, they use az bicep build, az bicep version and so on. Azure PowerShell does not automatically reuse Azure CLI’s private Bicep installation, so install the standalone CLI when your PowerShell workflow requires it. Microsoft’s installation guide documents the current options.
Sign in for local experiments:
az login
az account show
az account set --subscription "My Development Subscription"
Always confirm the selected subscription before deploying. Many costly mistakes are not syntax errors; they are correct deployments sent to the wrong subscription.
Create a learning folder
infrastructure/
main.bicep
main.dev.bicepparam
main.prod.bicepparam
bicepconfig.json
modules/
Do not commit passwords, connection strings, private keys or client secrets. Parameter files are convenient, but they are still source files. Use managed identity and a secret store such as Key Vault instead of placing secrets in Git.
4. Your first Bicep resource
Create main.bicep:
targetScope = 'resourceGroup'
param location string = resourceGroup().location
param storageAccountName string
resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: storageAccountName
location: location
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
properties: {
accessTier: 'Hot'
allowBlobPublicAccess: false
minimumTlsVersion: 'TLS1_2'
}
}
output storageAccountId string = storage.id
Let us slow down and read every part.
targetScope says this file is deployed at resource-group scope. param declares information supplied from outside the template. A default location uses the current resource group’s region. The storage name has no default because Azure storage account names must be globally unique.
The resource declaration contains three identifiers:
resource storage 'Microsoft.Storage/storageAccounts@2023-05-01'
storageis the symbolic name used inside this Bicep file;Microsoft.Storage/storageAccountsis the Azure resource type;2023-05-01is the resource API version.
storage.id asks Bicep for the deployed resource’s Azure ID without manually constructing a long string.
The body defines common properties such as name and location, then resource-specific properties. IntelliSense helps because Bicep knows the schema for the selected API version.
Finally, an output exposes the storage account ID after deployment. Outputs are useful for pipelines or parent modules, but should not expose secrets.
Compile without deploying
az bicep build --file main.bicep
This creates ARM JSON and catches syntax/type problems. Inspect the JSON once. You do not normally maintain it, but seeing the compilation result strengthens your mental model.
5. Names, types and interpolation
Bicep supports strings, integers, booleans, arrays and objects. Modern Bicep also supports richer type declarations, but the core types are enough to begin.
param environmentName string
param enableZoneRedundancy bool = false
param retentionDays int = 30
var commonTags = {
environment: environmentName
managedBy: 'bicep'
system: 'customer-portal'
}
var appName = 'customer-${environmentName}'
${...} interpolates an expression into a string. Variables calculate internal values; parameters are part of the template’s external contract.
A healthy rule is:
- use a parameter when the caller should choose;
- use a variable when the template should derive;
- use a literal when the value is an intentional standard.
Decorators make contracts clearer
@description('Deployment environment used for naming and tags.')
@allowed([
'dev'
'test'
'prod'
])
param environmentName string
@minValue(1)
@maxValue(100)
param instanceCount int = 1
@secure()
param legacySecret string
Decorators add metadata or constraints. @secure() prevents a value being recorded in normal deployment history outputs, but it does not make committing a secret safe. Prefer secret references and managed identities.
6. Parameter files and environment differences
Development and production often share architecture but use different capacity, names and retention. Do not copy the entire template for each environment. Copying creates drift because a later security improvement may reach one file but not another.
A .bicepparam file connects environment values to one template:
using './main.bicep'
param environmentName = 'dev'
param storageAccountName = 'stcustomerdev001'
param retentionDays = 7
Production can use another file:
using './main.bicep'
param environmentName = 'prod'
param storageAccountName = 'stcustomerprod001'
param retentionDays = 90
The compiler checks names and types against main.bicep. This is stronger than an unrelated JSON parameter file.
Keep genuine environmental differences in parameter files. Keep organisation-wide standards in modules or policy. Keep secrets outside both.
7. Dependencies: let references tell the story
Resources depend on one another. A web app needs its hosting plan ID. A diagnostic setting needs the workspace ID. Bicep usually infers deployment order from symbolic references.
resource plan 'Microsoft.Web/serverfarms@2023-12-01' = {
name: 'plan-${environmentName}'
location: location
sku: {
name: 'P0v3'
}
}
resource app 'Microsoft.Web/sites@2023-12-01' = {
name: 'app-${environmentName}-${uniqueString(resourceGroup().id)}'
location: location
properties: {
serverFarmId: plan.id
httpsOnly: true
}
}
Because app references plan.id, Bicep knows the plan must exist first. Independent resources may deploy in parallel.
There is an explicit dependsOn property, but use it only when a real dependency is invisible in expressions. Unnecessary dependencies slow deployment and make the graph harder to understand.
Visual Studio Code can visualise the dependency graph. Use it when a template becomes complex. A surprising graph often reveals hidden coupling.
Reference existing resources
Not every resource should be created by the current template. A central platform team may already manage a Log Analytics workspace:
param monitoringResourceGroup string
param workspaceName string
resource monitoringRg 'Microsoft.Resources/resourceGroups@2024-03-01' existing = {
name: monitoringResourceGroup
}
resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = {
scope: monitoringRg
name: workspaceName
}
existing tells Bicep to reference rather than deploy. Deployment fails if the resource cannot be found at the stated scope, which is preferable to silently targeting the wrong object.
8. Understand deployment scopes
Azure organises resources in a hierarchy:
Tenant
└─ Management groups
└─ Subscriptions
└─ Resource groups
└─ Resources
Bicep deployments can target resource-group, subscription, management-group or tenant scope. Most application resources live in a resource group, but creating resource groups, assigning policy or configuring management groups requires a higher scope.
targetScope = 'subscription'
param location string
param resourceGroupName string
resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' = {
name: resourceGroupName
location: location
}
module application './modules/application.bicep' = {
name: 'applicationDeployment'
scope: rg
params: {
location: location
}
}
The subscription-scope file creates a resource group, then deploys a module into it. The identity running the deployment needs permission at the relevant scope.
Choose the narrowest sensible deployment scope. A pipeline that only manages one resource group should not automatically receive Owner rights across a subscription.
9. Expressions and functions
Bicep expressions calculate values. Common functions expose deployment context or transform data:
var suffix = uniqueString(subscription().id, resourceGroup().id)
var safeEnvironment = toLower(environmentName)
var fullName = take('st${safeEnvironment}${suffix}', 24)
resourceGroup(), subscription() and tenant() return context. uniqueString() creates a deterministic hash from inputs. Deterministic means the same inputs return the same value; it does not mean globally unique under every imaginable combination.
Functions exist for strings, arrays, objects, dates, resources and deployments. Use them to express derivation, not to hide an unreadable program inside one line. If an expression requires prolonged decoding, assign intermediate variables with meaningful names.
Be careful with runtime values
Some values are known during compilation. Others exist only after Azure evaluates or creates a resource. This affects conditions, loops and outputs. The editor often warns when a value cannot be used in the requested phase.
The practical lesson is to rely on symbolic references and type checking instead of manually building resource IDs wherever possible.
10. Conditions: optional infrastructure
You may need a feature only in production. Bicep’s if expression can conditionally deploy a resource:
param environmentName string
param deployPrivateEndpoint bool = environmentName == 'prod'
resource privateEndpoint 'Microsoft.Network/privateEndpoints@2024-01-01' = if (deployPrivateEndpoint) {
name: 'pe-storage-${environmentName}'
location: location
properties: {
// subnet and private-link configuration
}
}
Conditions are useful, but a template with dozens of switches becomes difficult to reason about and test. If two environments have fundamentally different architectures, separate compositions may be clearer than one universal file.
Also remember that a condition does not automatically cascade to child or dependent resources. Ensure every conditional dependency is handled safely.
11. Loops: repeat without copying
Suppose an application needs several storage containers. Copying a resource block creates maintenance risk. Use a loop:
param containers array = [
'documents'
'exports'
'imports'
]
resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = {
parent: storage
name: 'default'
}
resource container 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = [for containerName in containers: {
parent: blobService
name: containerName
properties: {
publicAccess: 'None'
}
}]
The parent relationship makes nesting explicit. Loops can use an item, an index, objects and conditions. They can also control batch size when Azure should not deploy every item concurrently.
Avoid using loops to hide unrelated resources inside one clever structure. Repetition with a shared shape is a good loop candidate; different business responsibilities deserve separate declarations or modules.
12. Modules: build understandable boundaries
A growing main.bicep can become as unmaintainable as a giant application class. A module is a Bicep file deployed by another Bicep file. It has parameters as inputs and outputs as results.
Create modules/storage.bicep:
param name string
param location string
param tags object = {}
resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: name
location: location
tags: tags
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
properties: {
allowBlobPublicAccess: false
minimumTlsVersion: 'TLS1_2'
supportsHttpsTrafficOnly: true
}
}
output id string = storage.id
output name string = storage.name
Consume it from main.bicep:
module storage './modules/storage.bicep' = {
name: 'storageDeployment'
params: {
name: storageAccountName
location: location
tags: commonTags
}
}
Read the module like a function call. The parent supplies a small contract; the module owns secure defaults and resource details.
What makes a good module?
A good module represents a stable capability, has a focused contract, enforces sensible standards, hides accidental complexity and produces useful non-secret outputs. It should not expose every resource property without thought.
Module boundaries can follow platform capabilities: web hosting, monitoring, storage, private networking or a complete application workload. Choose a level that supports ownership and reuse.
Registries and Azure Verified Modules
Local modules work well inside one repository. Organisations can publish versioned modules to a private registry backed by Azure Container Registry. Bicep also supports public Azure Verified Modules, which are prebuilt modules aligned with Microsoft practices.
module storage 'br/public:avm/res/storage/storage-account:0.18.0' = {
name: 'storageDeployment'
params: {
name: storageAccountName
}
}
Pin an explicit version. A shared module is a dependency and should not change unexpectedly. Evaluate a module’s contract, permissions and generated resources rather than assuming “verified” means “correct for every organisation.”
13. Outputs connect deployments carefully
Outputs expose information after deployment:
output appServiceName string = app.name
output appServicePrincipalId string = app.identity.principalId
output applicationUrl string = 'https://${app.properties.defaultHostName}'
A parent can use a module output:
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(storage.id, web.outputs.principalId, 'blob-reader')
scope: storage
properties: {
principalId: web.outputs.principalId
roleDefinitionId: subscriptionResourceId(
'Microsoft.Authorization/roleDefinitions',
blobReaderRoleId)
}
}
This reference creates a dependency from the role assignment to the web module.
Do not output storage keys, database passwords or secret values. Deployment outputs can be recorded in logs or history. Output identifiers and endpoints; let applications retrieve secrets through managed identity.
14. Managed identity: remove credentials from configuration
A common beginner design stores a connection secret in application settings. A stronger design gives the application a managed identity and grants that identity the minimum required role.
resource app 'Microsoft.Web/sites@2023-12-01' = {
name: appName
location: location
identity: {
type: 'SystemAssigned'
}
properties: {
serverFarmId: plan.id
httpsOnly: true
}
}
Azure creates an identity tied to the web app. A role assignment authorises it to read blobs, access Key Vault secrets or perform another narrow capability. The application uses Azure Identity libraries to obtain tokens without a stored password.
Separate identity from authorization:
- managed identity establishes who the workload is;
- role assignments establish what it may do;
- resource firewall/network rules establish where access may originate.
15. Compile, lint and format before deployment
Fast local feedback protects both developers and subscriptions.
az bicep format --file main.bicep
az bicep lint --file main.bicep
az bicep build --file main.bicep
Formatting produces consistent layout. The linter finds syntax problems and selected best-practice violations. Building confirms that Bicep can compile the template.
Configure the linter in bicepconfig.json:
{
"analyzers": {
"core": {
"enabled": true,
"rules": {
"no-hardcoded-env-urls": { "level": "warning" },
"outputs-should-not-contain-secrets": { "level": "error" },
"use-secure-value-for-secure-inputs": { "level": "error" }
}
}
}
}
Treat diagnostics deliberately. Do not disable a rule merely to make a pipeline green. Document why the exception is safe, scope it narrowly and revisit it.
Current Bicep also provides a local bicep snapshot capability for comparing a normalised representation of infrastructure logic. Snapshot testing and Azure what-if solve related but different problems: snapshot is local and detects template logic changes; what-if asks Azure to predict environmental changes.
16. Preview with what-if
Never make the first view of a production change the deployment itself. Azure Resource Manager’s what-if operation predicts additions, modifications, deletions and unchanged resources without applying them.
At resource-group scope:
az deployment group what-if \
--resource-group rg-customer-prod \
--template-file main.bicep \
--parameters main.prod.bicepparam
Then deploy:
az deployment group create \
--name customer-platform-20260728 \
--resource-group rg-customer-prod \
--template-file main.bicep \
--parameters main.prod.bicepparam
The command family changes with scope: az deployment sub, mg or tenant for subscription, management group or tenant deployments.
Read what-if output critically. Some provider behaviour cannot be predicted perfectly, and defaults or unresolved expressions can create noise. What-if is a safety aid, not a proof. Combine it with review, testing, policy and controlled rollout.
Validation levels
A good delivery path progressively increases confidence:
format → lint → compile → static/security checks
→ ARM validation → what-if → approval → deployment
→ smoke test → monitoring
Each stage catches a different category of problem. Compilation cannot prove your account has permission. ARM validation cannot prove the application works after deployment. A successful deployment cannot prove users can complete their journey.
17. Deployment modes, idempotency and deletion
Idempotency means applying the same desired definition repeatedly should converge on the same result rather than creating random duplicates. Declarative resource names and ARM deployments support this model.
However, you must understand deletion semantics. Standard ARM deployment commonly uses incremental behaviour: resources in the template are created or updated, but removing a declaration does not necessarily delete the existing resource. This protects against surprising deletion but allows unmanaged resources to remain.
Historically, complete deployment mode could remove resources absent from a template, but broad deletion is risky. Azure Deployment Stacks provide a newer lifecycle-management model: a stack knows which resources it manages and can detach or delete resources that become unmanaged, with optional deny settings.
Do not enable deletion because it sounds tidy. Ask:
- Is the resource stateful?
- Is data backed up and restorable?
- Could another workload share it?
- Does the deployment identity have overly broad scope?
- Is deletion visible in what-if and approval?
- What does rollback mean after destructive change?
18. Build a realistic .NET application environment
Let us compose a small production-minded platform:
Resource group
├─ Log Analytics workspace
├─ Application Insights
├─ App Service plan
├─ Linux web app with managed identity
├─ Storage account
├─ Key Vault
└─ Role assignments
The root file should describe composition rather than every low-level property:
targetScope = 'resourceGroup'
param environmentName string
param location string = resourceGroup().location
var suffix = uniqueString(subscription().id, resourceGroup().id)
var tags = {
environment: environmentName
workload: 'customer-api'
managedBy: 'bicep'
}
module monitoring './modules/monitoring.bicep' = {
name: 'monitoring'
params: {
location: location
environmentName: environmentName
tags: tags
}
}
module storage './modules/storage.bicep' = {
name: 'storage'
params: {
name: take('stcustomer${environmentName}${suffix}', 24)
location: location
tags: tags
}
}
module web './modules/web-app.bicep' = {
name: 'web'
params: {
appName: 'app-customer-${environmentName}-${suffix}'
location: location
environmentName: environmentName
applicationInsightsConnectionString:
monitoring.outputs.connectionString
tags: tags
}
}
Notice the story: create monitoring and storage, then web hosting that consumes monitoring configuration. A later role assignment connects the web identity to storage. Modules keep service detail out of the composition.
Configuration is not secret management
Values such as environment name, telemetry sampling percentage and feature flags may be normal configuration. Credentials and keys are secrets. Prefer identity-based connections. Where a secret remains unavoidable, store it in Key Vault and pass a secret URI or create a Key Vault reference rather than returning the value as an output.
Add diagnostics intentionally
Deploying Application Insights is not the same as observable software. Decide which resources emit diagnostic logs, where logs are retained, what metrics indicate health, and which alerts require action. Avoid enabling every category forever without cost review; observability also needs an information and retention design.
19. CI/CD with GitHub Actions
A deployment from a developer laptop is useful for learning but weak as a team production process. A pipeline provides repeatability, protected environments, logs and approval controls.
A simplified GitHub Actions workflow is:
name: infrastructure
on:
pull_request:
paths: ['infrastructure/**']
push:
branches: [main]
paths: ['infrastructure/**']
permissions:
id-token: write
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Build Bicep
run: az bicep build --file infrastructure/main.bicep
- name: Preview
run: >-
az deployment group what-if
--resource-group rg-customer-prod
--template-file infrastructure/main.bicep
--parameters infrastructure/main.prod.bicepparam
For deployment, add a job triggered only on the protected branch, target a protected GitHub environment and run az deployment group create after review.
The identity configuration should use OpenID Connect/workload identity federation rather than a long-lived client secret. GitHub receives a short-lived token for the approved repository, branch or environment. This reduces secret rotation and leakage risk.
Pin actions according to your organisation’s supply-chain policy, keep permissions minimal and separate validation from production deployment. Microsoft maintains a current GitHub Actions Bicep guide.
Pull request behaviour
On a pull request, format, lint, compile, scan and generate a what-if result. Reviewers should see both the Bicep diff and predicted Azure impact. Do not deploy untrusted pull-request code using a privileged production identity.
On merge, require the protected environment’s approval, deploy the reviewed commit, run smoke tests and record the deployment name/commit SHA.
20. CI/CD with Azure DevOps
The same principles apply in Azure Pipelines:
trigger:
branches:
include: [main]
paths:
include: [infrastructure]
stages:
- stage: Validate
jobs:
- job: ValidateBicep
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
- task: AzureCLI@2
inputs:
azureSubscription: service-connection-production
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az bicep build --file infrastructure/main.bicep
az deployment group what-if \
--resource-group rg-customer-prod \
--template-file infrastructure/main.bicep \
--parameters infrastructure/main.prod.bicepparam
Azure DevOps environments can provide approvals and checks. Service connections should use workload identity federation where supported and should receive minimum RBAC scope.
Do not hide all logic inside YAML. Keep infrastructure meaning in Bicep, and let the pipeline orchestrate validation, approval and deployment.
21. Test infrastructure at several levels
Infrastructure tests answer different questions.
Static checks
- Does Bicep compile?
- Does the linter find unsafe patterns?
- Are file names and module versions governed?
- Does a security scanner flag public access or weak TLS?
Deployment validation
- Does ARM accept resource types and properties?
- Are policies satisfied?
- Does the deployment identity have permission?
- What changes does what-if predict?
Post-deployment tests
- Does the application endpoint respond?
- Is HTTPS enforced?
- Can the managed identity access exactly the required resource?
- Are public endpoints disabled where intended?
- Do logs and alerts arrive?
Resilience and recovery tests
- Can the environment be recreated in an empty resource group?
- Can stateful data be restored?
- Can a failed deployment be diagnosed and resumed?
- Does a module upgrade preserve required data?
22. Common beginner mistakes and how to think through them
Mistake: hard-coding names and regions everywhere
Derive consistent names centrally and parameterise genuine variation. Respect service naming constraints and global uniqueness.
Mistake: passing secrets as ordinary parameters
Use managed identity first, Key Vault references second, secure parameters only when unavoidable. Never output secrets.
Mistake: using dependsOn for every resource
Let symbolic references infer dependencies. Add an explicit dependency only when no reference expresses the real ordering.
Mistake: one enormous template
Split around stable capabilities and ownership. Keep a readable root composition.
Mistake: a tiny module for every resource
Excessive fragmentation makes navigation and versioning harder. A module should provide meaningful abstraction, not merely move five lines elsewhere.
Mistake: deploying without what-if
Compile success says syntax is valid. It says nothing about a destructive environmental change. Preview and review.
Mistake: assuming successful deployment means successful service
Run health and integration checks. An App Service can exist while the application cannot start.
Mistake: broad pipeline permissions
Scope identities narrowly. Separate platform-level pipelines from application resource-group pipelines.
Mistake: using one mutable module version
Pin versions and upgrade through review. Consumers need stability.
Mistake: copying production for development without cost awareness
Keep the same architecture where useful, but parameterise capacity, retention and redundancy deliberately. Security should not disappear merely because an environment is called dev.
Mistake: treating generated ARM JSON as source
Maintain Bicep. Generated files are build artefacts unless a specific downstream process requires them.
23. Maintainability and team governance
Infrastructure lives for years. The first deployment is a small part of its cost.
A maintainable repository might look like:
infrastructure/
├─ main.bicep
├─ main.dev.bicepparam
├─ main.test.bicepparam
├─ main.prod.bicepparam
├─ bicepconfig.json
├─ modules/
│ ├─ monitoring.bicep
│ ├─ storage.bicep
│ ├─ key-vault.bicep
│ └─ web-app.bicep
├─ tests/
└─ README.md
The README should explain prerequisites, target scopes, naming, deployment commands, expected outputs, module ownership, recovery concerns and links to runbooks.
Version control everything relevant
Review Bicep, parameter files, policy definitions, pipeline code, alert definitions and dashboards. Emergency portal changes sometimes happen, but reconcile them back into code promptly or the next deployment may reverse them.
Define ownership
Who approves network modules? Who can publish shared module versions? Who owns production parameter values? Who responds to alerts? Technology without ownership becomes abandoned machinery.
Upgrade API and module versions carefully
New Azure API versions can add capabilities or change defaults. Do not perform blind repository-wide replacement. Read the resource documentation, inspect the diff, run what-if in a lower environment and validate behaviour.
Use policy as a guardrail
Bicep expresses intended resources; Azure Policy can deny or audit non-compliant configuration regardless of how it was created. Policies can require tags, allowed regions, private networking or diagnostic settings. Combine reusable modules for the paved road with policy for the safety boundary.
24. A production mentoring checklist
Before approving an infrastructure change, ask these questions.
Purpose
- What user or operational outcome needs this resource?
- Is an existing platform capability available?
- Who owns and pays for it?
Security
- Is authentication identity-based?
- Are role assignments minimal in privilege and scope?
- Is public network access necessary?
- Are secrets absent from code, outputs and logs?
- Do policy and locks protect critical resources?
Reliability
- What are the service-level and recovery requirements?
- Is zone or region redundancy needed?
- What happens if deployment stops halfway?
- Is stateful data backed up and restoration tested?
Performance and cost
- Does the SKU match measured needs?
- Can non-production capacity be lower safely?
- Are log retention and data transfer costs understood?
- Is automatic scaling bounded?
Delivery
- Do format, lint and compile pass?
- Has what-if been reviewed?
- Are destructive operations explicit?
- Is the production environment protected by approval?
- Does the pipeline use federated identity and least privilege?
Operations
- Are health signals, logs, metrics and alerts defined?
- Is there a runbook and owner?
- Can the team correlate a deployment with a commit?
- Has the post-deployment smoke test passed?
25. A seven-stage practice project
The fastest way to learn Bicep is to build in safe increments.
Stage 1: one resource
Create a development resource group manually, then deploy a locked-down storage account with Bicep. Compile and inspect ARM JSON. Delete and recreate it.
Stage 2: parameters and outputs
Add a .bicepparam file, tags, allowed environments and a safe output. Try an invalid value and read the diagnostic.
Stage 3: dependencies
Add a Log Analytics workspace and diagnostic settings. Visualise the dependency graph. Remove unnecessary dependsOn entries.
Stage 4: modules
Move storage and monitoring into focused modules. Keep main.bicep as composition. Add another environment without copying modules.
Stage 5: a .NET workload
Deploy an App Service with managed identity and Application Insights. Grant the application access to storage without a key. Publish a tiny ASP.NET Core API and verify telemetry.
Stage 6: safe automation
Create a pull-request pipeline that formats, lints, compiles and runs what-if. Use workload identity federation. Protect production with an environment approval.
Stage 7: operational proof
Add smoke tests, alerts, budgets and restoration notes. Recreate a non-production environment from an empty resource group. Record weaknesses and improve the modules.
At every stage, explain aloud what ARM will do before running the command. If your prediction differs from the result, that gap is the learning opportunity.
26. How to read a Bicep file confidently
When reviewing unfamiliar infrastructure, do not start at every property. Use this order:
- Read
targetScopeto learn the blast radius. - Read parameters to understand the external contract.
- Find secure inputs and verify secrets are handled safely.
- Read modules to see the architectural composition.
- Identify stateful and public resources.
- Trace symbolic references and role assignments.
- Read conditions and loops for environmental variation.
- Read outputs for information leaving the deployment.
- Inspect parameter files and pipeline identity.
- Review what-if before approving.
27. The deeper lessons
Azure Bicep teaches ideas that extend beyond its syntax.
Desired state is a contract. Your repository says what the platform should be. Drift is a mismatch to investigate.
Dependencies are architecture. A reference from a web module to monitoring or identity reveals how the system is connected.
Modules are team boundaries. Good modules encode secure defaults and allow consumers to move quickly without becoming experts in every Azure property.
Automation amplifies both quality and mistakes. A pipeline can reproduce a secure environment or delete the wrong resource consistently. Review, scope and preview determine which.
Security works best without secrets. Managed identity and federation reduce the burden of storing, distributing and rotating credentials.
A deployment is not an outcome. Users need a working, observable and recoverable service. Test beyond resource creation.
Infrastructure changes continuously. API versions, service capabilities, security guidance and costs evolve. Pin versions, review updates and maintain the code as a product.
28. Troubleshooting without guessing
When a deployment fails, resist changing several things at once. First identify which layer rejected the work.
A red underline in Visual Studio Code or a failed bicep build is usually an authoring problem: invalid syntax, an unknown property, a type mismatch or an unavailable resource schema. Read the complete diagnostic, including its BCP code. Check that the API version supports the property you used. IntelliSense can show the shape expected by that version.
An ARM validation failure occurs after compilation. The Bicep may be syntactically correct while Azure rejects its meaning. Common causes include an invalid name, an unavailable region/SKU combination, a missing required property or an Azure Policy denial. The deployment operation details normally identify the resource and provider message:
az deployment group show \
--resource-group rg-customer-dev \
--name my-deployment
az deployment operation group list \
--resource-group rg-customer-dev \
--name my-deployment \
--output table
An authorization failure means the deployment identity lacks an action at the requested scope. Do not immediately grant Owner. Determine which resource operation failed, which identity the pipeline actually used, and the narrowest role/scope that supplies the required action. Remember that creating a role assignment itself requires special authorization permission.
A naming conflict often appears with globally named services such as storage accounts and web apps. Use deterministic suffixes, obey length and character restrictions, and do not solve collision by introducing a random value that changes on every deployment. Stable names are important for idempotency.
An unregistered provider can block a resource type in a subscription. Confirm provider status and register it through the organisation’s approved process:
az provider show --namespace Microsoft.Web --query registrationState
A deployment can be partially successful. ARM may create independent resources before another resource fails. This is not automatically corruption; declarative deployment is designed to be run again after the fault is corrected. Inspect deployment operations and current resources, fix the cause, run what-if again and redeploy. Avoid manually deleting successful stateful resources unless the recovery plan requires it.
If what-if repeatedly reports changes that do not appear meaningful, investigate provider defaults and properties Azure normalises after deployment. Do not simply stop reviewing the output. Reduce noise by declaring stable values where appropriate, using current tool versions and understanding documented what-if limitations.
For application failures after successful infrastructure deployment, move outward through the chain: resource health, application startup logs, configuration references, managed-identity role assignment, network/DNS reachability and dependency health. Infrastructure success proves that Azure accepted resources; it does not prove the deployed workload can use them.
Keep a short incident note when troubleshooting teaches a reusable lesson. Update the module, validation rule, test or runbook so the same failure becomes easier—or impossible—the next time. This is how an IaC platform improves: not by never failing, but by converting failures into durable guardrails.
Conclusion
You began with a simple problem: reliably recreate the Azure environment needed by an application. Along the way, you learned that Bicep is a declarative Azure-specific language compiled to ARM JSON; symbolic references express dependencies; parameters and variables separate caller choice from template decisions; conditions and loops handle controlled variation; modules create reusable boundaries; outputs connect components; and managed identity replaces many dangerous secrets.
You also learned that professional IaC extends beyond the .bicep file. Formatting, linting, compilation, snapshot checks, ARM validation, what-if, peer review, protected environments, federated identity, smoke tests, policy, monitoring and recovery all contribute to safe delivery.
Do not try to master every Azure resource type. Master the reasoning process: identify the desired outcome, choose a narrow scope, express the design clearly, minimise permissions, preview change, deploy through a controlled identity, verify behaviour and preserve what you learned.
Your first storage account is not the final goal. The goal is confidence that your team can understand, review, reproduce and safely evolve the environment. That is the real promise of Infrastructure as Code.
