Azure ARM Templates: Managing Multiple Environments From a Single Source

The most common mistake I saw with early ARM template adoption was writing a separate template for each environment — one for dev, one for staging, one for production. This preserved the "separate documentation per environment" mental model that preceded IaC, just in a JSON format instead of a Word document. The environments drifted anyway, just more slowly.

The right model is one template, multiple parameter files. This forces the template to express everything that's the same across environments, while parameter files capture what's different. When something needs to change in all environments, you change it once in the template. When something is environment-specific, it lives in the parameter file where it belongs. Here's how I structured this in 2013.

Template vs Parameter File Responsibilities

The template describes the shape of the infrastructure: what types of resources, how they're connected, what options are available for configuration. The parameter file provides the values: which specific sizes, which specific names, which specific credentials.

// azuredeploy.json — the template (same for all environments)
{
  "parameters": {
    "environment": {
      "type": "string",
      "allowedValues": ["dev", "staging", "prod"]
    },
    "sqlVmSize": {
      "type": "string",
      "defaultValue": "Standard_DS2"
    },
    "sqlAdminUsername": { "type": "string" },
    "sqlAdminPassword": { "type": "securestring" },
    "storageAccountName": { "type": "string" }
  },
  "variables": {
    "vmName": "[concat('sql-', parameters('environment'), '-01')]",
    "vnetName": "[concat('vnet-', parameters('environment'))]"
  }
}
// azuredeploy.dev.parameters.json
{
  "$schema": "...",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "environment": { "value": "dev" },
    "sqlVmSize": { "value": "Standard_DS1" },
    "sqlAdminUsername": { "value": "sqladmin" },
    "storageAccountName": { "value": "acmedevstorage01" }
  }
}

// azuredeploy.prod.parameters.json
{
  "parameters": {
    "environment": { "value": "prod" },
    "sqlVmSize": { "value": "Standard_DS4" },
    "sqlAdminUsername": { "value": "sqladmin" },
    "storageAccountName": { "value": "acmeprodstorage01" }
  }
}

Passwords are securestring parameters that are passed at deploy time, never stored in the parameter files. The parameter files live in source control. Secrets don't.

Deploying From Source Control

# Deploy dev environment
az group deployment create   --resource-group rg-acme-dev   --template-file azuredeploy.json   --parameters @azuredeploy.dev.parameters.json   --parameters sqlAdminPassword='$SecretFromVault'

# Deploy prod (same template, different parameters)
az group deployment create   --resource-group rg-acme-prod   --template-file azuredeploy.json   --parameters @azuredeploy.prod.parameters.json   --parameters sqlAdminPassword='$ProdSecretFromVault'

(Note: I'm using the modern Azure CLI syntax here; in 2013 this was done through PowerShell cmdlets, but the concept is identical.)

Idempotency: The Key Property

ARM templates are idempotent — applying the same template to an existing resource group produces the same result whether the resources exist or not. If the VM already exists with the specified configuration, nothing changes. If it doesn't exist, it's created. If the configuration drifted, it's corrected.

This property meant you could run the same deploy command to both create an environment and to enforce configuration consistency. "I want to make sure all environments match the template" was a single command, not an audit of three separate environments. That's a significant operational simplification.

What Changed About Environment Parity

Before this approach, environment parity was aspirational — you tried to keep dev, staging, and prod similar, but they drifted because changes were applied manually and inconsistently. With a shared template as the source of truth, parity was structural. The only way environments could diverge was if someone made out-of-band changes through the portal (which the discipline rules prohibited) or if the parameter files legitimately specified different values for a reason.

Debugging "it works in dev but not in prod" became a structured investigation: diff the parameter files, look for differences in the template that might be environment-sensitive, check for any portal changes that bypassed the template. Three places to look, all explicit. That's a much smaller search space than "the environments are somehow different, I don't know how." As always, I'm here to help.

Read more