ARM Templates & Deployment Patterns

📖 15 min read

What Are ARM Templates

Azure Resource Manager (ARM) templates are JSON files that define your entire Azure infrastructure. You submit a template to Azure, and Resource Manager parses it, validates it, and deploys all resources in the correct order.

ARM templates are the foundational deployment format for Azure. Every deployment to Azure goes through the ARM deployment plane. When you use Bicep (Azure’s modern IaC language), it compiles down to ARM JSON at deployment time. When you use the Azure Portal’s graphical interface, it generates ARM templates behind the scenes.

Why ARM Templates Still Matter

Modern Azure development increasingly uses Bicep, but ARM templates remain critical knowledge because:

Legacy codebases: Organizations have years of investments in ARM templates. Understanding ARM is essential when maintaining or migrating these templates.

Bicep compilation: When you deploy a Bicep template, Azure compiles it to ARM JSON. Understanding the compiled output helps debug issues and understand what actually gets deployed.

Generated outputs: Azure tools and services generate ARM templates automatically. Portal-based configuration exports generate ARM JSON. Understanding the structure helps you work with these generated files.

Integration and tooling: Many Azure services output ARM templates as part of their export/backup functionality. CI/CD pipelines often work directly with ARM templates.

Capability reference: The ARM reference documentation is the authoritative definition of every Azure resource and property. When Bicep hides complexity, the ARM docs show what’s actually possible.


ARM Template Structure

Every ARM template is a JSON file with this basic structure:

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": { },
  "variables": { },
  "functions": [ ],
  "resources": [ ],
  "outputs": { }
}

Schema and Content Version

The $schema URI identifies which version of the ARM template language you’re using. Different schemas support different features. The most common modern schema is 2019-04-01 or newer.

The contentVersion is a string you control. It helps you track template versions and has no impact on deployment; it’s purely for your own organization (like a semantic version number for the template itself).

Parameters

Parameters allow template users to provide values at deployment time instead of hardcoding them. Parameters appear in the Azure Portal UI, CLI prompts, and automation scripts.

Example parameter:

"parameters": {
  "location": {
    "type": "string",
    "defaultValue": "eastus",
    "metadata": {
      "description": "Azure region for resources"
    },
    "allowedValues": [
      "eastus",
      "westus",
      "northeurope"
    ]
  },
  "environmentName": {
    "type": "string",
    "minLength": 1,
    "maxLength": 10
  },
  "vmCount": {
    "type": "int",
    "defaultValue": 2,
    "minValue": 1,
    "maxValue": 10
  }
}

Parameter types:

  • String, int, bool
  • array, object
  • secureString (hidden in logs/history)
  • secureObject (for sensitive objects like secrets)

Variables

Variables are computed values calculated once at deployment time. Use variables to avoid repeating complex expressions throughout your template.

Example variables:

"variables": {
  "resourcePrefix": "[concat(parameters('environmentName'), '-rg')]",
  "vnetName": "[concat(variables('resourcePrefix'), '-vnet')]",
  "subnetName": "[concat(variables('resourcePrefix'), '-subnet')]",
  "uniqueId": "[uniqueString(resourceGroup().id)]",
  "storageAccountName": "[concat('storage', variables('uniqueId'))]"
}

Variables can reference parameters, other variables, resource properties, and ARM functions. They cannot reference outputs.

Resources

The resources array contains all the Azure resources to be deployed. Each resource has a type (like Microsoft.Compute/virtualMachines), properties, and optionally dependencies.

Basic resource structure:

"resources": [
  {
    "type": "Microsoft.Network/virtualNetworks",
    "apiVersion": "2021-02-01",
    "name": "[variables('vnetName')]",
    "location": "[parameters('location')]",
    "properties": {
      "addressSpace": {
        "addressPrefixes": ["10.0.0.0/16"]
      },
      "subnets": [
        {
          "name": "default",
          "properties": {
            "addressPrefix": "10.0.1.0/24"
          }
        }
      ]
    }
  }
]

Resource references and dependencies:

{
  "type": "Microsoft.Compute/virtualMachines",
  "name": "myVM",
  "dependsOn": [
    "[resourceId('Microsoft.Network/networkInterfaces', 'myNIC')]"
  ],
  "properties": {
    "networkProfile": {
      "networkInterfaces": [
        {
          "id": "[resourceId('Microsoft.Network/networkInterfaces', 'myNIC')]"
        }
      ]
    }
  }
}

Use dependsOn to explicitly declare dependencies when ARM cannot infer them. Use resourceId() to reference other resources in the template.

Outputs

Outputs are values computed at deployment time and returned to the user. Outputs can be used by other templates, scripts, or displayed to the person running the deployment.

Example outputs:

"outputs": {
  "vnetId": {
    "type": "string",
    "value": "[resourceId('Microsoft.Network/virtualNetworks', variables('vnetName'))]"
  },
  "vnetName": {
    "type": "string",
    "value": "[variables('vnetName')]"
  },
  "deploymentInfo": {
    "type": "object",
    "value": {
      "region": "[parameters('location')]",
      "environment": "[parameters('environmentName')]"
    }
  }
}

Template Functions and Expressions

ARM templates use a set of built-in functions and expression syntax to compute values dynamically.

Reference and ResourceId Functions

reference() retrieves properties of a resource after it’s deployed:

"properties": {
  "storageEndpoint": "[reference(resourceId('Microsoft.Storage/storageAccounts', 'mystorage')).primaryEndpoints.blob]"
}

resourceId() generates the fully qualified ID of a resource:

"[resourceId('Microsoft.Compute/virtualMachines', 'myVM')]"
"[resourceId(subscription().id, 'myResourceGroup', 'Microsoft.Network/networkInterfaces', 'myNIC')]"

String Functions

concat() joins strings together:

"[concat('prefix-', parameters('environmentName'), '-suffix')]"

format() uses string formatting:

"[format('https://{0}.blob.core.windows.net/', variables('storageAccountName'))]"

split() and join() work with delimited lists:

"[split('a,b,c', ',')]"  // Returns ["a", "b", "c"]
"[join(array('a', 'b', 'c'), ',')]"  // Returns "a,b,c"

toLower(), toUpper(), substring(), replace(), contains(): all standard string operations.

Numeric and Array Functions

length() returns array or string length:

"[length(parameters('nameList'))]"

min(), max() find extrema in arrays or lists of numbers:

"[max(10, 20, 30)]"  // Returns 30

range() creates an array of integers:

"[range(1, 5)]"  // Returns [1, 2, 3, 4, 5]

filter(), map() transform arrays:

"[filter(variables('items'), lambda('x', greater(x, 5)))]"

Conditional Functions

if() returns a value based on a condition:

"[if(equals(parameters('environment'), 'prod'), 'Premium', 'Standard')]"

equals(), not(), and(), or() for boolean logic:

"[and(equals(parameters('env'), 'prod'), not(empty(parameters('tags'))))]"

Unique Value Functions

uniqueString() generates a pseudo-random string based on input:

"[uniqueString(resourceGroup().id)]"

This is useful for creating globally unique names (like storage account names which must be globally unique across all Azure).

Pseudo-Parameters

resourceGroup() provides information about the target resource group:

"[resourceGroup().id]"
"[resourceGroup().name]"
"[resourceGroup().location]"

subscription() provides subscription information:

"[subscription().id]"
"[subscription().subscriptionId]"

deployment() provides deployment metadata:

"[deployment().name]"

Loops, Copies, and Conditions

ARM templates support several mechanisms for creating multiple resources or configuring them conditionally.

Copy Loops

copy creates multiple instances of a resource:

"resources": [
  {
    "type": "Microsoft.Storage/storageAccounts",
    "name": "[concat('storage', copyIndex())]",
    "apiVersion": "2019-06-01",
    "location": "[parameters('location')]",
    "sku": {
      "name": "Standard_LRS"
    },
    "kind": "StorageV2",
    "copy": {
      "name": "storagecopy",
      "count": "[parameters('storageCount')]"
    }
  }
]

The copyIndex() function returns the current iteration (0, 1, 2, …). You can also use copyIndex() for property-level copying to create multiple subnets within a single VNet resource.

Conditions

condition controls whether a resource is deployed:

"resources": [
  {
    "type": "Microsoft.Storage/storageAccounts",
    "condition": "[equals(parameters('environment'), 'prod')]",
    "name": "prodStorage",
    "apiVersion": "2019-06-01",
    "location": "[parameters('location')]",
    "sku": { "name": "Premium_LRS" },
    "kind": "StorageV2"
  }
]

Resources created with conditions can be referenced in outputs using the same condition, preventing errors when a conditionally deployed resource doesn’t exist.


Nested and Linked Templates

For complex deployments, ARM supports breaking templates into reusable pieces.

Linked Templates

Linked templates are separate templates deployed from a main template. The main template references external template URIs.

Main template:

{
  "resources": [
    {
      "type": "Microsoft.Resources/deployments",
      "apiVersion": "2021-04-01",
      "name": "networkDeployment",
      "properties": {
        "mode": "Incremental",
        "templateLink": {
          "uri": "https://mystorageaccount.blob.core.windows.net/templates/network.json",
          "contentVersion": "1.0.0.0"
        },
        "parameters": {
          "location": {
            "value": "[parameters('location')]"
          }
        }
      }
    }
  ]
}

How linked templates work:

  1. The main template references a URI pointing to the linked template (typically in Azure Blob Storage)
  2. Resource Manager downloads the linked template
  3. The linked template is deployed independently with its own resource group and scope
  4. Outputs from the linked template become available to the main template

Advantages:

  • Reusable across multiple templates
  • Can be versioned and stored separately
  • Clear separation of concerns
  • Allows team collaboration (different teams manage different templates)

Disadvantages:

  • Linked templates must be stored somewhere accessible (Blob Storage, GitHub, etc.)
  • Requires CORS or public access for the template URI
  • More complex debugging (template chains)
  • Additional API calls during deployment

Nested Templates

Nested templates are templates embedded directly inside a parent template (as a string). They deploy within the same context as the parent.

{
  "resources": [
    {
      "type": "Microsoft.Resources/deployments",
      "apiVersion": "2021-04-01",
      "name": "nestedDeployment",
      "properties": {
        "mode": "Incremental",
        "template": {
          "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
          "contentVersion": "1.0.0.0",
          "resources": [
            {
              "type": "Microsoft.Storage/storageAccounts",
              "name": "mystorageaccount",
              "apiVersion": "2019-06-01",
              "location": "[parameters('location')]",
              "sku": { "name": "Standard_LRS" },
              "kind": "StorageV2"
            }
          ]
        },
        "parameters": {
          "location": {
            "value": "[parameters('location')]"
          }
        }
      }
    }
  ]
}

Advantages:

  • Self-contained; no external file dependencies
  • Can pass complex expressions and outputs between parent and nested
  • Easier to debug (everything in one place)

Disadvantages:

  • Templates become large and hard to read
  • Not easily reusable across multiple parents
  • Difficult to version independently

When to Use Which

Use linked templates when:

  • You want reusable components shared across many deployments
  • Different teams manage different infrastructure domains
  • Templates are large and benefit from separation
  • You can reliably host template files in a URI-accessible location

Use nested templates when:

  • You want self-contained deployments with no external dependencies
  • Components are only used in this one parent template
  • You’re doing simple decomposition for readability

Deployment Modes

ARM deployments operate in two modes, each with different implications for existing resources.

Incremental Mode

Incremental deployment adds or updates resources specified in the template while leaving everything else untouched.

{
  "properties": {
    "mode": "Incremental"
  }
}

