Manage a hardened Azure Storage account with its blob containers, queues, tables, and file shares as one unit — private, Entra-first, and encrypted by default — on
hashicorp/azurerm ~> 4.0.
- 📦 Creates one
azurerm_storage_accounthardened by default: no public access, TLS 1.2, shared-key auth off (Entra ID preferred), infrastructure encryption on, HTTPS-only, cross-tenant replication off. - 🗂️ Manages containers, queues, tables, and file shares as keyed maps (
for_each). - 🧱 Optional
network_rules(default Deny), managedidentity,customer_managed_key, andblob_propertieswith versioning + 7-day soft delete on by default. - 🔒 Never emits an access key or connection string.
💡 Why it matters: storage is where regulated data lands. A private, Entra-authenticated, double-encrypted account on the empty call means the safe posture is the default — and every child object inherits it.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
flowchart TD
RG["terraform-azurerm-resource-group"]
KV["terraform-azurerm-key-vault (CMK key)"]
UAI["terraform-azurerm-user-assigned-identity"]
SA["terraform-azurerm-storage-account"]
ASA["azurerm_storage_account"]
CHILD["containers / queues / tables / shares (for_each)"]
PE["terraform-azurerm-private-endpoint"]
QP["terraform-azurerm-storage-account-queue-properties"]
TP["terraform-azurerm-storage-account-table-properties"]
NR["terraform-azurerm-storage-account-network-rules"]
SW["terraform-azurerm-storage-account-static-website"]
LU["terraform-azurerm-storage-account-local-user"]
RG -->|"resource_group_name + location"| SA
KV -->|"key_vault_key_id (CMK)"| SA
UAI -->|"user_assigned_identity_id"| SA
SA --> ASA
ASA --> CHILD
SA -->|"id"| PE
SA -->|"id, which becomes that record OWN Terraform id"| QP
SA -->|"id, which becomes that record OWN Terraform id"| TP
SA -->|"id, which becomes that record OWN Terraform id"| NR
SA -->|"id, which becomes that record OWN Terraform id"| SW
SA -->|"id, under which each local user mints its OWN child id"| LU
NR -.->|"CONFLICTS with this module own network_rules argument"| SA
classDef this fill:#0078D4,color:#ffffff,stroke:#004578,stroke-width:2px;
classDef key fill:#004578,color:#ffffff,stroke:#004578;
class SA this;
class ASA key;
flowchart LR
I1["name / location / resource_group_name"]
I2["public access off, TLS1_2, shared-key off,<br/>infra encryption on, https-only"]
I3["network_rules / identity / CMK / blob_properties"]
I4["containers / queues / tables / shares (maps)"]
SA["azurerm_storage_account.this"]
CO["azurerm_storage_container.this (for_each)"]
QU["azurerm_storage_queue.this (for_each)"]
TA["azurerm_storage_table.this (for_each)"]
SH["azurerm_storage_share.this (for_each)"]
O1["id / name / primary_blob_endpoint"]
O2["container_ids / queue_ids / table_ids / share_ids"]
I1 --> SA
I2 --> SA
I3 --> SA
I4 --> CO
I4 --> QU
I4 --> TA
I4 --> SH
SA --> CO
SA --> QU
SA --> TA
SA --> SH
SA --> O1
CO --> O2
classDef this fill:#0078D4,color:#ffffff,stroke:#004578,stroke-width:2px;
classDef key fill:#004578,color:#ffffff,stroke:#004578;
class SA key;
class CO this;
Resource inventory
| Resource | Cardinality | Role |
|---|---|---|
azurerm_storage_account.this |
1 (keystone) | The hardened account. |
azurerm_storage_container.this |
0..N (for_each) |
Blob containers (private by default). |
azurerm_storage_queue.this |
0..N (for_each) |
Queues. |
azurerm_storage_table.this |
0..N (for_each) |
Tables. |
azurerm_storage_share.this |
0..N (for_each) |
File shares. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| Provider | hashicorp/azurerm ~> 4.0 |
| Provider block | None in this module — the caller configures provider "azurerm" { features {} }, auth, and subscription. |
Schema notes that bite
nameis globally unique, lowercase, 3–24 chars, and immutable (validated).account_tier,is_hns_enabled,nfsv3_enabledandinfrastructure_encryption_enabledare force-new in the schema — changing any of them replaces the account.account_kindis force-new too, but conditionally and from the diff customisation rather than the schema: it replaces unless the old kind isStorageor the new one isStorageV2.account_replication_typereplaces the account when the change CROSSES the zonal boundary —{LRS, GRS, RAGRS}on one side,{ZRS, GZRS, RAGZRS}on the other. A move within a family is in place; a move across, in either direction, destroys the account and everything in it. This module defaults toZRS, so the ordinary cost-reduction edit (ZRS→LRS) is the destructive one. See thereplication_familyoutput.- With
shared_access_key_enabled = false(the default), the deploying identity must hold a data-plane role (e.g. Storage Blob Data Owner) to manage containers/queues/etc. — a management-plane role alone is not enough. min_tls_versionbelowTLS1_2is rejected on current tiers.- Child objects use
storage_account_id(the v4 form), not the legacystorage_account_name.
- Storage Account Contributor on the target resource group (management plane).
- A data-plane role (e.g. Storage Blob Data Owner for containers/blobs, plus queue/table/file data roles as needed) for the deploying identity, because shared-key auth is disabled by default and the provider manages child objects over the data plane under Entra ID.
- The
Microsoft.Storageresource provider registered on the subscription. - An existing resource group.
- For CMK: an existing Key Vault key and a user-assigned identity granted Key Vault Crypto Service Encryption User.
- For private access: a subnet and a private endpoint +
privatelink.blob.core.windows.net(or the relevant sub-service) DNS zone. - The caller configures the
provider "azurerm" { features {} }block, auth, and subscription; the module declares none of these.
terraform-azurerm-storage-account/
├── providers.tf # required_version + azurerm ~> 4.0; no provider block
├── variables.tf # name, rg, location, tier/kind/replication, secure toggles, network_rules, identity, CMK, blob_properties, children
├── main.tf # azurerm_storage_account.this + for_each containers/queues/tables/shares
├── outputs.tf # id, name, primary_blob_endpoint, primary_dfs_endpoint, child id maps
├── README.md # this document
├── SCOPE.md # cross-module contract
├── LICENSE # MIT
└── .gitignore # canonical Terraform ignore set
provider "azurerm" {
features {}
}
module "sa" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"
name = "stplatprodeus2"
resource_group_name = "rg-data-prod-eastus2"
location = "eastus2"
containers = { data = {} }
}ℹ️ The empty call is private, TLS 1.2, shared-key off, double-encrypted. Pin the module by tag (
?ref=v1.0.0), never a branch.
Consumes
| Input | Type | From |
|---|---|---|
resource_group_name |
string |
terraform-azurerm-resource-group (name) |
location |
string |
caller / resource group (location) |
customer_managed_key.key_vault_key_id |
string |
terraform-azurerm-key-vault key versionless_id |
customer_managed_key.user_assigned_identity_id |
string |
terraform-azurerm-user-assigned-identity (id) |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Account Resource ID | private endpoints, diagnostics, role assignments |
name |
Account name | reference |
location |
Account region | blob backup instances, and any sibling needing the ACCOUNT's region |
primary_blob_endpoint |
Blob endpoint | app configuration |
container_ids / queue_ids / table_ids / share_ids |
child maps | downstream |
1 · Secure baseline
module "sa" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"
name = "stsecurebaseeus2"
resource_group_name = "rg-data-eastus2"
location = "eastus2"
}🔒 Private, TLS 1.2, shared-key off, infrastructure encryption on, HTTPS-only, ZRS — all default.
2 · Containers at scale
containers = {
raw = {}
curated = {}
published = { metadata = { tier = "gold" } }
}3 · Queues and tables
queues = { events = {}, deadletter = {} }
tables = { sessions = {} }4 · File shares
shares = {
profiles = { quota = 100 }
scratch = { quota = 50, access_tier = "TransactionOptimized" }
}5 · Network rules (allow specific subnets)
network_rules = {
virtual_network_subnet_ids = [var.app_subnet_id]
ip_rules = ["203.0.113.0/24"]
}🔒
default_actiondefaults toDeny; only listed networks are allowed.
6 · Customer-managed key encryption
module "sa" {
# ...
identity = { type = "UserAssigned", identity_ids = [var.cmk_identity_id] }
customer_managed_key = {
key_vault_key_id = var.kv_key_versionless_id
user_assigned_identity_id = var.cmk_identity_id
}
}7 · Data Lake Gen2 (HNS)
module "sa" {
# ...
is_hns_enabled = true
containers = { filesystem = {} }
}ℹ️
is_hns_enabledis force-new and enables the DFS endpoint.
8 · Tune blob versioning / soft delete
blob_properties = {
versioning_enabled = true
change_feed_enabled = true
delete_retention_days = 30
container_delete_retention_days = 30
}9 · Geo-redundant replication
module "sa" {
# ...
account_replication_type = "GZRS"
}10 · Premium block blob
module "sa" {
# ...
account_tier = "Premium"
account_kind = "BlockBlobStorage"
account_replication_type = "ZRS"
access_tier = null # REQUIRED: BlockBlobStorage does not accept an access tier,
# and this module defaults access_tier to "Hot" and always sends it.
}11 · Restrict copy scope to Entra
module "sa" {
# ...
allowed_copy_scope = "AAD"
}12 · for_each — an account per zone
module "sa" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"
for_each = toset(["raw", "curated"])
name = "stdata${each.key}eus2"
resource_group_name = "rg-data-eastus2"
location = "eastus2"
}13 · Grant data-plane access via RBAC
module "sa" { # ... }
module "sa_roles" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
scope = module.sa.id
role_assignments = {
blob-owner = {
role_definition_name = "Storage Blob Data Owner"
principal_id = var.app_principal_id
}
}
}🔒 With shared-key auth off, data access is granted by role assignment, not keys.
14 · 🏗️ End-to-end composition
A resource group, a Key Vault CMK key, a user-assigned identity, and a CMK-encrypted storage account with a private endpoint.
provider "azurerm" {
features {}
}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-data-prod-eastus2"
location = "eastus2"
}
module "id" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"
name = "id-sa-cmk"
resource_group_name = module.rg.name
location = module.rg.location
}
module "kv" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
name = "kv-data-prod-eus2"
resource_group_name = module.rg.name
location = module.rg.location
tenant_id = var.tenant_id
keys = { sa-cmk = { key_type = "RSA", key_size = 3072, key_opts = ["wrapKey", "unwrapKey"] } }
}
module "sa" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"
name = "stdataprodeus2"
resource_group_name = module.rg.name
location = module.rg.location
identity = { type = "UserAssigned", identity_ids = [module.id.id] }
customer_managed_key = {
key_vault_key_id = module.kv.key_versionless_ids["sa-cmk"]
user_assigned_identity_id = module.id.id
}
containers = { data = {} }
}💡 The identity holds crypto access on the vault; the account encrypts with the vault key and stays private — the whole chain wires by reference.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string |
✅ | — | Account name (3–24 lowercase). Immutable. |
resource_group_name |
string |
✅ | — | Containing resource group. Immutable. |
location |
string |
✅ | — | Azure region. Immutable. |
account_tier |
string |
— | "Standard" |
Standard / Premium. Force-new. |
account_replication_type |
string |
— | "ZRS" |
LRS…RAGZRS. |
account_kind |
string |
— | "StorageV2" |
Account kind. Conditionally force-new — replaces unless leaving Storage or arriving at StorageV2. |
min_tls_version |
string |
— | "TLS1_2" |
TLS floor. |
public_network_access_enabled |
bool |
— | false |
Public reachability. |
shared_access_key_enabled |
bool |
— | false |
Account-key auth. |
infrastructure_encryption_enabled |
bool |
— | true |
Double encryption. Force-new. |
network_rules |
object |
— | null |
Firewall (default Deny). 🔴 Do not set alongside terraform-azurerm-storage-account-network-rules — the provider states the two "cannot be used together. Spurious changes will occur." |
identity / customer_managed_key |
object |
— | null |
MI / CMK. |
blob_properties |
object |
— | {} |
Versioning + soft delete. |
containers/queues/tables/shares |
map(object) |
— | {} |
Data-plane children. |
tags |
map(string) |
— | {} |
Tags. |
timeouts |
object |
— | null |
Optional timeouts. |
Full variable schemas (selected)
network_rules = object({ default_action = optional("Deny"), bypass = optional(["AzureServices"]),
ip_rules = optional([]), virtual_network_subnet_ids = optional([]) })
customer_managed_key = object({ key_vault_key_id = string, user_assigned_identity_id = string })
blob_properties = object({ versioning_enabled = optional(true), change_feed_enabled = optional(bool),
last_access_time_enabled = optional(bool),
delete_retention_days = optional(7), container_delete_retention_days = optional(7) })
containers = map(object({ name = optional(string), container_access_type = optional("private"), metadata = optional(map) }))
shares = map(object({ name = optional(string), quota = number, access_tier = optional, enabled_protocol = optional, metadata = optional(map) }))| Output | Description | Notes |
|---|---|---|
id |
Account Resource ID | Emitted first. |
name |
Account name | — |
location |
Account region | Normalized by the provider. Pass this to a module that needs the account's region rather than typing one. |
primary_blob_endpoint |
Blob endpoint | — |
primary_dfs_endpoint |
DFS endpoint | For HNS accounts. |
identity_principal_id |
MI principal ID | Null if no identity. |
container_ids/queue_ids/table_ids/share_ids |
Child maps | — |
No access key or connection string is emitted.
- Hardened empty call. Public access off, TLS 1.2, shared-key off, infra encryption on, HTTPS-only, cross-tenant replication off, ZRS — the defaults, each a documented opt-out.
- Entra-first data plane. Because shared-key auth is disabled, the provider manages child objects over the data plane under Entra ID — the deploying identity needs the relevant data role (e.g. Storage Blob Data Owner). This is the single most common apply-time surprise.
- Force-new fields bite, and two of them are invisible in the schema.
account_tier,is_hns_enabled,nfsv3_enabledandinfrastructure_encryption_enabledare ordinary schema force-new fields.account_kindandaccount_replication_typeare reached from the provider's diff customisation instead, so they appear in no schema walk:account_kindreplaces unless the old kind isStorageor the new one isStorageV2, andaccount_replication_typereplaces when the change crosses the zonal boundary. Both destroy every blob, queue, table and share. - Children keyed, never
count. Containers/queues/tables/shares usefor_eachover stable keys and reference the account byid. - No secrets emitted. Access keys and connection strings are never output; prefer Entra ID access.
features {}dependence. Noprovider {}block here; the caller configuresprovider "azurerm" { features {} }.
| Concern | Secure default (empty call) | Opt-out |
|---|---|---|
| Public network access | false |
set true |
| Anonymous blob access | allow_nested_items_to_be_public = false |
set true |
| TLS floor | TLS1_2 |
lower (often rejected) |
| Shared-key auth | false (Entra ID) |
set true |
| Infrastructure encryption | true |
set false (force-new) |
| HTTPS-only | true |
set false |
| Cross-tenant replication | false |
set true |
| Network default action | Deny (when network_rules set) |
Allow |
| Blob versioning / soft delete | on, 7 days | tune or disable in blob_properties |
cd terraform-azurerm-storage-account
terraform init -backend=false
terraform validate
terraform fmt -check
Remove-Item -Recurse -Force .terraform -ErrorAction SilentlyContinuePin the module by tag (
?ref=v1.0.0), never a branch. Plan-only during authoring; a human runsplan/applyfrom CI.
The offline proof gate — terraform init -backend=false, terraform validate, terraform fmt -check — proves the configuration is type-correct against the pinned azurerm ~> 4.0 schema (including the name, tier, kind, replication, TLS, and container-access validations) and canonically formatted, with no cloud calls. What it does not exercise: global name uniqueness, whether the deploying identity holds the data-plane role, and CMK key access — those surface only under terraform plan/apply against real credentials from CI.
$ terraform output
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data-prod-eastus2/providers/Microsoft.Storage/storageAccounts/stdataprodeus2"
name = "stdataprodeus2"
primary_blob_endpoint = "https://stdataprodeus2.blob.core.windows.net/"
container_ids = {
"data" = ".../storageAccounts/stdataprodeus2/blobServices/default/containers/data"
}| Symptom | Cause | Fix |
|---|---|---|
| 403 creating containers | Shared-key off + identity lacks data-plane role | Grant Storage Blob Data Owner (etc.) at the account. |
| Account name rejected | Not globally unique / not 3–24 lowercase | Choose a unique, valid name. |
| Plan wants to replace the account | Changed a force-new field (tier/kind/HNS/infra-encryption) | Expected; plan the rebuild. |
| Public container rejected | allow_nested_items_to_be_public = false |
Keep containers private, or opt in at the account. |
| CMK apply fails | Identity lacks crypto access on the vault key | Grant Key Vault Crypto Service Encryption User. |
| Cannot reach account | public_network_access_enabled = false, no private endpoint |
Add a private endpoint, or open public access. |
- Provider resources:
azurerm_storage_account,azurerm_storage_container,azurerm_storage_queue,azurerm_storage_table,azurerm_storage_share - Sibling modules:
terraform-azurerm-key-vault,terraform-azurerm-user-assigned-identity,terraform-azurerm-private-endpoint,terraform-azurerm-role-assignments,terraform-azurerm-resource-group - This module's cross-module contract:
SCOPE.md
💙 "Infrastructure as Code should be standardized, consistent, and secure."