IaC Azure

Terraform Module: Azure Storage Account — Secure-by-default blob, file, and data lake storage

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

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 configlive/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 configlive/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

TerraformAzureStorage AccountModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading