Background
Sections
IntroductionFoundations1. Resource Hierarchy2. Resource Manager3. Identity and RBAC4. Regions and Availability5. Naming and TaggingVirtual Machines1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetVirtual Network1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetBlob Storage1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure SQL Database1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Kubernetes Service1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Container Registry1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetMicrosoft Entra ID1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure RBAC1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Functions1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAPI Management1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure App Configuration1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Machine Learning1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Monitor1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure AI Foundry1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and Cheatsheet

5. Deployment

22 min read

Getting Started proved the service exists. This page makes it repeatable, reviewable, and reversible — a parameterised module, remote state, a pipeline with no secrets in it, three environments, and an answer to "how do we undo this at 2 a.m."

Blob Storage has an unusual twist that no other topic in this article has: the storage account is also where Terraform's own state lives. So this page has a chicken-and-egg section, and it's worth reading even if you skim the rest.

[Image Prompt: 2D minimalistic pipeline diagram of a storage account deployment flowing from git commit through terraform plan, pull request review, manual approval, and apply into dev, staging, and prod environments, flat design, clean vector art style, white background]

Tool order — the same everywhere in this article

Rank Tool What it's for here When it's the wrong choice
1. Primary Terraform (azurerm, plus azapi for preview features) The full worked example — the account, its containers, lifecycle rules, private endpoint, diagnostics, and role assignments State is yours to protect; portal clicks cause drift; azurerm lags brand-new storage features by weeks to months, which is what azapi is for
2. Secondary Ansible (azure.azcollection) Day-2 operations and data-shaped tasks Terraform shouldn't own — seeding containers, uploading bootstrap objects, rotating keys, applying a lifecycle policy as an operational change Won't reconcile infrastructure drift the way terraform plan does; don't make it the source of truth for the account itself
3. Third Bicep / ARM The Azure-native path — day-one support for new storage features, deployment stacks, template specs, and what Microsoft's docs and exams assume Azure-only; no plan as rich as Terraform's, though what-if is close; ARM JSON is unreadable at scale

All three apply cleanly to Blob Storage, so all three are written below.


1. The Terraform module

A real module, parameterised by environment. Three files.

variables.tf

variable "name_prefix" {
  description = "Short lowercase alphanumeric prefix. Storage account names allow no hyphens."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9]{3,11}$", var.name_prefix))
    error_message = "name_prefix must be 3-11 lowercase alphanumeric characters (account names cap at 24)."
  }
}

variable "environment" {
  description = "dev | staging | prod"
  type        = string

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of: dev, staging, prod."
  }
}

variable "location" {
  type    = string
  default = "uksouth"
}

variable "containers" {
  description = "Containers to create in the account."
  type        = list(string)
  default     = ["raw", "curated"]
}

variable "enable_hns" {
  description = "Hierarchical namespace (ADLS Gen2). IRREVERSIBLE — set at creation only."
  type        = bool
  default     = false
}

variable "subnet_id" {
  description = "Subnet for the private endpoint. Null disables private networking (dev only)."
  type        = string
  default     = null
}

variable "private_dns_zone_id" {
  description = "Resource ID of the privatelink.blob.core.windows.net zone."
  type        = string
  default     = null
}

variable "log_analytics_workspace_id" {
  type    = string
  default = null
}

main.tf

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
    azapi = {
      source  = "Azure/azapi"
      version = "~> 2.0"
    }
  }
}

provider "azurerm" {
  # The features block is not decoration. For storage it controls destroy-time
  # behaviour around soft-deleted data — the storage equivalent of the Key Vault
  # purge-protection footgun.
  features {
    resource_group {
      # Refuse to delete a resource group that still contains resources not in state.
      # Leave this true; setting it false is how people delete things they didn't know existed.
      prevent_deletion_if_contains_resources = true
    }
  }
}

