HomeNotesTalksThe roast curve
M365 Barista Talk · LinkedIn
Home /Notes /R-03

R-03 Foundry & Azure

Bicep patterns for maintainable Azure platforms

22/10/20268 minDark · ImplementationShare
Ferdi Lethen-Oellers next to the lettering “Bicep Patterns: still clear in a year”

Imagine you are on vacation for three weeks. In the second week, a firewall rule in the prod VNet has to be changed, and it is urgent. Your colleague opens your Bicep repo. Does she find the right spot within five minutes? Or does she call you at the beach?

For me, that is the most honest test for infrastructure as code. Not whether the template runs today, but whether someone else can change it tomorrow without being afraid. The language makes things easy, and that is exactly why the first file just keeps growing. A platform only becomes maintainable through a few patterns you follow from the start. I show you five of them here.

1. Modules instead of a monolith

The most important rule: one file describes one thing. The network, the Log Analytics workspaces and the Key Vault each get their own module. The main.bicep then only does orchestration. It calls modules and passes outputs from one module to the next.

targetScope = 'subscription'

param location string = 'germanywestcentral'
param env string

resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' = {
  name: 'rg-platform-${env}'
  location: location
}

module logging 'modules/logging.bicep' = {
  scope: rg
  name: 'logging-${env}'
  params: { location: location, env: env }
}

module network 'modules/network.bicep' = {
  scope: rg
  name: 'network-${env}'
  params: {
    location: location
    env: env
    logAnalyticsId: logging.outputs.workspaceId
  }
}

Two details help in daily work. Give every module deployment a meaningful name, then you find it in the portal under “Deployments” right away. And only define outputs where another module really needs them.

2. Do not build what already exists: Azure Verified Modules

Before you write a storage module by hand, look into the registry. Azure Verified Modules are prebuilt, pretested and preverified modules from Microsoft, and they are now the only standard in the public Bicep registry.

For older templates this means: the former community modules stay in the registry, and your references keep working. But their source code has been replaced by a pointer to the AVM successor, and VS Code no longer suggests them. So they do not get any more updates. Your code does not break, it quietly ages. That is exactly why a look into your old templates pays off.

You reference them via the br/public alias:

module vault 'br/public:avm/res/key-vault/vault:0.11.0' = {
  name: 'kv-${env}'
  params: {
    name: 'kv-plat-${env}'
    location: location
    enablePurgeProtection: true
  }
}

Version 0.11.0 is an example. Take the current version from the AVM module index and pin it.

This is not always the whole solution. A wrapper module for your company standards still makes sense, for example for fixed tags, diagnostic settings and naming conventions. The wrapper calls the AVM module internally. This way you maintain your standards in one place and do not have to reinvent the wheel.

3. Parameter files per environment

If your main.bicep contains if (env == 'prod'), that is a warning sign. Environment differences belong in parameter files, not in the logic. That is what .bicepparam is for. The file is typed and checks your values right in the editor:

using './main.bicep'

param env = 'prod'
param location = 'germanywestcentral'

There is one file per environment (dev.bicepparam, test.bicepparam, prod.bicepparam) and one template. With @allowed, @minLength and @description on the template parameters, you catch typos before they land in Azure.

⚠️ Attention
Secrets do not belong in parameter files. Parameter files end up in Git, and the Git log never forgets. Mark the parameter in the template with @secure() and let the .bicepparam file fetch the value directly from Key Vault.

using './main.bicep'

param adminPassword = getSecret('<subscription-id>', 'rg-secrets', 'kv-secrets-prod', 'adminPassword')

For this to work, enabledForTemplateDeployment must be enabled on the Key Vault.

4. Deployment stacks: cleaning up is part of deploying

Bicep creates resources and changes them. If you delete a resource from the template, it still stays in Azure. After a year, you end up with a platform that no longer matches your code.

Deployment stacks solve exactly that. A stack remembers which resources it manages. You decide what happens to resources that are no longer in the template:

Setting What happens to removed resources?
detachAll They are only detached from management and stay in Azure.
deleteResources The resources are deleted.
deleteAll The resource groups are deleted as well.

With deny settings, you also protect managed resources against manual changes, either only against deletion (denyDelete) or against changes and deletion (denyWriteAndDelete).

az stack sub create \
  --name stack-platform-prod \
  --location germanywestcentral \
  --parameters prod.bicepparam \
  --action-on-unmanage detachAll \
  --deny-settings-mode denyWriteAndDelete

Because the .bicepparam file already points to the template via using, you do not need --template-file here.

My advice: start on a test subscription with detachAll until you understand everything the stack considers “its own”. Only then switch to deleteResources. Otherwise, a stack with a delete mode in the wrong scope deletes things you wanted to keep. This is not trivial, and a Monday morning with an empty resource group is not a good start to the week.

5. Quality in the pipeline: linter, what-if and review

Maintainability also comes from automation. Three things belong in every pull request pipeline:

Step 1: Build and linter. az bicep build or az bicep lint with the rules from bicepconfig.json. That is where you define which rules count as warnings and which as errors. My tip: enable the use-recent-module-versions rule. It warns you as soon as a pinned AVM version is outdated.

Step 2: What-if. az deployment sub what-if shows in the PR what would change. The reviewer then sees not just code, but its effect on the environment.

Step 3: Gate before prod. Prod is only rolled out after approval. Dev and test run automatically.

What-if is no guarantee, because the preview can show noise for some resource types. Still, it beats flying blind.

Which pattern for which case?

Situation Pattern Effort
Template gets hard to read Split into modules low
Standard resources (Key Vault, storage, VNet) Azure Verified Modules low
Dev, test and prod differ .bicepparam per environment low
Legacy and manual changes Deployment stacks medium
Several teams, shared platform Pipeline with linter and what-if medium

What stays

Bicep does not become maintainable through more features, but through discipline. Split into modules, use verified building blocks, separate configuration from code, and let stacks and pipeline make sure code and reality stay together. You do not have to introduce everything at once. Start with splitting into modules, that brings calm into your files the fastest. And your vacation stays a vacation.

☕ Espresso Moment: A good espresso needs a clean recipe, not more ingredients. So does your template.

Your takeaways

  • Split your main.bicep into modules, one responsibility per file.
  • Before building anything yourself, check whether an Azure Verified Module exists, and pin the version.
  • Search old templates for non-AVM modules. They keep running, but get no more updates.
  • Move environment differences into .bicepparam files. Get secrets via getSecret from Key Vault, never from the repo.
  • Test deployment stacks with detachAll first, before you let resources be deleted automatically.
  • Run linter and what-if in every pull request, including use-recent-module-versions.

Questions or feedback? Tell me on LinkedIn.

Share this note

Ferdi Lethen-Oellers
Ferdi Lethen-Oellers

Senior Modern Workplace Consultant at amexus, Microsoft MVP, author of “Microsoft 365 Administration für Dummies”.