What happens:

  • Resources in the template are created or updated
  • Resources not in the template are left alone
  • If a resource exists and matches the template, it’s updated if the template specifies different properties
  • If a resource exists but isn’t in the template, it’s ignored

Safe for: Most deployments. Incremental mode is forgiving.

Risk: If your template is incomplete or you forget to include a resource, the old resource remains.

Complete Mode

Complete deployment replaces everything in the scope. Resources in the template are created or updated, and resources not in the template are deleted.

{
  "properties": {
    "mode": "Complete"
  }
}

What happens:

  • Resources in the template are created or updated
  • Resources in the resource group that are not in the template are deleted

Safe for: Resource groups created solely for this template, where the template describes the complete desired state.

Risk: If your template is incomplete or accidentally excludes a resource, that resource is deleted, causing data loss or downtime.

Deployment Scope

Deployments can target different scopes:

Resource Group (most common):

  • Deploys resources into a specific resource group
  • Scope: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}

Subscription:

  • Deploys resources at the subscription level (resource groups, management group assignments, policies)
  • Requires deploymentTemplate.json schema

Management Group:

  • Deploys policies, role assignments, and other management-level resources across subscriptions
  • Used for governance and compliance

Tenant:

  • Deploys tenant-level resources (management group definitions, provider registrations)

Parameter Files and Environment Management

ARM templates are separated from their input values using parameter files.

main.json (template):

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "location": { "type": "string" },
    "environmentName": { "type": "string" },
    "vmSize": { "type": "string" }
  },
  "resources": [
    {
      "type": "Microsoft.Compute/virtualMachines",
      "name": "[concat(parameters('environmentName'), '-vm')]",
      "location": "[parameters('location')]",
      "properties": {
        "hardwareProfile": {
          "vmSize": "[parameters('vmSize')]"
        }
      }
    }
  ]
}

parameters-dev.json:

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "location": { "value": "eastus" },
    "environmentName": { "value": "dev" },
    "vmSize": { "value": "Standard_B2s" }
  }
}

parameters-prod.json:

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "location": { "value": "eastus" },
    "environmentName": { "value": "prod" },
    "vmSize": { "value": "Standard_D2s_v3" }
  }
}

Deployment via CLI:

az deployment group create \
  --resource-group myRG \
  --template-file main.json \
  --parameters parameters-prod.json

This separation allows the same template to be deployed to multiple environments with different configuration values.


Template Validation and What-If Deployments

Before deploying, validate templates to catch errors early.

Validation

Validate checks template syntax and properties without actually deploying:

az deployment group validate \
  --resource-group myRG \
  --template-file main.json \
  --parameters parameters-prod.json

Validation confirms that the template is well-formed JSON, all referenced resources exist, and properties match their expected types. It does not test whether your infrastructure design will work.

What-If Deployment

What-If shows what changes would be made if you deployed:

az deployment group what-if \
  --resource-group myRG \
  --template-file main.json \
  --parameters parameters-prod.json

What-If shows:

  • Which resources will be created
  • Which resources will be deleted
  • Which resources will be modified (and what properties change)
  • Which resources will be unchanged

This is especially useful before running a Complete mode deployment to ensure you don’t accidentally delete resources.


ARM Template Limitations and Pain Points

ARM templates are powerful but have drawbacks that motivated the creation of Bicep.

Verbosity and Boilerplate

ARM templates are verbose. Simple deployments require lots of JSON. Bicep was designed to be more concise while compiling to ARM JSON.

ARM example (repetitive):

{
  "type": "Microsoft.Storage/storageAccounts",
  "apiVersion": "2019-06-01",
  "name": "[concat('storage', uniqueString(resourceGroup().id))]",
  "location": "[parameters('location')]",
  "properties": {
    "accountType": "Standard_LRS"
  },
  "kind": "StorageV2",
  "resources": [
    {
      "type": "blobServices/containers",
      "apiVersion": "2019-06-01",
      "name": "[concat('default/', 'mycontainer')]",
      "dependsOn": [
        "[concat('Microsoft.Storage/storageAccounts/', concat('storage', uniqueString(resourceGroup().id)))]"
      ]
    }
  ]
}