locals {
  # Account names: 3-24 chars, lowercase alphanumeric, globally unique across ALL of Azure.
  account_name = "st${var.name_prefix}${var.environment}"

  is_prod = var.environment == "prod"

  # Redundancy is an ACCOUNT-level setting, not a per-blob storage class.
  replication = local.is_prod ? "GZRS" : "LRS"

  tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
    Topic       = "blob-storage"
  }
}

resource "azurerm_resource_group" "this" {
  name     = "rg-${var.name_prefix}-${var.environment}"
  location = var.location
  tags     = local.tags
}

resource "azurerm_storage_account" "this" {
  name                     = local.account_name
  resource_group_name      = azurerm_resource_group.this.name
  location                 = azurerm_resource_group.this.location
  account_kind             = "StorageV2"
  account_tier             = "Standard"
  account_replication_type = local.replication

  # --- IRREVERSIBLE. Changing this forces a REPLACE and destroys all data. ---
  is_hns_enabled = var.enable_hns

  # --- security posture: these six lines are the whole argument ---
  min_tls_version                   = "TLS1_2"
  https_traffic_only_enabled        = true
  allow_nested_items_to_be_public   = false
  shared_access_key_enabled         = false # forces Entra ID auth. See Architecture.
  public_network_access_enabled     = var.subnet_id == null
  default_to_oauth_authentication   = true

  blob_properties {
    versioning_enabled  = true
    change_feed_enabled = local.is_prod

    delete_retention_policy {
      days = local.is_prod ? 30 : 7
    }

    container_delete_retention_policy {
      days = local.is_prod ? 30 : 7
    }
  }

  network_rules {
    # Deny by default; the private endpoint is the way in.
    default_action = var.subnet_id == null ? "Allow" : "Deny"
    bypass         = ["AzureServices"] # lets Backup, Monitor, Event Grid etc. reach the account
  }

  identity {
    type = "SystemAssigned"
  }

  tags = local.tags

  lifecycle {
    # A name change or an HNS flip would silently destroy the account and every blob in it.
    prevent_destroy = true
  }
}

# Containers are a DATA-PLANE operation. The identity running Terraform needs a
# blob data role, not just Contributor — and network access to the endpoint.
resource "azurerm_storage_container" "this" {
  for_each = toset(var.containers)

  name                  = each.value
  storage_account_id    = azurerm_storage_account.this.id
  container_access_type = "private"
}

# Tier cold data automatically. This is the single largest cost lever in the topic.
resource "azurerm_storage_management_policy" "this" {
  storage_account_id = azurerm_storage_account.this.id

  rule {
    name    = "tier-and-expire-raw"
    enabled = true

    filters {
      prefix_match = ["raw/"]
      blob_types   = ["blockBlob"]
    }

    actions {
      base_blob {
        tier_to_cool_after_days_since_modification_greater_than    = 30
        tier_to_archive_after_days_since_modification_greater_than = 180
        delete_after_days_since_modification_greater_than          = 2555 # ~7 years
      }

      # Versioning without this rule is a bill that grows forever.
      version {
        delete_after_days_since_creation = 90
      }

      snapshot {
        delete_after_days_since_creation_greater_than = 90
      }
    }
  }
}

# --- private networking (prod and staging) ---------------------------------
resource "azurerm_private_endpoint" "blob" {
  count = var.subnet_id == null ? 0 : 1

  name                = "pe-${local.account_name}-blob"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  subnet_id           = var.subnet_id

  private_service_connection {
    name                           = "psc-${local.account_name}"
    private_connection_resource_id = azurerm_storage_account.this.id
    is_manual_connection           = false
    # One private endpoint per SUB-RESOURCE. "blob" here; add another for "dfs" if HNS is on.
    subresource_names = var.enable_hns ? ["blob", "dfs"] : ["blob"]
  }

  private_dns_zone_group {
    name                 = "default"
    private_dns_zone_ids = [var.private_dns_zone_id]
  }

  tags = local.tags
}

