5. Deployment
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 createdefaults 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-ifwith the same--modeyou intend to deploy with;what-ifwill 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: writeis mandatory. Omit it andazure/loginfails 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
publicNetworkAccessis 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_kindin some transitions, andlocationalways.is_sftp_enabled/nfsv3_enabledand 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
destroywill 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
CanNotDeletelock, 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