In my previous blog What is an Azure deployment? we talked about what a deployment was and concluded that a deployment is a container or wrapper around requests to ARM for resources. One of the key advantages of using a deployment to deploy resources is validation or the ability to understand before a request is made whether it is valid.
Why should we validate a deployment?
Just like the practice of defence in depth with security writing IAC should have a quality in depth approach, and just like applying security in a shift left way (doing security earlier rather than later in the process) we should catch quality issues in bicep as far ‘left’ as possible because these same issues are more problematic when caught later.
So what are the layers of quality check we use to make sure Bicep code is high quality, error free and secure?
Lint
Linting is the process of checking code for potential errors and best practice violations. The Bicep CLI has a built-in linter which can lint files we pass to it to find syntax errors and enforce coding standards.
az bicep lint -f main.bicep
So let’s try this out on a Bicep file to see what it comes up with:
param name string
param name string
param anotherParam string = 'usefulString'
resource sa 'Microsoft.Storage/storageAccounts@2032-06-01' = {
name: name
location: 'uksmouth'
kind: 'BlobStorage'
sku: {
name: 'ReallyGood'
}
properties: {
accessTier: 'Hot'
allowBlobPublicAccess: false
defaultToOAuthAuthentication: true
minimumTlsVersion: 'TLS1_8'
networkAcls: {
bypass: 'AzureServices'
defaultAction: 'Deny'
ipRules: []
virtualNetworkRules: []
}
supportsHttpsTrafficOnly: true
}
}
Bearing in mind VS Code has the Bicep linter built in, the az bicep lint command is useful because it means that linting can be performed in a pipeline task for example.
The output shows two errors and two warnings: I’ve declared the ‘name’ param twice and declared ‘anotherParam’ but not used it in the code. So through linting I’ve caught code quality errors, and importantly I can also define what linting checks are performed.
We can also run the build command, compiling Bicep into ARM JSON. This uses the same linting that the lint command uses but also produces an ARM template built from the Bicep passed to the command:
az bicep build -f main.bicep
Custom linter rules
The VS Code extension and the Bicep CLI use default linter rules based on the arm-ttk test cases (https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/linter). Custom linter rules can be added using a bicepconfig.json file placed in your project root:
{
"analyzers": {
"core": {
"enabled": true,
"rules": {
"adminusername-should-not-be-literal": {
"level": "warning"
},
"no-unused-params": {
"level": "off"
},
"what-if-short-circuiting": {
"level": "off"
}
}
}
}
}
So in the above config we are specifying that the rule ‘no-unused-params’ is off, and now our linter ignores the unused param. The lint command has no output either.
and the lint command has no output either
Validate
From the example above it’s clear that linting can check our Bicep to ensure we’re following best practice and there are no syntactical errors. If we look at the Bicep above we can see the linter is happy with all the properties we have passed — for example the linter expects the minimumTlsVersion value to be a string and as long as a string is passed it’s happy. But we’ve passed ‘TLS1_8’ and TLS 1.8 does not exist. The Bicep is syntactically correct, but it isn’t valid.
In a previous post we talked about what a deployment is (https://medium.com/@jonny\_11585/what-is-an-azure-deployment-e327e4ebc120) — it’s a wrapper around a request to ARM. The az deployment group command has several subcommands such as validate.
Let's run the validate command on our template.
az deployment group validate -g rg -f main.bicep -p name=name
the output of this command shows us our location is invalid:
{"code": "LocationNotAvailableForResourceType", "message": "The provided location 'uksmouth' is not available for resource type 'Microsoft.Storage/storageAccounts'.
List of available regions for the resource type is 'eastus,eastus2,westus,westeurope,eastasia,southeastasia,japaneast,japanwest,northcentralus,southcentralus,centralus,northeurope,brazilsouth,australiaeast,australiasoutheast,southindia,centralindia,westindia,canadaeast,canadacentral,westus2,westcentralus,uksouth,ukwest,koreacentral,koreasouth,francecentral,australiacentral,southafricanorth,uaenorth,switzerlandnorth,germanywestcentral,norwayeast,westus3,jioindiawest,swedencentral,qatarcentral,polandcentral,italynorth,israelcentral,mexicocentral,spaincentral,newzealandnorth,indonesiacentral,malaysiawest,chilecentral,austriaeast,belgiumcentral'."}
So lint and build missed the invalid location but validate has picked up on it. If we change the location to ‘uksouth’ our Bicep passes validation:
{
"error": null,
"id": "/subscriptions/sub_id/resourceGroups/rg/providers/Microsoft.Resources/deployments/main",
"name": "main",
"properties": {
"correlationId": "6d51362d-0ed0-479c-9e00-6d0a04c7df48",
"debugSetting": null,
"dependencies": [],
"duration": "PT0S",
"error": null,
"mode": "Incremental",
"onErrorDeployment": null,
"outputResources": null,
"outputs": null,
"parameters": {
"anotherParam": {
"type": "String",
"value": "usefulString"
},
"name": {
"type": "String",
"value": "name"
}
},
"parametersLink": null,
"providers": [
{
"id": null,
"namespace": "Microsoft.Storage",
"providerAuthorizationConsentState": null,
"registrationPolicy": null,
"registrationState": null,
"resourceTypes": [
{
"aliases": null,
"apiProfiles": null,
"apiVersions": null,
"capabilities": null,
"defaultApiVersion": null,
"locationMappings": null,
"locations": [
"uksouth"
],
"properties": null,
"resourceType": "storageAccounts",
"zoneMappings": null
}
]
}
],
"provisioningState": "Succeeded",
"templateHash": "12093554567674034375",
"templateLink": null,
"timestamp": "2026-03-08T06:52:44.305259+00:00",
"validatedResources": [
{
"id": "/subscriptions/sub_id/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/name",
"resourceGroup": "rg"
}
]
},
"resourceGroup": "rg",
"type": "Microsoft.Resources/deployments"
}
But even the validate command has missed many issues with the template: a resource provider API version that doesn’t exist (2032–06–01), the SKU ‘ReallyGood’, minimum TLS version ‘TLS1_8’, and the string ‘car’ in the ipRules array.
param name string
param anotherParam string = 'usefulString'
resource sa 'Microsoft.Storage/storageAccounts@2032-06-01' = {
name: name
location: 'uksouth'
kind: 'BlobStorage'
sku: {
name: 'ReallyGood'
}
properties: {
accessTier: 'Hot'
allowBlobPublicAccess: false
defaultToOAuthAuthentication: true
minimumTlsVersion: 'TLS1_8'
networkAcls: {
bypass: 'AzureServices'
defaultAction: 'Deny'
ipRules: ['car']
virtualNetworkRules: []
}
supportsHttpsTrafficOnly: true
}
}
By using — debug we can see the validate command reaches out to a validate endpoint
az deployment group validate -g rg -f main.bicep -p name=name --debug
cli.azure.cli.core.sdk.policies: Request URL: ‘https://management.azure.com/subscriptions/my_sub_id/resourcegroups/rg/providers/Microsoft.Resources/deployments/main/ validate?api-version=2022-09-01'
The REST API docs (https://learn.microsoft.com/en-us/rest/api/resources/deployments/validate) describe this endpoint as: “Validates whether the specified template is syntactically correct and will be accepted by Azure Resource Manager.” The key word here is “Azure Resource Manager” — not the resource provider. So the validate command doesn't actually check validity with the resource provider.
What-If
Even the what-if command doesn’t catch these issues. What-if is fundamentally a diff tool, not a validation tool — it compares your template against the current state and tells you what would change, but it doesn’t validate whether those changes would actually succeed:
+ Microsoft.Storage/storageAccounts/name [2032-06-01]
apiVersion: "2032-06-01"
id: "/subscriptions/sub_id/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/name"
kind: "BlobStorage"
location: "uksouth"
name: "name"
properties.accessTier: "Hot"
properties.allowBlobPublicAccess: false
properties.defaultToOAuthAuthentication: true
properties.minimumTlsVersion: "TLS1_8"
properties.networkAcls.bypass: "AzureServices"
properties.networkAcls.defaultAction: "Deny"
properties.networkAcls.ipRules: [
0: "car"
]
properties.supportsHttpsTrafficOnly: true
sku.name: "ReallyGood"
type: "Microsoft.Storage/storageAccounts"
* Microsoft.Storage/storageAccounts/3d65fr67tvbiu
Resource changes: 1 to create, 1 to ignore.
The what-if happily echoes back all the invalid values — ‘ReallyGood’, ‘TLS1_8’, ‘car’ — without flagging any of them. It simply says “looks good to me”.
Validation gap
So we have a significant gap: lint/build checks code quality and type structure, validate checks whether ARM will accept the template structure, and what-if shows what would change — but none of them validate whether the resource provider will accept the property values. The only thing that truly validates resource property values is the actual deployment itself.
There are few Azure-native options to fully validate deployments before they run, but there are strategies to make sure our IaC is valid.
Use Azure Verified Modules
Azure Verified Modules (https://azure.github.io/Azure-Verified-Modules/) are Microsoft’s official, Bicep modules that bake in best practices. They use typed parameters with constraints, so many invalid values are visible immediately.
Because the AVM module defines typed parameters with allowed values, passing ‘reallygood’ as a SKU name or ‘TLS1_8’ as a TLS version would be caught at build time — the Bicep compiler knows these aren’t valid values for the module’s parameters. You could also write your own modules that perform similar type validation using @allowed() decorators or user-defined types.
Use PSRule
PSRule for Azure (https://azure.github.io/PSRule.Rules.Azure/) is an open-source tool that validates ARM/Bicep templates against hundreds of rules covering Azure best practices. It runs offline and will catch all the issues in the above template.
PSRule runs on PowerShell and is a good candidate to be integrated into CI pipelines via GitHub Actions or Azure DevOps tasks without needing a local install.
Summary
Validating Bicep deployments requires a layered approach because no single tool catches everything. Lint and build catch code quality issues and type errors. ARM validate catches structural problems and invalid regions. What-if shows what would change but doesn’t validate values. And the resource provider only validates property values at actual deployment time.




Top comments (0)