# --- observability: diagnostic settings are OFF by default -----------------
resource "azurerm_monitor_diagnostic_setting" "blob" {
  count = var.log_analytics_workspace_id == null ? 0 : 1

  name = "diag-${local.account_name}-blob"
  # Note the /blobServices/default suffix — blob logs hang off the SERVICE,
  # not the account. Pointing this at the account ID silently gets you nothing.
  target_resource_id         = "${azurerm_storage_account.this.id}/blobServices/default"
  log_analytics_workspace_id = var.log_analytics_workspace_id

  enabled_log { category = "StorageRead" }
  enabled_log { category = "StorageWrite" }
  enabled_log { category = "StorageDelete" }

  metric { category = "Transaction" }
}

outputs.tf

output "storage_account_id" {
  value = azurerm_storage_account.this.id
}

output "storage_account_name" {
  value = azurerm_storage_account.this.name
}

output "blob_endpoint" {
  value = azurerm_storage_account.this.primary_blob_endpoint
}

output "dfs_endpoint" {
  description = "Only meaningful when HNS is enabled."
  value       = var.enable_hns ? azurerm_storage_account.this.primary_dfs_endpoint : null
}

output "principal_id" {
  description = "System-assigned identity — grant it roles on Key Vault, Event Grid, etc."
  value       = azurerm_storage_account.this.identity[0].principal_id
}

The loop

terraform init -backend-config=backends/dev.hcl
terraform plan  -var-file=envs/dev.tfvars -out=tfplan
terraform apply tfplan

When azurerm doesn't have the feature yet

Azure ships storage features faster than the provider absorbs them — SFTP local users, new immutability options, and preview networking settings are recurring examples. When the argument doesn't exist, azapi writes the ARM payload directly against the same resource:

# Example shape: set a property azurerm has not exposed yet.
resource "azapi_update_resource" "storage_preview_setting" {
  type        = "Microsoft.Storage/storageAccounts@2023-05-01"
  resource_id = azurerm_storage_account.this.id

  body = {
    properties = {
      # e.g. a preview-only property. Preview features have NO SLA and can change.
      # Verify the API version and property name against the current ARM reference.
    }
  }
}

Label anything you configure this way as preview in your own README, and revisit it when the provider catches up — leaving azapi overrides in place after azurerm gains support causes perpetual diffs.


2. Remote state — and the chicken-and-egg problem

Local state is a footgun on a team: it isn't shared, it isn't locked, it isn't backed up, and it contains secrets in plain text. The azurerm backend stores it in a blob and uses native blob leases for locking — no separate lock table, which is a genuine simplification over AWS's S3 + DynamoDB arrangement.

Bootstrap the state account once, by hand

You cannot manage your state backend with the state it holds. Create it out-of-band, exactly once, and never touch it again:

RG=rg-tfstate
ACCT=sttfstate$RANDOM
LOC=uksouth

az group create -n $RG -l $LOC

az storage account create -n $ACCT -g $RG -l $LOC \
  --sku Standard_GZRS \
  --kind StorageV2 \
  --min-tls-version TLS1_2 \
  --allow-blob-public-access false \
  --allow-shared-key-access false

# Versioning + soft delete on the STATE account is not optional.
# A corrupted state file with no previous version is a very bad afternoon.
az storage account blob-service-properties update -n $ACCT -g $RG \
  --enable-versioning true \
  --enable-delete-retention true --delete-retention-days 30

az storage container create --account-name $ACCT -n tfstate --auth-mode login

# Then lock it so nobody deletes it, including you.
az lock create --name protect-tfstate --lock-type CanNotDelete \
  --resource-group $RG

backends/dev.hcl

resource_group_name  = "rg-tfstate"
storage_account_name = "sttfstateXXXXX"
container_name       = "tfstate"
key                  = "blob-storage/dev.terraform.tfstate"
use_azuread_auth     = true # authenticate to the backend with Entra, not the account key
use_oidc             = true # in CI; locally this falls back to your az login

use_azuread_auth = true is the line people miss. Without it, Terraform reaches for the account key — which you disabled — and the backend init fails in a way that looks like a network problem.

