Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for Azure Storage Account: TLS 1.2 enforced, public access disabled, blob versioning, lifecycle rules, network ACLs, and managed-identity outputs. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "azurerm" {
features {}
}
module "storage_account" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-storage-account?ref=v1.0.0"
name = "..." # Storage account name; 3-24 lowercase alphanumeric chars…
resource_group_name = "..." # Resource group to create the account in.
location = "..." # Azure region (e.g. `centralindia`).
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
An Azure Storage Account (azurerm_storage_account) is the foundational namespace for Blob, File, Queue, Table, and Azure Data Lake Storage Gen2 services. It is also one of the easiest resources in Azure to misconfigure: ship one with the wrong account_tier, public blob access left on, or TLS 1.0 still accepted, and you have an audit finding, a data-exfiltration vector, or a surprise on the bill — all from a single block of HCL that “worked.”
This module wraps azurerm_storage_account behind a small, opinionated, var-driven interface so every account your platform provisions is secure by default: HTTPS-only traffic, min_tls_version = "TLS1_2", public blob access disabled, and shared-key/Entra-ID behaviour you set deliberately rather than by accident. On top of the account it wires the two sub-resources almost every production account needs — a azurerm_storage_account_network_rules deny-by-default firewall and an azurerm_storage_management_policy for blob lifecycle tiering/expiry — plus optional blob versioning, soft delete, and change feed via the blob_properties block. The result is that a team consumes a few well-named inputs instead of remembering thirty security-relevant arguments.
When to use it
- You provision more than one or two storage accounts and want a single, reviewed definition of “what good looks like” (encryption in transit, no anonymous access, deny-by-default networking).
- You need ADLS Gen2 (hierarchical namespace) for analytics/lakehouse workloads and want
is_hns_enabledgated behind a flag so nobody flips it on a general-purpose account by mistake. - You want blob lifecycle management (hot → cool → archive → delete) expressed as data and applied uniformly, instead of hand-clicking rules in the portal.
- You are building a landing zone or service catalog where application teams request storage through a module call and a PR, not a ticket.
Reach for the raw resource instead only for a genuine one-off (a throwaway lab account) where the module’s guardrails get in your way.
Module structure
terraform-module-azure-storage-account/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
resource "azurerm_storage_account" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
account_kind = var.account_kind
account_tier = var.account_tier
account_replication_type = var.account_replication_type
access_tier = var.access_tier
# Security baseline
https_traffic_only_enabled = true
min_tls_version = "TLS1_2"
allow_nested_items_to_be_public = false
shared_access_key_enabled = var.shared_access_key_enabled
public_network_access_enabled = var.public_network_access_enabled
default_to_oauth_authentication = true
# ADLS Gen2 hierarchical namespace (analytics workloads)
is_hns_enabled = var.is_hns_enabled
# Infrastructure encryption (double encryption at rest)
infrastructure_encryption_enabled = var.infrastructure_encryption_enabled
blob_properties {
versioning_enabled = var.blob_versioning_enabled
change_feed_enabled = var.blob_change_feed_enabled
last_access_time_enabled = var.blob_last_access_time_enabled
delete_retention_policy {
days = var.blob_soft_delete_retention_days
}
container_delete_retention_policy {
days = var.container_soft_delete_retention_days
}
}
identity {
type = "SystemAssigned"
}
tags = var.tags
}
# Deny-by-default firewall. Applied only when explicit allow-lists are supplied
# so the account does not silently lock itself out in environments that rely on
# private endpoints or service-level access.
resource "azurerm_storage_account_network_rules" "this" {
count = var.network_rules_enabled ? 1 : 0
storage_account_id = azurerm_storage_account.this.id
default_action = "Deny"
bypass = var.network_bypass
ip_rules = var.allowed_ip_rules
virtual_network_subnet_ids = var.allowed_subnet_ids
}
# Blob lifecycle management: tier down and expire data on a schedule.
resource "azurerm_storage_management_policy" "this" {
count = length(var.lifecycle_rules) > 0 ? 1 : 0
storage_account_id = azurerm_storage_account.this.id
dynamic "rule" {
for_each = var.lifecycle_rules
content {
name = rule.value.name
enabled = rule.value.enabled
filters {
prefix_match = rule.value.prefix_match
blob_types = rule.value.blob_types
}
actions {
base_blob {
tier_to_cool_after_days_since_modification_greater_than = rule.value.tier_to_cool_after_days
tier_to_archive_after_days_since_modification_greater_than = rule.value.tier_to_archive_after_days
delete_after_days_since_modification_greater_than = rule.value.delete_after_days
}
snapshot {
delete_after_days_since_creation_greater_than = rule.value.snapshot_delete_after_days
}
}
}
}
}
variables.tf
variable "name" {
description = "Name of the storage account. Must be 3-24 chars, lowercase letters and numbers only, globally unique."
type = string
validation {
condition = can(regex("^[a-z0-9]{3,24}$", var.name))
error_message = "name must be 3-24 characters, lowercase letters and numbers only (no hyphens or uppercase)."
}
}
variable "resource_group_name" {
description = "Name of the resource group in which to create the storage account."
type = string
}
variable "location" {
description = "Azure region for the storage account (e.g. centralindia, eastus)."
type = string
}
variable "account_kind" {
description = "Kind of storage account. StorageV2 is recommended for general-purpose v2 and ADLS Gen2."
type = string
default = "StorageV2"
validation {
condition = contains(["StorageV2", "BlobStorage", "BlockBlobStorage", "FileStorage", "Storage"], var.account_kind)
error_message = "account_kind must be one of StorageV2, BlobStorage, BlockBlobStorage, FileStorage, Storage."
}
}
variable "account_tier" {
description = "Performance tier. Standard for most workloads, Premium for low-latency block blob/file."
type = string
default = "Standard"
validation {
condition = contains(["Standard", "Premium"], var.account_tier)
error_message = "account_tier must be either Standard or Premium."
}
}
variable "account_replication_type" {
description = "Replication strategy: LRS, ZRS, GRS, RAGRS, GZRS, or RAGZRS."
type = string
default = "ZRS"
validation {
condition = contains(["LRS", "ZRS", "GRS", "RAGRS", "GZRS", "RAGZRS"], var.account_replication_type)
error_message = "account_replication_type must be one of LRS, ZRS, GRS, RAGRS, GZRS, RAGZRS."
}
}
variable "access_tier" {
description = "Default access tier for blobs: Hot or Cool. Ignored for Premium accounts."
type = string
default = "Hot"
validation {
condition = contains(["Hot", "Cool"], var.access_tier)
error_message = "access_tier must be either Hot or Cool."
}
}
variable "shared_access_key_enabled" {
description = "Allow access via storage account access keys. Set false to force Entra ID (Azure AD) auth only."
type = bool
default = true
}
variable "public_network_access_enabled" {
description = "Allow access from the public network. Set false when using private endpoints exclusively."
type = bool
default = true
}
variable "is_hns_enabled" {
description = "Enable hierarchical namespace (ADLS Gen2). Cannot be changed after creation."
type = bool
default = false
}
variable "infrastructure_encryption_enabled" {
description = "Enable infrastructure (double) encryption at rest. Cannot be changed after creation."
type = bool
default = false
}
variable "blob_versioning_enabled" {
description = "Enable blob versioning to retain previous versions of blobs."
type = bool
default = true
}
variable "blob_change_feed_enabled" {
description = "Enable the blob change feed (an ordered log of create/update/delete events)."
type = bool
default = false
}
variable "blob_last_access_time_enabled" {
description = "Track last-access time so lifecycle rules can tier on access patterns."
type = bool
default = false
}
variable "blob_soft_delete_retention_days" {
description = "Days to retain soft-deleted blobs (1-365)."
type = number
default = 7
validation {
condition = var.blob_soft_delete_retention_days >= 1 && var.blob_soft_delete_retention_days <= 365
error_message = "blob_soft_delete_retention_days must be between 1 and 365."
}
}
variable "container_soft_delete_retention_days" {
description = "Days to retain soft-deleted containers (1-365)."
type = number
default = 7
validation {
condition = var.container_soft_delete_retention_days >= 1 && var.container_soft_delete_retention_days <= 365
error_message = "container_soft_delete_retention_days must be between 1 and 365."
}
}
variable "network_rules_enabled" {
description = "Apply a deny-by-default network firewall using the allow-lists below."
type = bool
default = false
}
variable "network_bypass" {
description = "Traffic to bypass network rules. Subset of: AzureServices, Logging, Metrics, None."
type = set(string)
default = ["AzureServices"]
}
variable "allowed_ip_rules" {
description = "Public IPv4 addresses or CIDR ranges allowed through the firewall. RFC1918 ranges are not permitted by Azure."
type = list(string)
default = []
}
variable "allowed_subnet_ids" {
description = "Resource IDs of VNet subnets (with the Storage service endpoint) allowed through the firewall."
type = list(string)
default = []
}
variable "lifecycle_rules" {
description = "Blob lifecycle management rules for tiering and expiry."
type = list(object({
name = string
enabled = optional(bool, true)
prefix_match = optional(list(string), [])
blob_types = optional(list(string), ["blockBlob"])
tier_to_cool_after_days = optional(number)
tier_to_archive_after_days = optional(number)
delete_after_days = optional(number)
snapshot_delete_after_days = optional(number)
}))
default = []
}
variable "tags" {
description = "Tags to apply to the storage account."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "The resource ID of the storage account."
value = azurerm_storage_account.this.id
}
output "name" {
description = "The name of the storage account."
value = azurerm_storage_account.this.name
}
output "primary_blob_endpoint" {
description = "The endpoint URL for blob storage in the primary location."
value = azurerm_storage_account.this.primary_blob_endpoint
}
output "primary_dfs_endpoint" {
description = "The endpoint URL for ADLS Gen2 (DFS) in the primary location."
value = azurerm_storage_account.this.primary_dfs_endpoint
}
output "primary_access_key" {
description = "The primary access key. Empty when shared_access_key_enabled is false."
value = azurerm_storage_account.this.primary_access_key
sensitive = true
}
output "primary_connection_string" {
description = "The primary connection string for the storage account."
value = azurerm_storage_account.this.primary_connection_string
sensitive = true
}
output "identity_principal_id" {
description = "The principal ID of the system-assigned managed identity (use for RBAC grants)."
value = azurerm_storage_account.this.identity[0].principal_id
}
How to use it
module "storage_account" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-storage-account?ref=v1.0.0"
name = "stkvprodlogs01"
resource_group_name = azurerm_resource_group.platform.name
location = azurerm_resource_group.platform.location
account_replication_type = "GZRS"
access_tier = "Hot"
# Lock it down: Entra ID auth only, private network only
shared_access_key_enabled = false
public_network_access_enabled = false
blob_versioning_enabled = true
blob_last_access_time_enabled = true
blob_soft_delete_retention_days = 30
network_rules_enabled = true
network_bypass = ["AzureServices", "Metrics"]
allowed_subnet_ids = [azurerm_subnet.app.id]
lifecycle_rules = [
{
name = "archive-and-expire-logs"
prefix_match = ["logs/"]
tier_to_cool_after_days = 30
tier_to_archive_after_days = 90
delete_after_days = 365
}
]
tags = {
environment = "production"
owner = "platform-team"
costcenter = "cc-1042"
}
}
# Downstream: grant a function app's identity write access to blobs using the
# storage account ID exported by the module.
resource "azurerm_role_assignment" "func_blob_writer" {
scope = module.storage_account.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azurerm_linux_function_app.ingest.identity[0].principal_id
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "azurerm"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...azurerm state bucket/container + key per path...
}
}
2. Module config — live/prod/storage_account/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-storage-account?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/storage_account && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Storage account name; 3-24 lowercase alphanumeric chars, globally unique. |
resource_group_name |
string |
— | Yes | Resource group to create the account in. |
location |
string |
— | Yes | Azure region (e.g. centralindia). |
account_kind |
string |
"StorageV2" |
No | Account kind (StorageV2, BlobStorage, BlockBlobStorage, FileStorage, Storage). |
account_tier |
string |
"Standard" |
No | Performance tier: Standard or Premium. |
account_replication_type |
string |
"ZRS" |
No | Replication: LRS, ZRS, GRS, RAGRS, GZRS, RAGZRS. |
access_tier |
string |
"Hot" |
No | Default blob access tier: Hot or Cool. |
shared_access_key_enabled |
bool |
true |
No | Allow access-key auth; set false to force Entra ID only. |
public_network_access_enabled |
bool |
true |
No | Allow public network access; false for private-endpoint-only. |
is_hns_enabled |
bool |
false |
No | Enable ADLS Gen2 hierarchical namespace (immutable). |
infrastructure_encryption_enabled |
bool |
false |
No | Enable double encryption at rest (immutable). |
blob_versioning_enabled |
bool |
true |
No | Retain previous blob versions. |
blob_change_feed_enabled |
bool |
false |
No | Enable the blob change feed event log. |
blob_last_access_time_enabled |
bool |
false |
No | Track last-access time for access-based lifecycle rules. |
blob_soft_delete_retention_days |
number |
7 |
No | Soft-delete retention for blobs (1-365). |
container_soft_delete_retention_days |
number |
7 |
No | Soft-delete retention for containers (1-365). |
network_rules_enabled |
bool |
false |
No | Apply a deny-by-default network firewall. |
network_bypass |
set(string) |
["AzureServices"] |
No | Traffic allowed to bypass network rules. |
allowed_ip_rules |
list(string) |
[] |
No | Public IPs/CIDRs allowed through the firewall. |
allowed_subnet_ids |
list(string) |
[] |
No | VNet subnet IDs allowed through the firewall. |
lifecycle_rules |
list(object) |
[] |
No | Blob lifecycle tiering/expiry rules. |
tags |
map(string) |
{} |
No | Tags applied to the storage account. |
Outputs
| Name | Description |
|---|---|
id |
The resource ID of the storage account. |
name |
The name of the storage account. |
primary_blob_endpoint |
Primary blob service endpoint URL. |
primary_dfs_endpoint |
Primary ADLS Gen2 (DFS) endpoint URL. |
primary_access_key |
Primary access key (sensitive; empty when shared keys are disabled). |
primary_connection_string |
Primary connection string (sensitive). |
identity_principal_id |
Principal ID of the system-assigned managed identity, for RBAC grants. |
Enterprise scenario
A retail analytics platform standardises every team’s landing-zone storage on this module. Ingestion accounts are created with is_hns_enabled = true for ADLS Gen2, shared_access_key_enabled = false so data engineers authenticate with their Entra ID identities through RBAC, and network_rules_enabled = true restricting traffic to the Databricks and Synapse subnets. A single lifecycle_rules block tiers raw event blobs from Hot to Cool at 30 days and Archive at 120 days, trimming the petabyte-scale storage bill by roughly 40% without anyone touching the portal — and because it is one reviewed module, a security finding (say, mandating TLS 1.2 or double encryption) is remediated fleet-wide with a single version bump.
Best practices
- Force Entra ID auth where you can. Set
shared_access_key_enabled = falseand grant access via RBAC roles (Storage Blob Data Contributor, etc.) against the module’sidoutput. Static account keys are long-lived, hard to rotate, and a common credential-leak source. - Default to private networking. Pair
public_network_access_enabled = falsewith private endpoints, or usenetwork_rules_enabled = truewith explicit subnet allow-lists. Never leave a production account open to the public internet with default-allow. - Pick replication for the blast radius, not habit.
ZRS/GZRSsurvive a zone failure within a region; useGZRS/RAGZRSfor cross-region durability of critical data and plainLRSonly for reproducible or transient data to save cost. - Let lifecycle rules manage spend. Tier cold data to Cool/Archive and expire what you do not need; enable
blob_last_access_time_enabledwhen you want access-based rather than age-based tiering. This is usually the single biggest storage cost lever. - Enable versioning and soft delete for recoverability. Keep
blob_versioning_enabled = trueand a sensibleblob_soft_delete_retention_daysso an accidental overwrite or delete is recoverable; treat these as cheap insurance, not optional extras. - Name accounts predictably. The
^[a-z0-9]{3,24}$validation enforces Azure’s constraints early; adopt a convention such asst<workload><env><nn>(e.g.stkvprodlogs01) so accounts are self-describing and globally unique on the first apply.