Limited Looping (Before Copy)

Older ARM versions lacked copy loops. Creating multiple resources required duplicating resource definitions or using complex workarounds.

No Modules Until Recently

ARM didn’t have a proper module system until linked templates were introduced. Bicep’s module support is simpler and more intuitive.

Lack of Type Validation

ARM properties are not validated against schemas at template time. You can misspell a property name and the deployment fails at runtime.

Complex Expressions

Using many nested functions makes templates hard to read:

"[concat(variables('prefix'), '-', parameters('environment'), '-', uniqueString(deployment().name))]"

Bicep allows simpler string interpolation:

'${prefix}-${environment}-${uniqueString(deployment().name)}'

Migration from ARM to Bicep

Bicep is the modern approach for Azure IaC. Organizations with existing ARM templates can migrate incrementally.

Decompile ARM to Bicep

The Bicep CLI can decompile ARM JSON templates to Bicep:

bicep decompile main.json

This generates main.bicep from main.json. The generated Bicep is functionally equivalent but may not be perfectly idiomatic. You should review and refactor the decompiled Bicep for readability.

Incremental Migration

You don’t need to convert everything at once:

  1. Start with new infrastructure using Bicep
  2. Gradually convert ARM templates to Bicep as they’re updated
  3. Use Bicep modules to wrap reusable pieces
  4. Reference existing ARM deployments during the transition

Bicep compiles to ARM JSON, so new Bicep deployments and old ARM deployments coexist without conflict.

Strategy for Large Codebases

For organizations with hundreds of ARM templates:

  1. Prioritize: Identify frequently-changed templates and high-value deployments
  2. Create module wrappers: Wrap ARM templates in Bicep modules to provide a consistent interface
  3. Decompile strategically: Decompile high-value templates and refactor the output
  4. New development in Bicep: All new infrastructure uses Bicep from the start
  5. Phase-out ARM: Over time, convert or retire ARM templates

ARM vs Bicep vs Terraform Decision Framework

All three are valid IaC approaches on Azure. The choice depends on your context.

Aspect ARM Templates Bicep Terraform
Language JSON Bicep DSL (compiles to ARM) HCL
Learning curve Steeper (JSON verbosity) Gentler (familiar syntax) Moderate (HCL)
Azure coverage 100% (native) 100% (via ARM) ~95% (usually complete)
Multi-cloud Azure only Azure only AWS, GCP, Azure, others
Community support Large (Microsoft) Growing (Microsoft) Very large (open source)
State management None (stateless) None (stateless) Required (state files)
Drift detection What-If What-If Plan shows drift
Maturity Mature (many years) Newer (actively improving) Very mature (multi-year)
Team expertise Likely exists Smaller community Highly valued skill

When to Use ARM

  • Legacy codebases: Existing ARM templates don’t justify immediate conversion
  • Azure-only shops: No multi-cloud needs and deep Azure expertise
  • Generated outputs: Portal or tool-generated templates are already ARM

When to Use Bicep

  • New Azure deployments: All new projects should start with Bicep
  • Modern syntax preference: Teams prefer readable syntax over JSON
  • Azure specialists: Your team knows Azure deeply and doesn’t need multi-cloud support
  • Migration from ARM: Converting existing ARM templates to Bicep for maintainability

When to Use Terraform

  • Multi-cloud architectures: Support for AWS, GCP, Azure, and other providers
  • Polyglot teams: HCL expertise is more widely available than Bicep
  • Platform engineering: Building abstractions that work across clouds
  • State management requirements: Organizations with sophisticated state/drift management needs

For detailed Bicep guidance, see the Azure Bicep Fundamentals guide.


Common Pitfalls

Pitfall 1: Using Complete Mode Without Careful Planning

Problem: Deploying in Complete mode with an incomplete template deletes unintended resources.

Result: Data loss, service interruptions, or deleted configurations.