One state per environment, and how to split it

Two honest options:

Approach How Pick it when
Directory (or key) per environment A separate key in the backend config plus a separate .tfvars ✅ Default. Explicit, greppable, allows genuinely different backends and even different subscriptions per environment
Terraform workspaces terraform workspace new prod, one backend, state key suffixed automatically Convenient for short-lived, near-identical environments. Dangerous for prod because one wrong workspace select applies dev's plan to prod, and the blast radius is invisible in the diff

For anything with a production environment, use a separate state key and a separate subscription. See the environments section below.


3. Ansible — day-2 operations

Ansible's honest role here is not owning the storage account; Terraform does that. Its role is the imperative, data-shaped work Terraform is bad at: seeding containers, uploading bootstrap objects, running a one-off retiering campaign, or rotating keys during an incident.

# playbook-blob-day2.yml
- name: Blob Storage day-2 operations
  hosts: localhost
  connection: local
  gather_facts: false

  vars:
    resource_group: "rg-demo-dev"
    account_name: "stdemodev"
    containers:
      - raw
      - curated
      - quarantine

  tasks:
    # Authentication: prefer a managed identity on the runner, or a service principal
    # via AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_SUBSCRIPTION_ID env vars.
    # Never put a client secret in the playbook.

    - name: Read the storage account (assert it exists — do not create it here)
      azure.azcollection.azure_rm_storageaccount_info:
        resource_group: "{{ resource_group }}"
        name: "{{ account_name }}"
      register: acct

    - name: Fail fast if Terraform has not created the account
      ansible.builtin.assert:
        that:
          - acct.storageaccounts | length == 1
        fail_msg: >-
          Account {{ account_name }} does not exist. Ansible does not own account
          creation in this design — run Terraform first.

    - name: Ensure operational containers exist
      azure.azcollection.azure_rm_storageblob:
        resource_group: "{{ resource_group }}"
        storage_account_name: "{{ account_name }}"
        container: "{{ item }}"
        state: present
      loop: "{{ containers }}"

    - name: Upload a bootstrap manifest
      azure.azcollection.azure_rm_storageblob:
        resource_group: "{{ resource_group }}"
        storage_account_name: "{{ account_name }}"
        container: curated
        blob: bootstrap/manifest.json
        src: ./files/manifest.json
        content_type: application/json
        state: present

Demonstrating idempotency — the whole point of using Ansible rather than a shell script:

ansible-playbook playbook-blob-day2.yml
# PLAY RECAP ... changed=4  ok=6

ansible-playbook playbook-blob-day2.yml
# PLAY RECAP ... changed=0  ok=6      ← second run changes nothing

If the second run reports changes, a task is not idempotent — usually because it uploads a file whose content hash the module can't compare. Fix that rather than living with it, because a playbook that always reports "changed" is a playbook nobody reads the output of.

Where Ansible is genuinely better than Terraform here: an emergency key rotation, a bulk retiering of ten million blobs, or seeding test data. All are imperative, one-shot actions with no desired state worth reconciling.


4. Bicep / ARM — the Azure-native path

Bicep module and what-if preview
// storage.bicep
@minLength(3)
@maxLength(11)
param namePrefix string

@allowed(['dev', 'staging', 'prod'])
param environment string

param location string = resourceGroup().location

@description('IRREVERSIBLE. Hierarchical namespace / ADLS Gen2.')
param enableHns bool = false

var accountName = 'st${namePrefix}${environment}'
var isProd = environment == 'prod'

resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: accountName
  location: location
  kind: 'StorageV2'
  sku: {
    name: isProd ? 'Standard_GZRS' : 'Standard_LRS'
  }
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    isHnsEnabled: enableHns
    minimumTlsVersion: 'TLS1_2'
    supportsHttpsTrafficOnly: true
    allowBlobPublicAccess: false
    allowSharedKeyAccess: false
    defaultToOAuthAuthentication: true
    publicNetworkAccess: isProd ? 'Disabled' : 'Enabled'
    networkAcls: {
      defaultAction: isProd ? 'Deny' : 'Allow'
      bypass: 'AzureServices'
    }
  }
  tags: {
    Environment: environment
    ManagedBy: 'bicep'
  }
}

resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = {
  parent: storage
  name: 'default'
  properties: {
    isVersioningEnabled: true
    changeFeed: { enabled: isProd }
    deleteRetentionPolicy: {
      enabled: true
      days: isProd ? 30 : 7
    }
    containerDeleteRetentionPolicy: {
      enabled: true
      days: isProd ? 30 : 7
    }
  }
}

resource rawContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = {
  parent: blobService
  name: 'raw'
  properties: {
    publicAccess: 'None'
  }
}

output storageAccountId string = storage.id
output blobEndpoint string = storage.properties.primaryEndpoints.blob
# Preview before you deploy. This is Bicep's answer to `terraform plan`.
az deployment group what-if \
  --resource-group rg-demo-dev \
  --template-file storage.bicep \
  --parameters namePrefix=demo environment=dev

az deployment group create \
  --resource-group rg-demo-dev \
  --template-file storage.bicep \
  --parameters namePrefix=demo environment=dev

⚠️ Deployment modes — the footgun worth naming every time

az deployment group create defaults to incremental mode: resources in the template are created or updated, and anything else in the resource group is left alone.

Complete mode (--mode Complete) deletes every resource in the resource group that is not in the template. Run it against a resource group containing a storage account you forgot to include and the account — and every blob in it — is gone. There is no confirmation beyond the one you skipped.

Always run what-if with the same --mode you intend to deploy with; what-if will show the deletions.

Where Bicep is genuinely the better choice for storage: anything using a brand-new storage feature that azurerm hasn't absorbed yet (Bicep gets it on day one via the API version), teams already living in Azure DevOps, and anything governed by deployment stacks, which give ARM a real notion of "resources this deployment owns" and a managed delete behaviour — closer to Terraform's model than ARM has historically been.


5. CI/CD — GitHub Actions with OIDC, no secrets

The only acceptable way to authenticate a pipeline to Azure is workload identity federation: the runner presents a short-lived OIDC token from GitHub, Entra ID trusts it based on a federated credential you configured, and no client secret exists anywhere.

One-time Entra setup

APP_ID=$(az ad app create --display-name "gha-blob-storage" --query appId -o tsv)
az ad sp create --id $APP_ID
SP_OID=$(az ad sp show --id $APP_ID --query id -o tsv)

# Control plane: manage the account.
az role assignment create --assignee-object-id $SP_OID --assignee-principal-type ServicePrincipal \
  --role "Contributor" \
  --scope "/subscriptions/$SUB/resourceGroups/rg-demo-dev"

# Data plane: create containers, write state. Contributor alone is NOT enough
# once shared-key access is disabled. This is the step everyone forgets.
az role assignment create --assignee-object-id $SP_OID --assignee-principal-type ServicePrincipal \
  --role "Storage Blob Data Contributor" \
  --scope "/subscriptions/$SUB/resourceGroups/rg-tfstate"

# Trust GitHub's token for this repo + environment. One credential per subject.
az ad app federated-credential create --id $APP_ID --parameters '{
  "name": "gha-main",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:my-org/my-repo:ref:refs/heads/main",
  "audiences": ["api://AzureADTokenExchange"]
}'

.github/workflows/deploy.yml

name: blob-storage

on:
  pull_request:
    paths: ['infra/blob-storage/**']
  push:
    branches: [main]
    paths: ['infra/blob-storage/**']

permissions:
  id-token: write     # required for OIDC — without it the login silently fails
  contents: read
  pull-requests: write

env:
  ARM_USE_OIDC: true
  ARM_USE_AZUREAD: true            # backend auth via Entra, not the account key
  ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
  ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
  ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}

