A standalone module that provisions a single Azure blob storage container, private by default, inside a storage account you already own. Targets
hashicorp/azurerm ~> 4.0.
- π¦ Creates one
azurerm_storage_container(namedthis) inside a storage account supplied bystorage_account_id. - π Defaults the container to
privateβ no anonymous read access to blobs or the container listing. - π·οΈ Accepts optional container
metadata(key/value pairs), updatable in place. - π Exposes an optional
default_encryption_scopeand itsencryption_scope_override_enabledguard for scope-narrowed encryption at rest. - β±οΈ Carries the universal
timeoutstail; it does not carrytags(the resource type has no tag support).
π‘ Why it matters: A container is the data-plane boundary where blob access and encryption scope are decided. Getting the default wrong exposes data to the public internet. This module makes the private, non-anonymous container the zero-configuration outcome and forces every relaxation to be typed explicitly and reviewed.
If this module saves you time, please consider supporting it:
- β Star the repository
- π€ Connect on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
graph LR
rg["terraform-azurerm-resource-group"]
sa["terraform-azurerm-storage-account"]
this["terraform-azurerm-storage-container"]
ra["terraform-azurerm-role-assignments"]
pe["terraform-azurerm-private-endpoint"]
rg -->|"resource_group_name"| sa
sa -->|"storage_account_id"| this
this -->|"scope for RBAC"| ra
sa -->|"private connectivity"| pe
classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
classDef keystone fill:#004578,stroke:#002E4D,color:#ffffff;
classDef sib fill:#F0F4F8,stroke:#B0BEC5,color:#1A1A1A;
class this me;
class sa keystone;
class rg,ra,pe sib;
This module owns only the container. The parent storage account (its keystone) is created by a sibling module and passed in by id; RBAC data-plane grants and private connectivity are the concern of their own sibling modules and consume this container by id.
graph LR
subgraph inputs["Inputs"]
name["name"]
said["storage_account_id"]
cat["container_access_type = private"]
md["metadata"]
des["default_encryption_scope"]
eso["encryption_scope_override_enabled"]
end
this["azurerm_storage_container.this"]
subgraph outputs["Outputs"]
oid["id"]
onm["name"]
ohip["has_immutability_policy"]
ohlh["has_legal_hold"]
end
name -->|"required"| this
said -->|"required"| this
cat -->|"private by default"| this
md -->|"optional"| this
des -->|"optional"| this
eso -->|"optional"| this
this -->|"emits"| oid
this -->|"emits"| onm
this -->|"emits"| ohip
this -->|"emits"| ohlh
classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
class this me;
Resource inventory
| Resource | Name | Count | Role |
|---|---|---|---|
azurerm_storage_container |
this |
1 | The blob container; the module's single keystone resource. |
| Requirement | Value |
|---|---|
| Terraform floor | >= 1.12.0 |
| Provider | hashicorp/azurerm, pinned ~> 4.0 |
| Provider block | None in this module β the caller configures provider "azurerm", including the mandatory features {} block, auth, and subscription. |
Schema notes that bite (verified against the live provider schema):
nameandstorage_account_idare force-new β changing either replaces the container and destroys its contents.default_encryption_scopeandencryption_scope_override_enabledare force-new β set them at creation; changing them later replaces the container.- The legacy account-name argument is deprecated in the pinned provider line (slated for removal in the next major line). This module exposes only
storage_account_id; supply the account's Resource ID, not its name. - The container's
idnow carries the Resource Manager ID. The separate resource-manager-ID attribute is deprecated in the pinned provider line, so this module emits the ARM identifier throughidand does not surface the deprecated attribute. container_access_typevaluesblobandcontaineronly take effect when the parent account permits public blob access (allow_nested_items_to_be_public = true), which a hardened account disables by default.metadatais computed as well as optional; supplying an empty map (the default) means "no user metadata" and avoids perpetual diffs.
- Management plane (create/update/delete the container definition):
Storage Account Contributoron the target storage account, or a custom role grantingMicrosoft.Storage/storageAccounts/blobServices/containers/*at that scope. - Data plane (read/write blobs inside the container): assign a Storage Blob Data role to the consuming identity at the account or container scope, least-privilege first:
Storage Blob Data Readerβ read/list blobs.Storage Blob Data Contributorβ read/write/delete blobs.Storage Blob Data Ownerβ full control including POSIX ACLs on Data Lake Gen2.
These two planes are independent: holding a management role does not grant blob data access, and vice versa.
- The
Microsoft.Storageresource provider registered on the target subscription. - The parent storage account already exists (its
idis an input); this module does not create it. - For
blob/containeraccess types, the parent account must permit public blob access (allow_nested_items_to_be_public = true). - Any named encryption scope referenced by
default_encryption_scopemust already exist on the parent account. - The caller configures
provider "azurerm" { features {} }, auth, and subscription.
terraform-azurerm-storage-container/
βββ providers.tf # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
βββ variables.tf # deeply-typed inputs; container_access_type enum; timeouts tail (no tags)
βββ main.tf # azurerm_storage_container.this; dynamic timeouts; try() on optionals
βββ outputs.tf # id first, then name, has_immutability_policy, has_legal_hold
βββ README.md # this document
βββ SCOPE.md # the cross-module contract
βββ LICENSE # MIT, Copyright (c) 2026 Casey Wood
βββ .gitignore # canonical library ignore set
provider "azurerm" {
features {}
}
module "app_data" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-container.git?ref=v1.0.0"
name = "app-data"
storage_account_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-platform/providers/Microsoft.Storage/storageAccounts/stplatformdata01"
# container_access_type defaults to "private" β no anonymous access.
}βΉοΈ The caller owns provider configuration: authentication, subscription, and the mandatory
features {}block. The module declares noprovider {}block and no credential/subscription variables. Always pin?ref=v1.0.0β never a branch.
Consumes
| Input | Type | Source module |
|---|---|---|
storage_account_id |
string |
terraform-azurerm-storage-account (id) |
Emits
| Output | Description |
|---|---|
id |
The Azure Resource Manager ID of the container (emitted first). |
name |
The container name. |
has_immutability_policy |
Whether an immutability policy is configured β not whether it is locked. |
has_legal_hold |
Whether a legal hold is configured. |
1 Β· Minimal private container (secure default)
module "logs" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-container.git?ref=v1.0.0"
name = "app-logs"
storage_account_id = var.storage_account_id
}π The empty call yields a
privatecontainer: no anonymous read access to blobs or the container listing.
2 Β· Container with metadata
module "invoices" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-container.git?ref=v1.0.0"
name = "invoices"
storage_account_id = var.storage_account_id
metadata = {
owner = "finance-platform"
dataclass = "internal"
retention = "7y"
}
}π‘ Metadata keys are validated by the provider as
^([a-z_]{1}[a-z0-9_]{1,})$β lower-case only, at least two characters, and C# keywords are rejected. The provider does not fold case for you:Owneris rejected at plan rather than stored asowner. Metadata is updatable in place, so editing it does not replace the container.
3 Β· Anonymous blob-level public read
module "public_assets" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-container.git?ref=v1.0.0"
name = "public-assets"
storage_account_id = var.storage_account_id
container_access_type = "blob"
}
β οΈ blobgrants anonymous read access to individual blobs (not the container listing). It only takes effect if the parent account setsallow_nested_items_to_be_public = true, which a hardened account disables by default.
4 Β· Anonymous container-level public read
module "public_catalog" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-container.git?ref=v1.0.0"
name = "public-catalog"
storage_account_id = var.storage_account_id
container_access_type = "container"
}
β οΈ containeradditionally exposes the blob listing anonymously. Use it only for genuinely public catalogs, and confirm the account permits public blob access.
5 Β· Default encryption scope
module "scoped_data" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-container.git?ref=v1.0.0"
name = "scoped-data"
storage_account_id = var.storage_account_id
default_encryption_scope = "cmkfinancescope"
}π Data at rest is always encrypted regardless of this setting; a default encryption scope narrows the key used for this container's blobs to a scope defined on the parent account. This field is force-new.
6 Β· Encryption scope allowing per-blob overrides
module "mixed_scope_data" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-container.git?ref=v1.0.0"
name = "mixed-scope-data"
storage_account_id = var.storage_account_id
default_encryption_scope = "cmkfinancescope"
encryption_scope_override_enabled = true
}π΄ Read the direction of this flag carefully β it is the opposite of what the name suggests.
encryption_scope_override_enabled = truepermits a blob to be uploaded under a different encryption scope than the container's default. It maps to the API'sDenyEncryptionScopeOverride = false, and the provider's own source comments that defaulting it tofalse"would be preferable here, but the API defaults this to true when unspecified". Sotrueis the PERMISSIVE value andfalseis the one that pins every blob to the container's scope. This example setstruedeliberately, to show the mixed-scope case. The flag is meaningful only alongsidedefault_encryption_scope, and both are force-new.
7 Β· Custom operation timeouts
module "slow_container" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-container.git?ref=v1.0.0"
name = "bulk-ingest"
storage_account_id = var.storage_account_id
timeouts = {
create = "30m"
delete = "30m"
}
}π‘ All four timeouts (
create,read,update,delete) are optional Go duration strings; omit the ones you do not need.
8 Β· Static-website content container ($web)
module "web_root" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-container.git?ref=v1.0.0"
name = "$web"
storage_account_id = var.storage_account_id
container_access_type = "private"
}π The reserved
$webcontainer backs the storage account's static-website feature.privateis still the right value here, but be clear about what it does and does not do. Microsoft: "Disallowing anonymous access for a storage account does not affect any static websites hosted in that storage account. The $web container is always publicly accessible." Changing the container's access level alters anonymous access to the blob endpoint (.blob.core.windows.net/$web/...) and has no effect on the static-website endpoint (.z##.web.core.windows.net), which serves every file by anonymous read whatever this setting says. Soprivatecloses the blob path and nothing more β treat everything you put in$webas public, and put access control in front of the website endpoint if you need it.
9 Β· The reserved root container (`$root`)
module "storage_root" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-container.git?ref=v1.0.0"
name = "$root"
storage_account_id = var.storage_account_id
}π
$rootlets a blob be addressed directly under the account host, with no container segment in the URL β useful for a file that has to sit at the top of the namespace, such ascrossdomain.xml. It staysprivatehere; nothing about the reserved name relaxes access.
β οΈ $rootand$webare the ONLY reserved names this resource accepts. The provider's validator is^\$root$|^\$web$|^[0-9a-z-]+$, so$logsis rejected at plan β the leading$fails the general pattern and there is no$logsalternative. That is not a gap in the module: classic storage-analytics logging is enabled on the storage account, and the service creates and owns$logsitself. There is nothing here to declare.
10 Β· Many containers with for_each at scale
locals {
containers = ["raw", "curated", "sandbox", "archive"]
}
module "lake_zones" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-container.git?ref=v1.0.0"
for_each = toset(local.containers)
name = each.value
storage_account_id = var.storage_account_id
}π‘ Keying
for_eachon a stable set means adding or removing one zone never re-creates the others.
11 Β· Per-container access type and metadata via a map
locals {
containers = {
raw = {
access = "private"
metadata = { zone = "raw" }
}
published = {
access = "blob"
metadata = { zone = "published" }
}
}
}
module "zoned" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-container.git?ref=v1.0.0"
for_each = local.containers
name = each.key
storage_account_id = var.storage_account_id
container_access_type = each.value.access
metadata = each.value.metadata
}
β οΈ Thepublishedzone usesblobaccess β it exposes blobs anonymously only if the parent account permits public blob access.
12 Β· Data Lake Gen2 medallion layout
module "medallion" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-container.git?ref=v1.0.0"
for_each = toset(["bronze", "silver", "gold"])
name = each.value
storage_account_id = var.storage_account_id
metadata = {
layer = each.value
}
}βΉοΈ On a hierarchical-namespace (Data Lake Gen2) account, containers are filesystems; the same resource models both, and data-plane access is still governed by Storage Blob Data roles.
13 Β· Wiring a sibling storage-account output
module "storage" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"
name = "stplatformdata01"
resource_group_name = var.resource_group_name
location = var.location
}
module "app_data" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-container.git?ref=v1.0.0"
name = "app-data"
storage_account_id = module.storage.id
}π‘ Referencing
module.storage.idmakes Terraform create the account before the container without anydepends_on.
14 Β· Granting data-plane access on the created container
module "app_data" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-container.git?ref=v1.0.0"
name = "app-data"
storage_account_id = module.storage.id
}
module "blob_access" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
scope = module.app_data.id
role_assignments = {
app_writer = {
scope = module.app_data.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = var.app_identity_principal_id
}
}
}π Scope the blob data role to the container
id, not the whole account, when only one container needs access β least privilege at the smallest scope.
15 Β· ποΈ End-to-end composition
provider "azurerm" {
features {}
}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-platform-data"
location = "eastus2"
}
module "storage" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"
name = "stplatformdata01"
resource_group_name = module.rg.name
location = module.rg.location
# secure defaults from the storage-account module: public access off,
# shared-key auth off, TLS1_2 floor, infra encryption on.
}
module "app_data" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-container.git?ref=v1.0.0"
name = "app-data"
storage_account_id = module.storage.id
metadata = {
owner = "platform-team"
}
}
module "blob_access" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
scope = module.app_data.id
role_assignments = {
app_writer = {
scope = module.app_data.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = var.app_identity_principal_id
}
}
}
output "container_id" {
value = module.app_data.id
}π‘ The chain wires resource group β storage account β container β data-plane role assignment entirely through module outputs. Ordering is implicit: each module references the previous one's
idorname, so nodepends_onis required.
Identity (required)
| Name | Type | Description |
|---|---|---|
name |
string |
Container name. The provider's validator is `^$root$ |
storage_account_id |
string |
Resource ID of the owning storage account. Force-new. |
Access, metadata, and encryption (optional)
| Name | Type | Default | Description |
|---|---|---|---|
container_access_type |
string |
"private" |
Anonymous access level: private, blob, or container. |
metadata |
map(string) |
{} |
Key/value metadata; updatable in place. |
default_encryption_scope |
string |
null |
Named account encryption scope applied to blobs. Force-new. |
encryption_scope_override_enabled |
bool |
null |
PERMIT a blob to use a different scope than the container default β true allows the override, false pins every blob to the container's scope. The provider default is true (permissive); this module leaves it unset and renders it only when default_encryption_scope is also set, which is what the provider's RequiredWith demands. Force-new. |
Universal tail
| Name | Type | Default | Description |
|---|---|---|---|
timeouts |
object({ create, read, update, delete }) |
null |
Optional per-operation timeouts (Go duration strings). |
βΉοΈ This resource type does not support Azure resource tags, so the module intentionally omits a
tagsvariable.
Full input schemas
variable "name" {
type = string
}
variable "storage_account_id" {
type = string
}
variable "container_access_type" {
type = string
default = "private"
validation {
condition = contains(["private", "blob", "container"], var.container_access_type)
error_message = "container_access_type must be one of: private, blob, container."
}
}
variable "metadata" {
type = map(string)
default = {}
}
variable "default_encryption_scope" {
type = string
default = null
}
variable "encryption_scope_override_enabled" {
type = bool
default = null
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
update = optional(string)
delete = optional(string)
})
default = null
}| Output | Description | Kind |
|---|---|---|
id |
The Azure Resource Manager ID of the blob container | Passthrough |
name |
The name of the blob container | Passthrough |
storage_account_id |
The Azure Resource Manager ID of the storage account that owns this container, as read back by the provider | Passthrough |
url |
The container's DATA-PLANE URL, in the form https://ACCOUNT.blob.core.windows.net/CONTAINER | Passthrough |
resource_type |
The Azure Resource Manager resource type of this record, for composing role definitions, policy assignments and diagnostic settings without parsing the Resource ID | Derived |
container_access_type |
The effective anonymous-access level on the container as read back by the provider: private, blob or container | Passthrough |
anonymous_read_permitted |
True when the container itself grants anonymous read access to its blobs (the access level is blob or container) | Passthrough |
anonymous_listing_permitted |
True only when the access level is container, which lets an unauthenticated caller ENUMERATE every blob name in the container and then read each one | Passthrough |
requires_account_to_allow_anonymous_access |
True when this container's access level is blob or container, which means anonymous reads will succeed ONLY IF the parent storage account also allows anonymous access (allowBlobPublicAccess, exposed by the account module as allow_nested_items_to_be_public) | Passthrough |
is_static_website_content_container |
True when this container is the reserved $web container | Passthrough |
default_encryption_scope |
The default encryption scope reported by the service for this container | Passthrough |
default_encryption_scope_configured |
True when this module was given an explicit default_encryption_scope | Derived |
encryption_scope_override_enabled |
The provider's own attribute: true means a blob MAY be uploaded under an encryption scope other than the container's default, false means it may not | Passthrough |
default_encryption_scope_enforced |
True only when a default encryption scope was configured AND overrides are denied, so every blob in this container is guaranteed to be encrypted under that one scope | Derived |
encryption_scope_override_setting_ignored |
True when the caller set encryption_scope_override_enabled without also setting default_encryption_scope | Derived |
has_immutability_policy |
Whether an immutability policy (WORM) is configured on the container. It never says whether that policy is LOCKED β read the policy module's own locked output, because a locked policy makes this container and its account undeletable |
Passthrough |
has_legal_hold |
Whether a legal hold is configured on the container | Passthrough |
metadata |
The user-defined name/value metadata on the container, as read back from the blob data plane | Passthrough |
managed_via_control_plane_only |
Always true for a container addressed by storage_account_id, which is the only form this module accepts | Constant |
refresh_reads_parent_account_properties |
Always true | Constant |
delete_removes_all_blobs |
Always true, and the most consequential destroy fact about this resource | Constant |
delete_is_not_immediately_effective |
Always true | Constant |
effective_timeouts |
The create/read/update/delete timeouts that will actually apply, resolving each unset field to the provider's built-in default for this resource | Derived |
- Single keystone. The module owns exactly one resource,
azurerm_storage_container.this, following this module suite's single-primary-resource convention. It does not create the parent account, resource group, private endpoints, or diagnostics β those are sibling concerns consumed byid. - Force-new fields destroy data.
nameandstorage_account_idare immutable; so aredefault_encryption_scopeandencryption_scope_override_enabled. A change to any of these replaces the container, which deletes its blobs. Treat them as create-time decisions. - The encryption-scope coupling.
encryption_scope_override_enabledis only meaningful whendefault_encryption_scopeis set.main.tfrenders the override flag only when a scope is present, so setting it in isolation does not produce a spurious value. metadatais computed. The default{}means "no user metadata"; the service may still return system metadata. Passing an explicit empty map avoids perpetual diffs.- No
tags. This resource type does not support Azure resource tags, so the module omits thetagsvariable entirely β do not expect to tag a container. idis the Resource Manager ID. In the pinned provider line the containeridcarries the ARM identifier, and the older resource-manager-ID attribute is deprecated; the module emits the ARM identifier throughid.features {}dependence. The module declares no provider block; the caller'sprovider "azurerm" { features {} }is required for initialization.
| Concern | Secure default (empty call) | Opt-out (caller must type it) |
|---|---|---|
| Anonymous public access | container_access_type = "private" |
set "blob" or "container" (and enable public blob access on the account) |
| Encryption at rest | always on (account-level); scope-narrowing off unless a scope is supplied | supply default_encryption_scope |
| Encryption-scope override | not resolved by the empty call β the module leaves the flag unset, and the flag is only rendered when default_encryption_scope is set. Where no default scope is configured there is no override to permit or deny. |
set encryption_scope_override_enabled = false alongside a default_encryption_scope to pin every blob to that scope; true is the provider's permissive default |
| Credentials / subscription / region-as-auth | never module inputs β the caller configures the provider | β |
terraform init -backend=false
terraform validate
terraform fmt -check- Pin the module with
?ref=v1.0.0β never a branch. - This is plan-only during authoring; a human runs
terraform plan/applyfrom CI against real credentials.
The offline proof gate exercises everything that does not require an Azure call:
terraform init -backend=falseβ resolves the pinned provider without a backend.terraform validateβ proves the configuration is type-correct against the pinned provider schema, including thecontainer_access_typeenum.terraform fmt -checkβ enforces canonical formatting.
validate and fmt never contact Azure. Only terraform plan (run by a human from CI) exercises the ARM API β for example, confirming the referenced storage account exists and that the identity holds the required roles.
Outputs:
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-platform-data/providers/Microsoft.Storage/storageAccounts/stplatformdata01/blobServices/default/containers/app-data"
name = "app-data"
has_immutability_policy = false
has_legal_hold = false
| Symptom | Cause | Fix |
|---|---|---|
container_access_type must be one of: private, blob, container |
A value outside the enum was passed. | Use private, blob, or container. |
Public reads still fail with blob/container access |
The parent account disables public blob access. | Set allow_nested_items_to_be_public = true on the account, or keep the container private. |
| Plan shows the container being replaced | A force-new field (name, storage_account_id, default_encryption_scope, encryption_scope_override_enabled) changed. |
Revert the change, or accept replacement knowing blobs are destroyed. |
AuthorizationPermissionMismatch when reading/writing blobs |
The identity has a management role but no Storage Blob Data role. | Assign Storage Blob Data Reader/Contributor/Owner at the account or container scope. |
| Deprecation warning about an account-name argument | An older example set the account by name. | Pass the account's Resource ID via storage_account_id. |
Error: Provider configuration not present / features error |
The caller has no provider "azurerm" { features {} }. |
Add the provider block with features {} in the root module. |
| Encryption-scope value ignored | encryption_scope_override_enabled set without default_encryption_scope. |
Set default_encryption_scope; the override flag only applies alongside it. |
- Terraform Registry:
azurerm_storage_container - Microsoft Learn: Introduction to Azure Blob Storage
- Microsoft Learn: Authorize access to blobs using Microsoft Entra ID
- Sibling modules:
terraform-azurerm-storage-account,terraform-azurerm-role-assignments,terraform-azurerm-private-endpoint - This module's
SCOPE.md
π "Infrastructure as Code should be standardized, consistent, and secure."