R-03 Foundry & Azure
Bicep patterns for maintainable Azure platforms

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.bicepparamfile 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.bicepinto 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
.bicepparamfiles. Get secrets viagetSecretfrom Key Vault, never from the repo. - Test deployment stacks with
detachAllfirst, 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.