jobs:
  plan:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infra/blob-storage
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init -backend-config=backends/dev.hcl
      - run: terraform validate
      - run: terraform plan -var-file=envs/dev.tfvars -out=tfplan
      - uses: actions/upload-artifact@v4
        with:
          name: tfplan
          path: infra/blob-storage/tfplan

  apply-dev:
    needs: plan
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: dev            # no approval gate
    steps: [ ... same login, then: terraform apply tfplan ]

  apply-prod:
    needs: apply-dev
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: prod           # ← GitHub environment with required reviewers
    steps: [ ... login, init with backends/prod.hcl, plan, apply ]

Three details that matter more than the YAML:

  • permissions: id-token: write is mandatory. Omit it and azure/login fails with a message that does not mention OIDC.
  • Plan on PR, apply on merge. The plan output posted as a PR comment is the review artifact. Applying a plan file (rather than re-planning at apply time) means the thing reviewed is the thing applied.
  • The prod runner cannot reach a private-endpoint-only account. Once publicNetworkAccess is Disabled, a GitHub-hosted runner has no route to the data plane, so container and blob resources fail. The options: a self-hosted runner inside the VNet, an Azure DevOps managed VNet-injected agent, or — the pragmatic middle ground — keep containers out of the private-only Terraform and manage them via an ARM-typed resource on the control plane. Decide this before you turn the firewall on, not after.

6. Environments

Boundary Use it for Notes
Separate resource groups Minimum viable separation Cheap and easy, but quota and policy still shared at subscription level
Separate subscriptions ✅ What to actually pick for prod The subscription is Azure's natural blast-radius and quota boundary. Storage-accounts-per-region is a per-subscription limit, so a busy dev subscription can literally block a prod deployment
Separate management groups Enforcing the difference by policy Where Azure Policy lives — see below
Separate tenants Regulated multi-org setups only High friction; rarely justified

One subscription per environment is the right default in Azure far more often than "one account per environment" is in AWS, precisely because so many limits are counted per-subscription-per-region.

Where Azure Policy makes it real. Convention breaks; policy doesn't. The storage policies worth assigning at the management-group level, at least in audit mode from day one:

  • Storage accounts should have infrastructure encryption / secure transfer required.
  • Storage accounts should disable public blob access (allowBlobPublicAccess = false).
  • Storage accounts should disable shared key access.
  • Storage accounts should use private link / restrict public network access.
  • Storage accounts should have a minimum TLS version of 1.2.
  • Allowed SKUs — block anyone creating a GRS account in dev, or an LRS account in prod.

Assign in Audit mode first, look at the compliance state for a week, then flip the important ones to Deny. Going straight to Deny breaks someone's pipeline on a Friday and gets policy uninstalled.

envs/prod.tfvars

name_prefix         = "acme"
environment         = "prod"
location            = "uksouth"
containers          = ["raw", "curated", "published"]
enable_hns          = true
subnet_id           = "/subscriptions/.../subnets/snet-privatelink"
private_dns_zone_id = "/subscriptions/.../privateDnsZones/privatelink.blob.core.windows.net"

7. Rollback and blast radius

This is the section to read twice, because storage is the one service where a bad rollback destroys data rather than availability.

What "undo" means here

Change Undo
A bad terraform apply on account settings Revert the commit, re-apply. Settings are mostly in-place updates
A deleted blob Soft delete → az storage blob undelete (only if it was on before the delete)
An overwritten blob Blob versioning → promote the previous version (again, only if it was on beforehand)
A deleted container Container soft delete → restore, within the retention window
A corrupted container's whole contents Point-in-time restore, which requires versioning + change feed + soft delete all enabled in advance
A deleted storage account Account-level soft delete may allow recovery within a retention window ⚠️ verify current availability and conditions. Do not rely on it
A regional outage Customer-initiated failover on a GRS/GZRS account — non-zero RPO, account-wide, not reversible on a whim

The pattern is unmissable: every recovery option must be enabled before the incident. Soft delete and versioning are not features you turn on after losing data. Turn them on in the module, in every environment, on day one — the storage overhead is small and the lifecycle rule caps it.

