Theoretical Foundation: Interpret an Azure Resource Manager template or a Bicep file
1. Initial Intuitionβ
Imagine you need to set up exactly the same office in ten different cities: same furniture, same layout, same equipment. You could go city by city setting everything up manually. Or you could create a detailed blueprint that describes exactly how the office should be, and send that blueprint to all ten cities at once.
An ARM Template or a Bicep file is that office blueprint. It's a document that declaratively describes which Azure resources should exist, with which configurations, in what order, and with which dependencies. Instead of clicking through the portal or running imperative commands one by one, you declare the desired state of the infrastructure and Azure takes care of creating, modifying, or deleting resources to achieve that state.
The central difference between imperative and declarative thinking is: imperative says "do this, then do that, then do that other thing". Declarative says "the final result should be this". Azure figures out how to get there.
2. Contextβ
ARM Templates and Bicep are Azure's native mechanisms for Infrastructure as Code (IaC). They operate directly on the Azure Resource Manager (ARM) layer, which is the orchestration layer that processes all operations in Azure, whether through portal, CLI, PowerShell, or API.
Bicep is a domain-specific language (DSL) that compiles to ARM Template JSON. All Bicep functionality is available in ARM, but Bicep has much more readable and concise syntax. The AZ-104 requires you to be able to read and interpret both.
3. Concept Constructionβ
3.1 ARM Template Structureβ
An ARM Template is a JSON file with a well-defined structure. Each section has a specific purpose:
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {},
"variables": {},
"functions": [],
"resources": [],
"outputs": {}
}
| Section | Required | Purpose |
|---|---|---|
$schema | Yes | URL that defines the template schema version |
contentVersion | Yes | Template version defined by the author (free string) |
parameters | No | Input values provided at deployment time |
variables | No | Values calculated internally, reused in the template |
functions | No | Custom template functions (rarely used) |
resources | Yes | The Azure resources that will be created or modified |
outputs | No | Values returned after deployment (e.g., VM IP, app URL) |
3.2 The Parameters Sectionβ
Parameters make the template reusable. Instead of hardcoding "brazilsouth" as location, you define a location parameter and pass the value at deployment time.
"parameters": {
"location": {
"type": "string",
"defaultValue": "brazilsouth",
"allowedValues": [
"brazilsouth",
"eastus",
"westeurope"
],
"metadata": {
"description": "Azure region for resource deployment"
}
},
"storageAccountName": {
"type": "string",
"minLength": 3,
"maxLength": 24,
"metadata": {
"description": "Storage account name (must be globally unique)"
}
},
"adminPassword": {
"type": "securestring",
"metadata": {
"description": "Administrator password - secure value, does not appear in logs"
}
}
}
Available parameter types: string, securestring, int, bool, object, secureObject, array.
The securestring and secureObject types are used for sensitive values: the value is not logged or shown in deployment history.
3.3 The Variables Sectionβ
Variables are values calculated once and reused. They help avoid repetition and make the template more readable.
"variables": {
"storageAccountSku": "Standard_LRS",
"vnetAddressPrefix": "10.0.0.0/16",
"subnetFrontendPrefix": "10.0.1.0/24",
"uniqueSuffix": "[uniqueString(resourceGroup().id)]",
"storageNameWithSuffix": "[concat(parameters('storageAccountName'), variables('uniqueSuffix'))]"
}
The [...] (brackets) notation indicates an ARM expression: a value calculated at runtime. The difference between parameters and variables is that parameters receive values from outside (at deployment); variables are calculated internally.
3.4 ARM Functionsβ
ARM Template has a rich set of functions that can be used in expressions:
| Category | Main Functions | Example |
|---|---|---|
| String | concat, toLower, toUpper, substring, length, trim | concat('storage', uniqueString(resourceGroup().id)) |
| Resource | resourceGroup, subscription, resourceId, reference | resourceGroup().location |
| Array | length, first, last, concat, union | length(parameters('allowedIPs')) |
| Math | add, sub, mul, div, mod | add(parameters('instanceCount'), 1) |
| Logical | if, and, or, not, bool | if(equals(parameters('env'), 'prod'), 'Premium', 'Standard') |
| Deployment | deployment, environment | deployment().name |
| Unique | uniqueString, newGuid | uniqueString(resourceGroup().id) |
The uniqueString() function is very important: it generates a deterministic 13-character hash from the input value. If you pass resourceGroup().id, the result is always the same for the same resource group, making it useful for generating unique names per resource group.
The reference() function allows reading properties of an already created resource, including resources created in the same template. For example, reference(resourceId('Microsoft.Network/publicIPAddresses', 'my-ip')).ipAddress returns the IP assigned to the public IP after its creation.
3.5 The Resources Section: The Heart of the Templateβ
Each resource has a required structure:
"resources": [
{
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2023-01-01",
"name": "[variables('storageNameWithSuffix')]",
"location": "[parameters('location')]",
"sku": {
"name": "Standard_LRS"
},
"kind": "StorageV2",
"properties": {
"accessTier": "Hot",
"minimumTlsVersion": "TLS1_2",
"allowSharedKeyAccess": false
}
}
]
| Field | Required | Purpose |
|---|---|---|
type | Yes | The Azure resource type (Microsoft.Provider/resourceType) |
apiVersion | Yes | The resource provider API version |
name | Yes | The resource name |
location | Yes (for most) | The region where the resource is created |
sku | Varies | The service tier |
kind | Varies | The resource subtype |
properties | Varies | Resource-specific configurations |
dependsOn | No | Explicit dependencies on other resources |
tags | No | Organization metadata |
3.6 DependsOn: Order Controlβ
ARM processes resources in parallel by default. To ensure one resource is created before another, use dependsOn:
{
"type": "Microsoft.Compute/virtualMachines",
"name": "my-vm",
"dependsOn": [
"[resourceId('Microsoft.Network/networkInterfaces', 'nic-my-vm')]",
"[resourceId('Microsoft.Compute/availabilitySets', 'as-web')]"
],
...
}
Important behavior: when you use the reference() or resourceId() function to reference another resource in the template, ARM automatically infers the dependency. Explicit dependsOn is only necessary when the dependency cannot be inferred via direct reference.
3.7 The Outputs Sectionβ
Outputs return information about created resources for later use (in pipelines, scripts, nested modules):
"outputs": {
"storageAccountEndpoint": {
"type": "string",
"value": "[reference(variables('storageNameWithSuffix')).primaryEndpoints.blob]"
},
"vmPublicIpAddress": {
"type": "string",
"value": "[reference(resourceId('Microsoft.Network/publicIPAddresses', 'pip-my-vm')).ipAddress]"
}
}
3.8 Bicep File Structureβ
Bicep has more concise and readable syntax. See the comparison:
ARM Template (JSON):
{
"$schema": "...",
"contentVersion": "1.0.0.0",
"parameters": {
"location": {
"type": "string",
"defaultValue": "brazilsouth"
}
},
"resources": [
{
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2023-01-01",
"name": "myaccount",
"location": "[parameters('location')]",
"sku": { "name": "Standard_LRS" },
"kind": "StorageV2",
"properties": {
"accessTier": "Hot"
}
}
]
}
Bicep (equivalent):
param location string = 'brazilsouth'
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: 'myaccount'
location: location
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
properties: {
accessTier: 'Hot'
}
}
The verbosity reduction is considerable. Bicep eliminates expression brackets, unnecessary quotes, and nested JSON structure.
3.9 Main Bicep Elementsβ
Parameters:
@description('Region for deployment')
@allowed(['brazilsouth', 'eastus'])
param location string = 'brazilsouth'
@description('Secure password')
@secure()
param adminPassword string
@minLength(3)
@maxLength(24)
param storageAccountName string
Variables:
var uniqueSuffix = uniqueString(resourceGroup().id)
var fullStorageName = '${storageAccountName}${uniqueSuffix}'
var storageSkuName = environment == 'prod' ? 'Standard_GRS' : 'Standard_LRS'
Outputs:
output blobEndpoint string = storageAccount.properties.primaryEndpoints.blob
output storageAccountId string = storageAccount.id
Reference to other resources (implicit dependency):
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: fullStorageName
location: location
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
properties: {}
}
resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-01-01' = {
parent: storageAccount // implicit dependency via 'parent'
name: 'default'
properties: {
deleteRetentionPolicy: {
enabled: true
days: 7
}
}
}
Modules (equivalent to nested templates in ARM):
module vnetModule './modules/vnet.bicep' = {
name: 'vnet-deploy'
params: {
vnetName: 'vnet-production'
addressPrefix: '10.0.0.0/16'
location: location
}
}
// Access module output
output vnetId string = vnetModule.outputs.vnetId
4. Structural Viewβ
5. How It Works in Practiceβ
Deployment Mode: Incremental vs. Completeβ
This is one of the most critical behaviors to understand and one that causes the most unexpected impacts:
- Incremental (default): the template defines resources that should exist. Existing resources in the resource group but absent from the template are preserved.
- Complete: the template defines the complete desired state. Existing resources in the resource group but absent from the template are deleted.
Complete mode is powerful for keeping the resource group exactly as defined in the template, but highly destructive if used carelessly.
What-If: Preview Changesβ
The --what-if command (and --confirm-with-what-if) lets you see exactly what changes will be made before running the deployment:
az deployment group what-if \
--resource-group rg-production \
--template-file main.bicep \
--parameters @parameters.json
The result shows each resource with its predicted status: + Create, ~ Modify, - Delete, = Nochange, x Ignore.
Deployment Scopesβ
Templates can be deployed at different scopes:
| Scope | CLI Command | What it can create |
|---|---|---|
| Resource Group | az deployment group create | Resources within the specified RG |
| Subscription | az deployment sub create | Resource Groups, subscription-level resources (policies, RBAC) |
| Management Group | az deployment mg create | MG-level policies, RBAC |
| Tenant | az deployment tenant create | Tenant configurations, Management Groups |
The targetScope in Bicep defines the file scope:
targetScope = 'subscription' // or 'resourceGroup' (default), 'managementGroup', 'tenant'
6. Implementation Methodsβ
6.1 Azure Portal: Template Specs and Deploymentβ
Direct template deployment in portal:
Create a resource > Deploy a custom template
Create Template Spec (versioned reusable template library in Azure):
az ts create \
--name template-standard-vnet \
--resource-group rg-templates \
--version "1.0" \
--template-file vnet.bicep \
--display-name "Corporate Standard VNet"
6.2 Azure CLIβ
ARM Template deployment:
az deployment group create \
--resource-group rg-production \
--template-file main.json \
--parameters @parameters.prod.json \
--parameters adminPassword="MySecurePassword123" \
--mode Incremental \
--name "deploy-$(date +%Y%m%d%H%M%S)"
Bicep deployment (CLI compiles automatically):
az deployment group create \
--resource-group rg-production \
--template-file main.bicep \
--parameters location=brazilsouth \
storageAccountName=mystorage \
--mode Incremental
Compile Bicep to ARM Template manually:
# Compile Bicep to ARM JSON
az bicep build --file main.bicep
# Decompile ARM JSON to Bicep (best effort, may need adjustments)
az bicep decompile --file main.json
Validate syntax without deployment:
# Validate template before deployment
az deployment group validate \
--resource-group rg-production \
--template-file main.bicep \
--parameters @parameters.json
Monitor deployment history:
# List resource group deployments
az deployment group list \
--resource-group rg-production \
--output table
# View specific deployment details and results
az deployment group show \
--resource-group rg-production \
--name "deploy-20240315120000"
# View deployment outputs
az deployment group show \
--resource-group rg-production \
--name "deploy-20240315120000" \
--query properties.outputs
6.3 PowerShellβ
# Bicep Deploy
New-AzResourceGroupDeployment `
-ResourceGroupName "rg-producao" `
-TemplateFile "./main.bicep" `
-TemplateParameterFile "./parameters.prod.json" `
-Mode Incremental `
-Name "deploy-$(Get-Date -Format 'yyyyMMddHHmmss')" `
-Verbose
# What-If
Get-AzResourceGroupDeploymentWhatIfResult `
-ResourceGroupName "rg-producao" `
-TemplateFile "./main.bicep" `
-TemplateParameterFile "./parameters.prod.json"
7. Control and Securityβ
Secret Management in Templatesβ
Templates should not contain hardcoded secrets. The correct practice is to use securestring parameters and provide values via:
- Parameter file not versioned (
.gitignoreforparameters.prod.jsonfiles with secrets) - Key Vault reference in parameter file:
// parameters.prod.json
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"adminPassword": {
"reference": {
"keyVault": {
"id": "/subscriptions/<sub-id>/resourceGroups/rg-secrets/providers/Microsoft.KeyVault/vaults/meu-keyvault"
},
"secretName": "vm-admin-password"
}
}
}
}
With this approach, the password value never appears in the parameter file or in the deployment history.
Locks and Resource Protection in Templatesβ
Templates can create resource locks like any other resource:
resource lock 'Microsoft.Authorization/locks@2020-05-01' = {
name: 'lock-storage-producao'
scope: storageAccount
properties: {
level: 'CanNotDelete'
notes: 'Production storage protected against accidental deletion'
}
}
8. Decision Makingβ
ARM Template vs. Bicep: which to use?β
| Situation | Choice | Reason |
|---|---|---|
| New project, new infrastructure | Bicep | Cleaner syntax, better DX, modern tooling |
| Existing template in ARM JSON | Keep ARM or decompile | Decompile as starting point, review before using |
| Integration with system that only accepts JSON | ARM Template | Bicep compiles to ARM, use generated JSON |
| Modules and composition | Bicep | Bicep modules are much simpler than ARM linked templates |
| Team with no experience in either | Bicep | Lower learning curve |
Incremental vs. Complete mode: when to use?β
| Situation | Mode | Caution |
|---|---|---|
| Deploy new resources | Incremental | Safe, preserves what exists |
| Update existing resources | Incremental | Safe |
| Ensure RG is exactly as template | Complete | Dangerous: deletes unlisted resources |
| Cleanup legacy resources | Complete with review | Run --what-if first to see what will be deleted |
9. Best Practicesβ
Version templates alongside application code: infrastructure templates should be in the same Git repository as the application code they support, with the same branching, code review, and CI/CD practices.
Use parameters for everything that changes between environments: location, SKUs, names, instance counts. Never hardcode values that differ between dev, staging, and production.
Prefer uniqueString() for generating unique names instead of timestamps: uniqueString(resourceGroup().id) is deterministic for the same resource group, generating the same result in repeated deployments. A timestamp would generate a different name for each deployment, potentially creating duplicate resources.
Use --what-if before any production deployment: especially before using Complete mode or before deployments that modify critical resources. Review each expected change before confirming.
Document parameters and outputs: use the description property (in ARM) and the @description() decorator (in Bicep) to document the purpose of each parameter and output. This makes it easier for other team members to use the template.
10. Common Errorsβ
Using Complete mode without checking what will be deleted
A template with 5 resources is deployed with --mode Complete in a resource group that has 8 resources (the 5 from the template plus 3 created manually later). The 3 additional resources are deleted silently. Always use --what-if before any Complete deployment.
Hardcoding apiVersion and not updating
An outdated apiVersion may not support the latest properties of a resource, or may be close to being discontinued. Regularly check the resource provider documentation to use supported and current versions.
Confusing variables with parameters
Parameters receive external values at deployment; variables are calculated internally. A common mistake is putting values that should be parameters in variables (losing the ability to customize), or putting values that are always the same in parameters (adding unnecessary complexity).
Expecting that dependsOn is always necessary
ARM automatically infers dependencies when you use reference() or resourceId() referencing another template resource. Adding explicit dependsOn on top of implicit references is redundant and increases verbosity without benefit. Use dependsOn only when the dependency exists but cannot be expressed via direct reference.
Using securestring but logging the value in the application
The securestring type protects the value in ARM, but if the application logs the environment variable containing the secret, the value is exposed in the logs. The securestring protection is limited to the ARM control plane.
11. Operations and Maintenanceβ
Deployment Historyβ
Each deployment creates an entry in the resource group history, visible for 90 days. The deployment name is important for traceability:
# List last 10 deployments with status
az deployment group list \
--resource-group rg-producao \
--query "[0:10].{Nome:name, Estado:properties.provisioningState, Horario:properties.timestamp}" \
--output table
Important limit: deployment history has a limit of 800 entries per resource group. After reaching this limit, new deployments fail with history limit error. In environments with many deployments (frequent CI/CD), configure automatic history cleanup or use deployment names that replace old entries (fixed name like deploy-main that gets overwritten).
Deployment Rollbackβ
ARM does not have automatic rollback. To revert to a previous state, you need to:
- Have the template and parameters of the previous version in version control
- Make a new deployment with the previous version
# Rollback: redeploy previous version
git checkout <previous-commit>
az deployment group create \
--resource-group rg-producao \
--template-file main.bicep \
--parameters @parameters.prod.json
12. Integration and Automationβ
CI/CD Pipeline with GitHub Actionsβ
# .github/workflows/infrastructure.yml
name: Deploy Infrastructure
on:
push:
branches: [main]
paths: ['infrastructure/**']
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Azure Login
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: What-If
run: |
az deployment group what-if \
--resource-group rg-producao \
--template-file infrastructure/main.bicep \
--parameters @infrastructure/parameters.prod.json
- name: Deploy
run: |
az deployment group create \
--resource-group rg-producao \
--template-file infrastructure/main.bicep \
--parameters @infrastructure/parameters.prod.json \
--name "deploy-${{ github.run_id }}"
Template Specs: Centralized Libraryβ
Template Specs allow storing versioned templates in Azure and reusing them:
// Reference a Template Spec in a Bicep
module vnet 'ts:/subscriptions/<sub-id>/resourceGroups/rg-templates/providers/Microsoft.Resources/templateSpecs/vnet-padrao/versions/1.0' = {
name: 'vnet-deploy'
params: {
vnetName: 'vnet-producao'
location: location
}
}
13. Final Summaryβ
Essential points:
- ARM Templates (JSON) and Bicep are forms of declarative Infrastructure as Code in Azure. You define the desired state; ARM decides how to get there.
- Bicep compiles to ARM Template JSON. All Bicep is supported in ARM, but Bicep has more concise and modern syntax.
- The template's
resourcessection defines what will be created.parametersreceive external values.variablescalculate internal values.outputsreturn results. - ARM automatically infers dependencies via
reference()andresourceId(). ExplicitdependsOnis only necessary when the dependency cannot be inferred.
Critical differences:
- Incremental mode (default): template resources are created/updated; existing resources absent from template are preserved.
- Complete mode: template resources are created/updated; existing resources absent from template are deleted.
securestringvs.string:securestringdoes not appear in ARM logs;stringdoes. Usesecurestringfor any sensitive value.variablesvs.parameters: variables are calculated internally and cannot be changed at deployment; parameters receive external values and customize the template.uniqueString(resourceGroup().id)is deterministic (same result for the same RG in repeated deployments). A timestamp would be unique but different for each deployment.
What needs to be remembered:
- ARM expression uses brackets
[...]to denote calculated values. Everything outside brackets is a string literal. --what-ifis mandatory before any production deployment, especially with Complete mode.- Deployment history has a limit of 800 entries per resource group. In frequent pipelines, use fixed deployment names or configure cleanup.
- For secrets in parameters, use Key Vault reference in the parameter file instead of putting the value directly.
- Bicep uses
parent:to express resource hierarchy (e.g., blob service within a storage account), which automatically creates the dependency and correct hierarchical name. targetScopein Bicep defines whether the file deploys to resource group (default), subscription, management group, or tenant.