Bicep Best Practices
Best practices in Bicep are patterns, conventions, and habits that make templates easier to read, safer to deploy, simpler to maintain, and consistent across teams. A template that works is not necessarily a template that is well-written. Best practices bridge that gap — they represent lessons learned from real deployments, team collaboration, and infrastructure at scale.
Think of best practices like building codes for a house. A house can stand without following them, but following them ensures safety, consistency, and that the next person who works on the structure understands what was built and why.
1. File and Project Organization
A well-organized project structure makes it easy to find files, understand scope, and onboard new team members. Use a consistent layout across all Bicep projects.
Recommended Project Structure
project/
│
├── main.bicep ← Entry point; orchestrates all modules
├── main.bicepparam ← Default parameter file
│
├── modules/ ← Reusable, single-purpose modules
│ ├── storage.bicep
│ ├── appservice.bicep
│ ├── keyvault.bicep
│ └── networking.bicep
│
├── environments/ ← Environment-specific parameter files
│ ├── dev.bicepparam
│ ├── staging.bicepparam
│ └── prod.bicepparam
│
└── .github/
└── workflows/
└── deploy.yml ← CI/CD pipeline definition
File Organization Rules
- One resource type per module – A storage module deploys only storage accounts. A networking module deploys only virtual networks and subnets.
- main.bicep orchestrates, modules do work – The main file calls modules and wires outputs to inputs. It does not define individual resources directly.
- Parameter files per environment – Never hard-code environment-specific values inside templates. Use
.bicepparamfiles for dev, staging, and prod differences. - Keep module files short – If a module is longer than 150 lines, consider splitting it further.
2. Naming Conventions
Consistent naming makes templates predictable. Someone reading the template for the first time should immediately understand what each parameter, variable, and resource does.
Parameter Naming
- Use camelCase for all parameter names.
- Name parameters after what they represent, not their type.
- Prefix boolean parameters with
enableoris.
// Good – clear, camelCase, descriptive
param storageAccountName string
param appServicePlanSku string
param enableDiagnostics bool
param isProduction bool
// Bad – unclear, inconsistent casing
param SA_Name string
param sku string
param diag bool
Variable Naming
- Use camelCase for variables.
- Prefix computed names with the resource type abbreviation when constructing resource names dynamically.
// Good
var storageAccountName = 'st${appName}${environment}'
var appServicePlanName = 'asp-${appName}-${environment}'
var keyVaultName = 'kv-${appName}-${environment}'
// Bad – no context about what the variable represents
var name1 = 'storage${appName}'
var thing = 'asp-${appName}'
Resource Symbolic Names
- Use camelCase for symbolic names.
- Name the symbol after the resource it represents.
- Avoid generic names like
resorr1.
// Good
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = { ... }
resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = { ... }
resource keyVault 'Microsoft.KeyVault/vaults@2023-02-01' = { ... }
// Bad
resource r1 'Microsoft.Storage/storageAccounts@2023-01-01' = { ... }
resource myresource 'Microsoft.Web/serverfarms@2023-01-01' = { ... }
Azure Resource Name Conventions
| Resource Type | Recommended Prefix | Example |
|---|---|---|
| Resource Group | rg- | rg-myapp-prod |
| Storage Account | st | stmyappprod |
| App Service Plan | asp- | asp-myapp-prod |
| App Service / Web App | app- | app-myapp-prod |
| Key Vault | kv- | kv-myapp-prod |
| Virtual Network | vnet- | vnet-myapp-prod |
| Subnet | snet- | snet-frontend-prod |
| Network Security Group | nsg- | nsg-frontend-prod |
| SQL Server | sql- | sql-myapp-prod |
| SQL Database | sqldb- | sqldb-myapp-prod |
3. Parameters — Best Practices
Parameters are the interface between the template and the outside world. Poorly designed parameters lead to invalid deployments, confusion, and security issues.
Always Add Descriptions
Every parameter should have a @description decorator explaining its purpose. This documentation appears in the Azure Portal and in IDE tooltips.
// Good – documented parameters
@description('The name of the storage account. Must be globally unique, 3–24 lowercase alphanumeric characters.')
param storageAccountName string
@description('The Azure region where all resources will be deployed.')
param location string = resourceGroup().location
@description('The pricing tier for the App Service Plan.')
@allowed(['F1', 'B1', 'B2', 'S1', 'P1v3'])
param appServicePlanSku string = 'B1'
// Bad – no documentation
param storageAccountName string
param location string = resourceGroup().location
param appServicePlanSku string = 'B1'
Constrain Parameters with Decorators
Use decorators to enforce valid values at validation time, before deployment begins.
@description('Storage account name must be 3–24 lowercase alphanumeric characters.')
@minLength(3)
@maxLength(24)
param storageAccountName string
@description('Number of App Service instances.')
@minValue(1)
@maxValue(10)
param instanceCount int = 1
@description('Allowed environments for this deployment.')
@allowed(['dev', 'staging', 'prod'])
param environment string
Use Default Values for Optional Parameters
Provide sensible defaults so callers only need to supply values that differ from the norm.
param location string = resourceGroup().location
param skuName string = 'Standard_LRS'
param enableHttpsOnly bool = true
Never Pass Secrets as Plain Parameters
Secrets passed as string parameters appear in deployment history in plain text. Always use Key Vault references for sensitive values.
// Bad – secret in plain text
param dbPassword string
// Good – reference Key Vault at deployment time
resource keyVault 'Microsoft.KeyVault/vaults@2023-02-01' existing = {
name: keyVaultName
scope: resourceGroup(keyVaultResourceGroup)
}
// Use the secret securely
resource sqlServer 'Microsoft.Sql/servers@2023-02-01-preview' = {
name: sqlServerName
properties: {
administratorLoginPassword: keyVault.getSecret('dbPassword')
}
}
4. Use Variables to Eliminate Repetition
If a value is used more than once, store it in a variable. Variables make the template easier to update and reduce the chance of inconsistency.
// Good – single definition, used everywhere
var resourcePrefix = '${appName}-${environment}'
var storageAccountName = 'st${appName}${environment}'
var appServicePlanName = 'asp-${resourcePrefix}'
var appServiceName = 'app-${resourcePrefix}'
var keyVaultName = 'kv-${resourcePrefix}'
var tags = {
application: appName
environment: environment
managedBy: 'bicep'
}
// Bad – repeated inline expressions
resource storageAccount '...' = {
name: 'st${appName}${environment}'
tags: {
application: appName
environment: environment
managedBy: 'bicep'
}
}
resource appServicePlan '...' = {
name: 'asp-${appName}-${environment}'
tags: {
application: appName
environment: environment
managedBy: 'bicep'
}
}
5. Tagging Strategy
Tags enable cost management, resource filtering, governance policies, and automation. Apply tags consistently across all resources using a shared variable.
// Define once as a variable
var commonTags = {
application: appName
environment: environment
owner: ownerEmail
costCenter: costCenterCode
managedBy: 'bicep'
deployedAt: utcNow()
}
// Apply to every resource
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: storageAccountName
location: location
tags: commonTags
sku: { name: skuName }
kind: 'StorageV2'
properties: { ... }
}
resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
name: appServicePlanName
location: location
tags: commonTags
sku: { name: appServicePlanSku }
}
Recommended Tags
| Tag Key | Example Value | Purpose |
|---|---|---|
| application | myapp | Identifies which app owns the resource |
| environment | prod | Separates dev / staging / prod costs |
| owner | team@company.com | Accountability and contact |
| costCenter | CC-1023 | Finance allocation |
| managedBy | bicep | Shows resource is IaC-managed |
6. Use Modules for Reusability
Modules encapsulate a set of resources behind a clean interface of parameters and outputs. They enable reuse across projects and prevent copy-paste drift between environments.
What Makes a Good Module
- Single responsibility – One module deploys one logical unit (e.g., a storage account or a virtual network).
- Well-defined parameters – All inputs are documented with
@description. - Useful outputs – Modules export resource IDs, names, and connection strings needed by other modules.
- No hard-coded values – Every environment-specific or project-specific value is a parameter.
// modules/storage.bicep
@description('Name of the storage account.')
@minLength(3)
@maxLength(24)
param storageAccountName string
@description('Azure region for the storage account.')
param location string
@description('Storage SKU.')
@allowed(['Standard_LRS', 'Standard_GRS', 'Premium_LRS'])
param skuName string = 'Standard_LRS'
@description('Tags to apply to the storage account.')
param tags object = {}
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: storageAccountName
location: location
tags: tags
sku: { name: skuName }
kind: 'StorageV2'
properties: {
supportsHttpsTrafficOnly: true
minimumTlsVersion: 'TLS1_2'
allowBlobPublicAccess: false
}
}
output storageAccountId string = storageAccount.id
output storageAccountName string = storageAccount.name
output primaryEndpoints object = storageAccount.properties.primaryEndpoints
// main.bicep – consuming the module
module storageModule './modules/storage.bicep' = {
name: 'storage-deployment'
params: {
storageAccountName: storageAccountName
location: location
skuName: storageSku
tags: commonTags
}
}
// Use the output from the storage module
output storageEndpoint string = storageModule.outputs.primaryEndpoints.blob
7. Always Pin API Versions
The API version controls the schema of the resource. Unpinned or automatically updated API versions can cause unexpected changes in resource behavior when the API evolves.
// Good – explicit, pinned API version
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = { ... }
resource keyVault 'Microsoft.KeyVault/vaults@2023-02-01' = { ... }
// Bad – never use 'latest' or omit the version
// Bicep requires an API version, but never rely on automatically-updated references
Rule: When updating an API version, review the changelog for the resource type to understand what properties changed. Update one resource type at a time and test in a lower environment first.
8. Prefer Resource References Over Hard-Coded IDs
When one resource depends on another, reference it symbolically rather than hard-coding its resource ID. Symbolic references create implicit dependencies, let the Bicep compiler optimize the deployment graph, and make the template self-consistent.
// Good – symbolic reference creates implicit dependency
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: storageAccountName
location: location
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
properties: {}
}
resource appService 'Microsoft.Web/sites@2023-01-01' = {
name: appServiceName
location: location
properties: {
serverFarmId: appServicePlan.id // symbolic reference
siteConfig: {
appSettings: [
{
name: 'StorageAccountName'
value: storageAccount.name // symbolic reference
}
]
}
}
}
// Bad – hard-coded resource ID
resource appService 'Microsoft.Web/sites@2023-01-01' = {
properties: {
serverFarmId: '/subscriptions/abc123/resourceGroups/rg-prod/providers/Microsoft.Web/serverfarms/asp-myapp'
}
}
9. Use existing for Resources Deployed Outside the Template
When a resource already exists in Azure and the template needs to reference it (without managing it), use the existing keyword instead of hard-coding its properties.
// Reference an existing Key Vault in a different resource group
resource keyVault 'Microsoft.KeyVault/vaults@2023-02-01' existing = {
name: keyVaultName
scope: resourceGroup(keyVaultResourceGroupName)
}
// Use the existing resource's properties
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: storageAccountName
properties: {
// reference the existing Key Vault's URI
encryption: {
keySource: 'Microsoft.Keyvault'
keyvaultproperties: {
keyVaultUri: keyVault.properties.vaultUri
keyName: encryptionKeyName
}
}
}
}
10. Secure Outputs — Never Output Secrets
Outputs are stored in deployment history and visible to anyone with read access to the resource group. Never output secrets, passwords, connection strings with credentials, or storage account keys.
// Bad – outputs a secret value
output storageConnectionString string = 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value}'
// Good – output the resource ID and name; let the application retrieve the key at runtime
output storageAccountId string = storageAccount.id
output storageAccountName string = storageAccount.name
// Good – output a Key Vault secret URI reference, not the secret value
output dbPasswordSecretUri string = '${keyVault.properties.vaultUri}secrets/dbPassword'
If an output must contain sensitive data (for passing between templates in the same pipeline), mark it with @secure():
@secure()
output adminPassword string = someSecureValue
11. Use the @secure() Decorator for Sensitive Parameters
Mark any parameter that receives a password, secret, or key with @secure(). This prevents the value from appearing in logs, deployment history, and outputs.
@secure()
@description('Administrator password for the SQL Server.')
param sqlAdminPassword string
@secure()
@description('API key for the external service.')
param externalApiKey string
12. Use targetScope Intentionally
Set the deployment scope explicitly at the top of every template. If deploying to a resource group (the most common case), the default applies, but making it explicit removes ambiguity.
// Resource group deployment (most common)
targetScope = 'resourceGroup'
// Subscription-level deployment (for policy, RBAC, resource groups)
targetScope = 'subscription'
// Management group deployment (for policy across subscriptions)
targetScope = 'managementGroup'
// Tenant-level deployment (rare)
targetScope = 'tenant'
13. Always Run Validation and What-If Before Deploying
Never deploy a Bicep template directly to production without first running validation and what-if. These two steps catch errors before they cause real changes.
# Step 1 – Validate the template (syntax and parameter checks)
az deployment group validate \
--resource-group rg-myapp-prod \
--template-file main.bicep \
--parameters prod.bicepparam
# Step 2 – Preview changes (what-if shows what will be created, modified, or deleted)
az deployment group create \
--resource-group rg-myapp-prod \
--template-file main.bicep \
--parameters prod.bicepparam \
--what-if \
--what-if-result-format FullResourcePayloads
# Step 3 – Deploy only after reviewing what-if output
az deployment group create \
--resource-group rg-myapp-prod \
--template-file main.bicep \
--parameters prod.bicepparam \
--name "deploy-$(date +%Y%m%d-%H%M%S)"
14. Name Every Deployment
Use the --name flag when running deployments. Named deployments are easy to find in Azure deployment history and can be correlated with Git commits and pipeline run numbers.
# Include the date and pipeline run number
az deployment group create \
--resource-group rg-myapp-prod \
--template-file main.bicep \
--parameters prod.bicepparam \
--name "deploy-20250429-run-142"
15. Use dependsOn Only When Necessary
Bicep infers dependencies automatically from symbolic resource references. Explicit dependsOn should only be used when a dependency exists that cannot be expressed through a property reference — for example, a role assignment that must exist before a resource tries to access a Key Vault.
// Explicit dependsOn needed – role assignment has no property linking it to the storage account deployment
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(storageAccount.id, appIdentityPrincipalId, storageBlobDataReaderRoleId)
scope: storageAccount
properties: {
roleDefinitionId: storageBlobDataReaderRoleId
principalId: appIdentityPrincipalId
}
}
resource appService 'Microsoft.Web/sites@2023-01-01' = {
name: appServiceName
dependsOn: [roleAssignment] // App Service must wait for RBAC assignment
properties: { ... }
}
16. Lint and Format Templates
The Bicep linter catches common mistakes, anti-patterns, and style violations. Run it as part of every CI/CD pipeline and during local development.
# Run the linter manually
az bicep lint --file main.bicep
# Build (compile) to catch errors
az bicep build --file main.bicep
The Bicep extension for VS Code runs the linter in real time and underlines problems as you type. Configure the linter rules in bicepconfig.json at the project root:
// bicepconfig.json
{
"analyzers": {
"core": {
"enabled": true,
"rules": {
"no-unused-params": { "level": "warning" },
"no-unused-vars": { "level": "warning" },
"prefer-interpolation": { "level": "warning" },
"secure-parameter-default": { "level": "error" },
"no-hardcoded-env-urls": { "level": "warning" },
"explicit-values-for-loc-params": { "level": "warning" }
}
}
}
}
17. Version Control Everything
Every Bicep file, parameter file, pipeline YAML, and bicepconfig.json should live in a Git repository. Infrastructure changes should follow the same pull request, review, and merge process as application code.
| Item | In Git? | Notes |
|---|---|---|
| *.bicep files | Yes | All templates and modules |
| *.bicepparam files | Yes | All environment parameter files |
| bicepconfig.json | Yes | Linter configuration |
| Pipeline YAML | Yes | CI/CD workflow definitions |
| Secrets / passwords | Never | Use Key Vault references instead |
| *.bicep compiled ARM JSON | Optional | Usually excluded via .gitignore |
Summary
| Practice | Why It Matters |
|---|---|
| Use a consistent project structure | Easy navigation, onboarding, and maintenance |
| Follow naming conventions | Templates become self-documenting and predictable |
| Add @description to all parameters | Documentation in the Portal and IDE tooltips |
| Constrain parameters with decorators | Catches invalid values before deployment starts |
| Never pass secrets as plain parameters | Prevents exposure in deployment history |
| Use variables to eliminate repetition | Single point of change, no inconsistency |
| Apply consistent tags to all resources | Cost management, governance, and filtering |
| Break templates into focused modules | Reusability and single responsibility |
| Pin API versions explicitly | Prevents unexpected behavior from API changes |
| Use symbolic references over hard-coded IDs | Implicit dependencies and portability |
Use existing for pre-existing resources | Clean reference without managing the resource |
| Never output secrets | Deployment history is readable by many |
| Mark sensitive parameters with @secure() | Keeps secrets out of logs and history |
| Run validate and what-if before deploying | Catches errors and previews changes safely |
| Name every deployment | Traceable deployment history in Azure |
| Lint templates in CI/CD and locally | Catches anti-patterns early |
| Version control all Bicep and param files | Auditability, collaboration, and rollback |