The operations that force replacement

terraform plan marks these as # forces replacement, and for a storage account, replacement means destroy and recreate — every blob gone. Read the plan output. Every time.

  • name — even a case or prefix change.
  • is_hns_enabled — flipping ADLS Gen2 on or off.
  • account_kind in some transitions, and location always.
  • is_sftp_enabled / nfsv3_enabled and other creation-only flags.

prevent_destroy = true on the account resource is the seatbelt. It converts a catastrophic apply into an error message, and the cost is that intentional destroys need a deliberate code change. That's the right trade for a data store.

The two Azure-specific traps

Soft delete and purge behaviour. A soft-deleted container's name stays taken until the retention window expires. A terraform destroy followed immediately by a terraform apply — a totally normal thing to do in dev — can fail with a name conflict against something you can no longer see in the portal. The same pattern bites harder on Key Vault; on storage it's mostly an annoyance, but it's the same shape of surprise.

Resource locks. A CanNotDelete lock on the resource group makes terraform destroy fail with a message that reads like a permissions error. It isn't. Check for locks before assuming your RBAC broke:

az lock list --resource-group rg-acme-prod -o table

Locks are inherited from parent scopes, so a lock on the subscription will fail an apply in a resource group where you can see no lock at all. Put CanNotDelete on production storage deliberately, and document where it is.

Immutability policies. A locked time-based retention policy or a legal hold will refuse deletion — that is the entire feature. terraform destroy cannot override it, and neither can an Owner. If you enable WORM in a test environment, use an unlocked policy with a short window.


8. Drift detection

Drift on a storage account is common, because the portal makes single-setting changes easy and someone will "just add an IP to the firewall" during an incident.

Method What it catches Cadence
terraform plan in CI on a schedule Anything in state that no longer matches Nightly. Fail the job on a non-empty plan and alert
az deployment group what-if The same, for Bicep-managed resources On demand, and before any complete-mode deploy
Azure Policy compliance state Anything violating the security baseline, including resources Terraform doesn't manage Continuous — this is the safety net for shadow resources
Activity log / Change Analysis Who changed it and when On investigation. az monitor activity-log list filtered to the account's resource ID
Microsoft Defender for Storage Malicious or anomalous data-plane activity Continuous, if licensed

A nightly drift job:

  drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with: { client-id: ..., tenant-id: ..., subscription-id: ... }
      - run: terraform init -backend-config=backends/prod.hcl
      - name: Detect drift
        run: terraform plan -var-file=envs/prod.tfvars -detailed-exitcode
        # exit 0 = no changes, 2 = drift detected, 1 = error

Getting back to a clean plan when someone has changed something by hand: decide whether the manual change was right. If it was, port it into the module and apply — the code becomes the truth again. If it wasn't, just re-apply and Terraform reverts it. If the change created a resource Terraform doesn't know about, terraform import (or an import block) brings it under management rather than leaving an orphan. The one thing never to do is edit the state file by hand.


9. Teardown

terraform destroy -var-file=envs/dev.tfvars

Cleanup note — what destroy will not remove:

  • Soft-deleted blobs, versions, snapshots, and containers. They persist for the retention window and they keep billing. Destroying the account removes them; destroying only a container does not.
  • Anything under a locked immutability policy or legal hold. By design, and no role overrides it.
  • Resources behind a CanNotDelete lock, including locks inherited from the subscription or management group.
  • The account name, which may stay reserved after deletion — plan for it when scripting destroy-then-recreate cycles.
  • Role assignments and diagnostic settings created outside the state file — e.g. by a portal click or a policy DeployIfNotExists. They become orphans pointing at a deleted principal.
  • The state account itself, which is deliberately outside this module and locked.
  • Data already replicated to a secondary region on a GRS account is removed with the account, but verify before assuming — geo-replication and deletion timing are not instantaneous.

Next: Integrations →

← Back to the Blob Storage overview · ← Previous: Getting Started