Terraform Azure Storage Accounts: Footguns and Production Config
The azurerm_storage_account resource looks like a 10-minute job. It is not. The resource has over 100 configurable attributes, the provider has changed default behavior across at least three major versions, and Azure will silently accept configurations that are technically valid but operationally wrong. We have cleaned up after enough of these to write down what actually bites teams in production.
What the Defaults Give You (And What They Don't)
A bare-minimum block with account_tier = "Standard" and account_replication_type = "LRS" provisions successfully. It also:
- Leaves public blob access in a version-dependent state (disabled by default in azurerm 3.7+, but only when the attribute is omitted entirely, which produces confusing diffs when you read older examples and copy them)
- Keeps shared key access enabled, meaning any leaked connection string is a full data-plane credential
- Applies an
"Allow"default action on network rules, so every IP on the internet can attempt to reach the storage plane - Creates no soft-delete policy, so a
terraform destroyor an accidentalaz storage blob delete-batchis permanent
None of these are Terraform bugs. They are Azure API defaults, and Terraform faithfully applies them.
Storage account names must also be 3 to 24 characters, lowercase alphanumeric only. No hyphens. This is the first place pipelines break, because the natural pattern ${var.prefix}-storage causes a 400 Bad Request that is easy to misread as a permissions error. Validate early:
variable "prefix" {
type = string
validation {
condition = can(regex("^[a-z0-9]{1,18}$", var.prefix))
error_message = "prefix must be lowercase alphanumeric, max 18 chars."
}
}
resource "random_string" "sa_suffix" {
length = 6
upper = false
special = false
}
locals {
storage_account_name = "${var.prefix}${random_string.sa_suffix.result}"
}
The Full Resource Block We Actually Ship
Here is the baseline we use for any storage account touching production data:
resource "azurerm_storage_account" "main" {
name = local.storage_account_name
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
account_tier = "Standard"
account_replication_type = var.replication_type # "LRS" | "ZRS" | "GRS" | "RAGRS"
min_tls_version = "TLS1_2"
# Disable both public-access vectors explicitly.
# allow_nested_items_to_be_public controls per-container public ACLs.
# shared_access_key_enabled controls the account-level SAS/shared-key plane.
allow_nested_items_to_be_public = false
shared_access_key_enabled = false
# Default is "Allow". Set to "Deny" and whitelist explicitly.
network_rules {
default_action = "Deny"
ip_rules = var.allowed_cidr_list
virtual_network_subnet_ids = var.subnet_ids
bypass = ["AzureServices", "Logging", "Metrics"]
}
blob_properties {
delete_retention_policy {
days = 14
}
container_delete_retention_policy {
days = 7
}
versioning_enabled = true
}
identity {
type = "SystemAssigned"
}
lifecycle {
prevent_destroy = true
ignore_changes = [tags]
}
tags = var.tags
}
shared_access_key_enabled = false means SAS tokens and shared key auth no longer work. Every caller must go through Entra ID. That is the right posture for accounts holding sensitive data, but it will break any application still using a connection string with AccountKey=. Audit your app configs before flipping it in an existing environment.
bypass = ["AzureServices"] is the escape hatch that lets Azure Monitor, Backup, and similar first-party services reach the account after you set default_action = "Deny". Without it, diagnostic settings stop working, and you will not notice until you go looking for logs.
Network Rules and the CI/CD Trap
Setting default_action = "Deny" is correct. It also breaks every pipeline that pushes build artifacts or Terraform state to that account, unless the pipeline's outbound IP is in ip_rules.
For GitHub Actions, outbound IPs are published at https://api.github.com/meta and they change. We pull them in a data pass and feed them into ip_rules, or we peer the runner subnet directly into the VNet and use virtual_network_subnet_ids instead. For Azure DevOps, self-hosted agents on a known subnet are cleaner to manage than chasing the Microsoft-hosted agent IP ranges.
The specific mistake that shows up repeatedly: someone sets default_action = "Deny" on a storage account already holding Terraform remote state. The next terraform plan hangs because the state backend is now unreachable. If you are migrating existing state to a locked-down account, open the network rules first, migrate the state, then close them.
Lifecycle Policies and Tiering
Soft delete on the blob_properties block handles accidental deletion. For actual tiering and expiry (move to Cool after 30 days, Archive after 90, delete after 365), you need a separate resource that most examples omit:
resource "azurerm_storage_management_policy" "main" {
storage_account_id = azurerm_storage_account.main.id
rule {
name = "archive-and-expire"
enabled = true
filters {
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 = 90
delete_after_days_since_modification_greater_than = 365
}
snapshot {
delete_after_days_since_creation_greater_than = 90
}
}
}
}
We have found accounts in production with no tiering policy, storing years of blob data in Hot tier, at 3 to 5 times the necessary monthly cost. The azurerm_storage_management_policy resource is consistently absent from internal runbooks at companies doing IaC for the first time.
Provider Version Pinning and Drift Management
The AzureRM provider has had significant breaking changes between 2.x, 3.x, and 4.x. The large_file_share_enabled attribute moved into a share_properties block in 3.x. Several network-rule defaults shifted. The identity block changed shape. Pin the provider version and do not use a range operator that silently allows a major version jump:
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.100"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
The ~> operator here allows 3.x patch and minor updates but blocks the 4.x jump. Upgrade intentionally, with a plan output reviewed before applying, not as a side effect of terraform init -upgrade running in CI.
On the drift side: Portal edits to a storage account do not write back to Terraform state. If someone opens the portal and changes the replication type during an incident, the next terraform plan will show a diff and potentially revert it. For attributes where a Terraform revert would cause an outage, run terraform plan in read-only mode on a schedule and alert on non-empty diffs. In Azure DevOps this is a pipeline with terraform plan -detailed-exitcode where exit code 2 triggers a notification. For attributes you genuinely want to exclude from drift detection, scope ignore_changes narrowly:
lifecycle {
prevent_destroy = true
ignore_changes = [account_replication_type, tags]
}
Do not use ignore_changes = [all]. It is the Terraform equivalent of commenting out your tests.
We cover the broader IaC patterns for Azure environment management, including state backend architecture and pipeline design, in the cloud architecture practice.
Next Steps
If your team needs a module review before a storage account configuration goes to production, or you are working through a multi-environment Azure landing zone, reach out to us.
