Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Azure Storage Account Terraform Module

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.

Terraform azurerm module type resources

🧩 Overview

  • 📦 Creates one azurerm_storage_account hardened 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), managed identity, customer_managed_key, and blob_properties with 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.

❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

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!

🗺️ Where this fits in the family

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;
Loading

🧬 What this module builds

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;
Loading

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.

✅ Provider / Versions

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

  • name is globally unique, lowercase, 3–24 chars, and immutable (validated).
  • account_tier, is_hns_enabled, nfsv3_enabled and infrastructure_encryption_enabled are force-new in the schema — changing any of them replaces the account. account_kind is force-new too, but conditionally and from the diff customisation rather than the schema: it replaces unless the old kind is Storage or the new one is StorageV2.
  • account_replication_type replaces 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 to ZRS, so the ordinary cost-reduction edit (ZRSLRS) is the destructive one. See the replication_family output.
  • 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_version below TLS1_2 is rejected on current tiers.
  • Child objects use storage_account_id (the v4 form), not the legacy storage_account_name.

🔑 Required Azure RBAC Roles / Permissions

  • 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.

Azure Prerequisites

  • The Microsoft.Storage resource 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.

📁 Module Structure

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

⚙️ Quick Start

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.

🔌 Cross-Module Contract

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

📚 Example Library

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_action defaults to Deny; 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_enabled is 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.

📥 Inputs

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) }))

🧾 Outputs

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.

🧠 Architecture Notes

  • 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_enabled and infrastructure_encryption_enabled are ordinary schema force-new fields. account_kind and account_replication_type are reached from the provider's diff customisation instead, so they appear in no schema walk: account_kind replaces unless the old kind is Storage or the new one is StorageV2, and account_replication_type replaces when the change crosses the zonal boundary. Both destroy every blob, queue, table and share.
  • Children keyed, never count. Containers/queues/tables/shares use for_each over stable keys and reference the account by id.
  • No secrets emitted. Access keys and connection strings are never output; prefer Entra ID access.
  • features {} dependence. No provider {} block here; the caller configures provider "azurerm" { features {} }.

🧱 Design Principles

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

🚀 Runbook

cd terraform-azurerm-storage-account
terraform init -backend=false
terraform validate
terraform fmt -check
Remove-Item -Recurse -Force .terraform -ErrorAction SilentlyContinue

Pin the module by tag (?ref=v1.0.0), never a branch. Plan-only during authoring; a human runs plan/apply from CI.

🧪 Testing

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.

💬 Example Output

$ 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"
}

🔍 Troubleshooting

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.

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."