Bicep at v0.4 in January 2022: Ready for Production, With Caveats
For most Azure teams the question has moved on from “what is Bicep?” to “should we move our ARM templates and pipelines to it this year?” That is a different question. It is about support, tooling, governance and the gaps you will hit six months in, not syntax. This is my assessment of where Bicep stands in mid-January 2022, at v0.4.1124, and the decision I’d make for an Azure-only platform team.
If you want the language basics first, start with my Bicep introduction. This post assumes you’ve written a template or two.
Where Bicep actually is today
Three facts frame the decision.
It is supported for production. Since v0.3 (March 2021), Bicep issues are covered by Microsoft support plans, and the Bicep FAQ answers “is it ready for production?” with a plain yes. The version number still starts with zero, and Microsoft hasn’t ruled out breaking changes, so pin your CLI version in pipelines rather than floating on whatever the agent has.
The 0.4 line added the governance pieces. v0.4 (June 2021) brought the built-in linter. v0.4.1008 (October 2021) added private module registries on Azure Container Registry (br: references) and template specs as a module source (ts:). v0.4.1124 (December 2021) added #disable-next-line suppression, more linter rules and an “Insert Resource” command in VS Code. All of this is in the Bicep release history on GitHub.
It is still ARM underneath. Bicep compiles to ARM JSON and deploys through Azure Resource Manager. Nothing in the deployment engine changed. That is the whole value proposition and also the source of every limitation below.
What you get on day one
Here is how I summarise the trade-off for Azure-only estates:
| Concern | Bicep (v0.4) | Terraform (AzureRM provider) |
|---|---|---|
| New resource types and API versions | Deployable as soon as ARM publishes them (type validation may lag, shown as warning BCP081) | Waits for a provider release |
| State | None; Azure is the source of truth | State file you must store, lock and protect |
| Preview of changes | ARM what-if | terraform plan |
| Removing resources you deleted from code | Only via complete mode, resource group scope | Built in |
| Non-Azure resources (Azure AD apps, GitHub, DNS elsewhere) | Not supported | Supported through other providers |
| Module sharing | Private registry on ACR, template specs | Terraform Registry, private registries |
The “no state” row cuts both ways, and people tend to quote only the good half. Not managing a state file removes a whole class of operational work: no storage account to secure, no lock contention, no state surgery after a failed apply. But it also means Bicep doesn’t know what it deployed last time. Delete a storage account from your template, redeploy in incremental mode, and the storage account stays. Terraform would destroy it.
A shape that holds up
The structure I’d standardise on: a subscription-scoped entry point that creates resource groups, with modules doing the work inside each group. Parameters are decorated so most bad input fails at compile or validation time rather than halfway through a deployment, and the module lowercases the name because decorators can’t check character sets.
// main.bicep
targetScope = 'subscription'
@allowed([
'dev'
'test'
'prod'
])
param environment string
param location string = 'australiaeast'
@minLength(3)
@maxLength(11)
@description('Lowercase letters and numbers only, 3-11 characters.')
param workload string
resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = {
name: 'rg-${workload}-${environment}'
location: location
tags: {
environment: environment
managedBy: 'bicep'
}
}
module storage 'modules/storage.bicep' = {
name: 'storage-${environment}'
scope: rg
params: {
namePrefix: workload
location: location
containerNames: [
'raw'
'curated'
]
}
}
output storageAccountName string = storage.outputs.storageAccountName
// modules/storage.bicep
@maxLength(11)
param namePrefix string
param location string
param containerNames array
resource account 'Microsoft.Storage/storageAccounts@2021-08-01' = {
name: toLower('${namePrefix}${uniqueString(resourceGroup().id)}')
location: location
sku: {
name: 'Standard_ZRS'
}
kind: 'StorageV2'
properties: {
minimumTlsVersion: 'TLS1_2'
supportsHttpsTrafficOnly: true
allowBlobPublicAccess: false
}
}
resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2021-08-01' = {
parent: account
name: 'default'
}
resource containers 'Microsoft.Storage/storageAccounts/blobServices/containers@2021-08-01' = [for containerName in containerNames: {
parent: blobService
name: containerName
}]
output storageAccountName string = account.name
The @maxLength(11) on the prefix is deliberate: uniqueString() returns 13 characters, and storage account names cap at 24. That kind of constraint belongs in the template, not in a wiki page. Decorators can’t check a string’s character set in v0.4, though, so toLower() in the module guards against a prefix like DataLake; a hyphen would still fail at deployment, which is why the @description spells out the rule.
Note what the module’s parameters are typed as: string and array. There is no way in v0.4 to declare “an array of objects with these properties”. Decorators like @allowed, @minLength and @secure() are all you have for input validation. If you pass complex objects into modules, write down the expected shape in a @description and validate it in review.
Turn the linter on properly
The linter is the most underused part of Bicep. By default most rules are warnings, which in a pipeline means nobody sees them. I set the rules I care about to error in a bicepconfig.json at the repository root so az bicep build fails when they trip:
{
"analyzers": {
"core": {
"enabled": true,
"verbose": false,
"rules": {
"no-hardcoded-env-urls": {
"level": "error"
},
"no-unused-params": {
"level": "error"
},
"no-unused-vars": {
"level": "warning"
},
"secure-parameter-default": {
"level": "error"
},
"outputs-should-not-contain-secrets": {
"level": "error"
}
}
}
}
}
outputs-should-not-contain-secrets is the one I’d never relax. Deployment outputs are readable by anyone with read access to the deployment history, so a connection string in an output is a credential leak with an audit trail. The linter documentation lists every rule. When you genuinely need an exception, use #disable-next-line <rule-name> on the line itself so the exception is visible in code review, rather than switching the rule off for the whole repo.
Pipeline: build, what-if, then deploy
The pipeline doesn’t need to be clever. Compile (which runs the linter), run what-if against the target subscription, and gate the deployment behind an environment approval.
# azure-pipelines.yml
trigger:
branches:
include:
- main
pool:
vmImage: 'ubuntu-latest'
stages:
- stage: Validate
jobs:
- job: BuildAndWhatIf
steps:
- task: AzureCLI@2
inputs:
azureSubscription: '<your-service-connection>'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
set -euo pipefail
az bicep install --version v0.4.1124
az bicep build --file main.bicep
az deployment sub what-if \
--location australiaeast \
--template-file main.bicep \
--parameters environment=prod 'workload=<your-workload>'
- stage: Deploy
dependsOn: Validate
jobs:
- deployment: DeployProd
environment: 'prod'
strategy:
runOnce:
deploy:
steps:
- checkout: self
- task: AzureCLI@2
inputs:
azureSubscription: '<your-service-connection>'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
set -euo pipefail
az bicep install --version v0.4.1124
az deployment sub create \
--location australiaeast \
--template-file main.bicep \
--parameters environment=prod 'workload=<your-workload>'
Two details matter. First, deployment jobs don’t check out the repository by default, so the explicit checkout: self is required or the deploy step can’t find main.bicep. Second, the pinned az bicep install --version means a new Bicep release can’t change your compiled output between the what-if and the deploy.
Be honest with your approvers about what-if. It is useful, but it reports noise: properties the resource provider fills in or normalises show up as changes on every run. It also can’t evaluate values that come from reference() or module outputs at deploy time, so those properties show as unknown. Treat it as a review aid, not a guarantee. I go into the noise problem in the follow-up post on what-if.
The gaps that still bite
These are the things I’d want a team to know before they commit:
- No custom types. As above, parameters are primitives, arrays and loosely typed objects. Large modules end up with long parameter lists or untyped configuration objects.
- Deployment-time constants. Resource names, and a few other properties, must be computable when the deployment starts. You can’t name a resource from a module output or a
reference()to something deployed in the same run. You’ll meet this as error BCP120 and restructure around it. - Drift and deletion. No state means no automatic cleanup. Complete mode deletes unmanaged resources, but only at resource group scope and only for resources that support it. Most teams I’d advise to stay in incremental mode and handle removals deliberately.
- Only ARM. Azure AD app registrations and groups, GitHub repositories, Datadog monitors: none of these are ARM resources, so Bicep can’t create them. You’ll need scripts or another tool for that layer.
- The public module registry isn’t here yet. The v0.4.1008 release notes say it is planned for v0.5. Today, sharing means running your own registry on ACR, which I cover in the next post on the Bicep registry.
When I wouldn’t choose Bicep
If your estate spans clouds, or your infrastructure definition has to include Azure AD objects, Kubernetes resources and SaaS configuration in the same plan, Terraform is still the better fit. One tool and one plan across all of it beats Bicep plus a pile of scripts. The same goes for a team that already runs Terraform well, with remote state, policy checks and module versioning in place. Migrating that to Bicep buys you day-zero API versions and removes the state file, and that’s rarely worth a rewrite.
For a team still hand-maintaining ARM JSON, the call is easy. Run az bicep decompile on your existing templates (the decompile docs are clear that the output needs cleanup, and it does), fix the warnings, and move on. There is no scenario I can think of where staying on raw ARM JSON is the better choice in 2022.
My call
For Azure-only platforms, Bicep is my default this year. It is supported, the linter and private registry close most of the governance gap that kept enterprises on ARM JSON or Terraform, and new Azure features are usable the day they ship. Go in knowing the limits: no custom types, no state-driven deletion, and nothing outside ARM. Pin the CLI version, make the linter fail builds, and treat what-if as a review aid rather than a contract.