Solution: Always validate and test Complete mode deployments with What-If first. Use Complete mode only for resource groups managed entirely by the template. For existing resource groups with resources managed outside the template, use Incremental mode.


Pitfall 2: Hard-Coded Values Instead of Parameters

Problem: Embedding environment-specific values directly in the template (location, VM sizes, SKUs).

Result: Template is not reusable. Each environment requires a separate template copy.

Solution: Use parameters for all values that vary between environments or users. Provide sensible defaults for parameters.


Pitfall 3: Incorrect Resource Dependencies

Problem: Resources can deploy in any order, but your infrastructure has dependencies (e.g., NIC before VM). Omitting explicit dependsOn may cause race conditions.

Result: Deployment fails intermittently or resources deploy in wrong order causing configuration errors.

Solution: Explicitly declare dependencies with dependsOn. ARM generally infers dependencies from property references, but always verify critical dependencies are explicit.


Pitfall 4: Linked Template URI Access Issues

Problem: Linked templates stored in Blob Storage become inaccessible due to firewall rules, missing SAS tokens, or incorrect permissions.

Result: Deployment fails with “Unable to download template” errors.

Solution: Ensure linked template URIs are publicly accessible or properly secured with SAS tokens. Test template URIs independently before deploying. Consider using nested templates for simpler cases.


Pitfall 5: Forgetting to Reference Outputs

Problem: Linked template outputs are available but never used; deployments don’t propagate information needed by other systems.

Result: Other infrastructure or scripts don’t have the information they need (like resource IDs or connection strings).

Solution: Design templates to output critical information (resource IDs, endpoints, connection strings). Return these from linked templates to parent deployments.


Pitfall 6: Outdated API Versions

Problem: Using very old API versions (e.g., 2015-08-01) that have been deprecated.

Result: Properties don’t exist in the old API version; deployments fail or missing features can’t be used.

Solution: Use current API versions. Reference the Azure Resource Manager schema documentation for current versions.


Pitfall 7: Complex Resource Interdependencies in Large Templates

Problem: Large templates with many resources become difficult to understand what depends on what.

Result: Changes to one resource cause unexpected failures in others; debugging is painful.

Solution: Break large templates into logical pieces using linked or nested templates. Each piece handles a cohesive infrastructure domain (networking, compute, data). This improves readability and reduces accidental coupling.


Key Takeaways

  1. ARM templates are fundamental to Azure. Every deployment to Azure goes through the ARM deployment plane. Understanding ARM is essential for working effectively with Azure infrastructure.

  2. ARM’s role is foundational, not deprecated. Bicep compiles to ARM, making ARM knowledge relevant even in modern Bicep-based deployments. ARM understanding helps debug compiled Bicep output.

  3. Parameters and variables separate concerns. Templates define structure; parameter files provide values. This separation enables the same template to deploy to multiple environments.

  4. Deployment modes have different safety profiles. Incremental mode is safe and forgiving for incomplete templates. Complete mode is dangerous without careful validation; always use What-If before running Complete deployments.

  5. Linked vs nested templates trade off reusability for complexity. Linked templates enable reuse across projects but require managed storage. Nested templates are self-contained but harder to reuse.

  6. Copies and conditions enable complex deployments without duplication. Use copy for multiple resource instances and condition to deploy resources conditionally based on parameters.

  7. Template functions enable dynamic configuration. Functions like reference(), uniqueString(), concat(), and if() make templates flexible and reusable without hardcoding.

  8. Validation and What-If prevent deployment disasters. Always validate templates before deploying and use What-If before Complete mode deployments.

  9. Bicep is the modern approach, but ARM knowledge remains relevant. New projects should use Bicep, but understanding ARM’s structure, functions, and limitations is necessary for working with existing infrastructure.

  10. Template complexity grows with size; decomposition matters. Large monolithic templates become unmaintainable. Break them into logical pieces using linked or nested templates.

Found this guide helpful? Share it with your team:

Share on LinkedIn