Problem Context
You need to deploy the same Azure infrastructure across three environments — dev, staging, and production. Doing it through the portal means three sets of clicking, three chances for a misconfigured SKU, and no audit trail. Scripting it with the CLI works once but becomes a maintenance nightmare as your topology grows. What you need is declarative, repeatable, version-controlled infrastructure.
ARM templates are Azure's native answer. They describe the desired state of your Azure resources in JSON. Azure Resource Manager compares that desired state to what currently exists and only makes the necessary changes — idempotent by design.
- You deployed to dev by hand and now production is missing two security settings
- Your “infrastructure script” is a 400-line bash file that no one understands
- You need to onboard a new Azure region and dread the manual work
- Audit asks for a history of infrastructure changes — you have nothing
ARM templates give you a single source of truth that Azure enforces on every deployment.
Core Structure
Every ARM template is a JSON document with a fixed schema:
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"storageAccountName": {
"type": "string",
"minLength": 3,
"maxLength": 24,
"metadata": { "description": "Storage account name — globally unique" }
},
"sku": {
"type": "string",
"defaultValue": "Standard_LRS",
"allowedValues": ["Standard_LRS", "Standard_GRS", "Premium_LRS"]
}
},
"variables": {
"location": "[resourceGroup().location]"
},
"resources": [
{
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2023-01-01",
"name": "[parameters('storageAccountName')]",
"location": "[variables('location')]",
"sku": { "name": "[parameters('sku')]" },
"kind": "StorageV2",
"properties": {
"minimumTlsVersion": "TLS1_2",
"allowBlobPublicAccess": false,
"supportsHttpsTrafficOnly": true
}
}
],
"outputs": {
"storageEndpoint": {
"type": "string",
"value": "[reference(parameters('storageAccountName')).primaryEndpoints.blob]"
}
}
}Key Sections Explained
Parameters
Parameters are the inputs to your template. They support types (string, int, bool, object, array, secureString), validation constraints (minLength, maxLength, allowedValues), and defaults. Use secureString for secrets — Azure redacts these from deployment logs.
Variables
Variables are computed once and reused throughout the template. They reduce repetition and let you centralise logic. The resourceGroup().location function is a classic variable use case — inherit location from the resource group rather than hard-code it.
Resources
The core array. Each resource needs type (e.g., Microsoft.Storage/storageAccounts), apiVersion, name, and location. Use learn.microsoft.com/azure/templates to look up exact schema for each resource type and API version. Pin the API version — never use latest.
Outputs
Outputs expose values from the deployment — resource IDs, endpoints, connection strings (avoid outputting secrets; use Key Vault instead). Outputs are essential when chaining templates: an outer template passes outputs from one child template as parameters to another.
DependsOn and Implicit Dependencies
ARM templates resolve the deployment graph automatically when you reference a resource by name using template functions. If you set a subnet id using [resourceId('Microsoft.Network/virtualNetworks', ...)], ARM infers the dependency. Explicit dependsOnis only needed when there's no reference — e.g., a role assignment that depends on a resource being deployed.
{
"type": "Microsoft.Authorization/roleAssignments",
"apiVersion": "2022-04-01",
"name": "[guid(resourceGroup().id, 'StorageBlobDataContributor')]",
"dependsOn": ["[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]"],
"properties": {
"roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-...')]",
"principalId": "[parameters('principalId')]"
}
}Template Specs and Linked Templates
For large deployments, split your template into reusable modules using Template Specs (the recommended modern approach) or Linked Templates (classic approach via accessible URL).
Template Specs are versioned ARM templates stored as Azure resources. You deploy them by referencing their resource ID:
{
"type": "Microsoft.Resources/deployments",
"apiVersion": "2022-09-01",
"name": "networkModule",
"properties": {
"mode": "Incremental",
"templateLink": {
"id": "/subscriptions/.../resourceGroups/rg-templates/providers/Microsoft.Resources/templateSpecs/network-template/versions/1.0"
},
"parameters": { ... }
}
}Incremental vs Complete Mode
ARM templates support two deployment modes. Incremental (default) adds or updates resources described in the template but leaves existing resources untouched. Complete mode deletes resources in the resource group that are not in the template — dangerous, but useful for drift correction. Always test with --what-if before running complete-mode deployments.
# What-if before any deployment
az deployment group what-if \
--resource-group rg-production \
--template-file main.json \
--parameters @prod.parameters.json
# Deploy in incremental mode (safe default)
az deployment group create \
--resource-group rg-production \
--template-file main.json \
--parameters @prod.parameters.jsonARM vs Bicep — When to Use Each
Bicep compiles to ARM JSON — every Bicep template has an equivalent ARM template. In practice:
- New projects: Use Bicep. Cleaner syntax, type checking, native VS Code tooling, automatic dependency inference.
- Existing ARM investments: Keep ARM if it's working; migrate to Bicep incrementally using
az bicep decompile. - Low-level operations: ARM is still the foundation. Understanding ARM helps debug Bicep compilation output and interpret deployment errors.
- Cross-tool export: Azure portal's “Export template” produces ARM JSON — decompile it to Bicep as a starting point.
Production Checklist
- Pin API versions — never rely on defaults
- Use
secureStringfor passwords and connection strings - Always run
--what-ifbefore deployments affecting production - Store parameter files per environment; never hard-code environment names in the template
- Enable diagnostic settings and resource locks in the template — not as a post-deployment step
- Version your templates in Git; tag releases that match deployed infrastructure
- Use Template Specs for shared modules instead of copy-pasting JSON across repos

