Provision the Upload Archive with Terraform (Microsoft Azure)
This page is the infrastructure-as-code alternative to Part 2 of the
Configure File Upload Capture (Microsoft Azure)
guide. It produces the same Azure resources as the console walkthrough there —
a resource group, a Key Vault customer-managed key, a hardened Blob Storage
account, a private container, and the container-scoped RBAC grant the Anzenna
app uses — in a single terraform apply.
Before starting:
Complete Part 1 in the
Microsoft Azure guide
(generate an age recipient keypair).
Complete 2a in the Microsoft Azure guide (consent the Anzenna app into your tenant with
az ad sp create). The Terraform looks up that service principal — it must already exist before you apply.
When the apply finishes, return to the Microsoft Azure guide and continue at Part 3 (Register everything in Anzenna).
terraform {
required_version = ">= 1.5"
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
azuread = { source = "hashicorp/azuread", version = "~> 3.0" }
random = { source = "hashicorp/random", version = "~> 3.6" }
}
}
variable "tenant_id" {
type = string
description = "Your Entra tenant ID — the tenant that will hold the archive storage."
}
variable "subscription_id" {
type = string
description = "Subscription (within tenant_id) that will hold the archive storage."
}
variable "location" {
type = string
default = "westus2"
description = "Azure region for the archive. Must satisfy your data-residency requirements."
}
variable "customer_label" {
type = string
description = "Short identifier. Becomes part of the resource group, Key Vault, and storage account names. Lowercase alphanumeric or hyphens, 2-32 chars."
validation {
condition = can(regex("^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$", var.customer_label))
error_message = "customer_label must be lowercase alphanumeric or hyphens, 2-32 chars, not starting/ending with a hyphen."
}
}
variable "container_name" {
type = string
default = "uploads"
}
variable "federation_app_client_id" {
type = string
default = "245121cf-892e-4210-a296-88850ee586d3"
description = "Application (client) ID of the Anzenna File Archival app you consent into your tenant. The default is Anzenna's production app."
}
variable "object_expiration_days" {
type = number
default = 90
description = "Lifecycle expiry for archived blobs."
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id
tenant_id = var.tenant_id
storage_use_azuread = true
# The storage account disables shared-key access, so data-plane calls (creating
# the container) must go through Entra RBAC. storage_use_azuread routes them there.
resource_provider_registrations = "none"
}
provider "azuread" {
tenant_id = var.tenant_id
}
data "azurerm_client_config" "current" {}
# The Anzenna app's service principal must already be consented into your tenant
# (az ad sp create --id <app-id>) before apply — this looks it up by its appId.
data "azuread_service_principal" "federation_app" {
client_id = var.federation_app_client_id
}
resource "random_id" "suffix" {
byte_length = 3
}
locals {
# Storage account names: 3-24 chars, lowercase alphanumeric, no hyphens, globally unique.
label_clean = substr(replace(var.customer_label, "-", ""), 0, 12)
storage_account_name = "anzup${local.label_clean}${random_id.suffix.hex}"
}
resource "azurerm_resource_group" "upload" {
name = "anzenna-upload-${var.customer_label}"
location = var.location
}
# Customer-managed key. Key Vault needs soft-delete + purge protection to back a
# storage account CMK. RBAC-authorized so access is granted with role assignments.
resource "azurerm_key_vault" "upload" {
name = "anzup-kv-${random_id.suffix.hex}"
location = azurerm_resource_group.upload.location
resource_group_name = azurerm_resource_group.upload.name
tenant_id = data.azurerm_client_config.current.tenant_id
sku_name = "standard"
purge_protection_enabled = true
soft_delete_retention_days = 7
rbac_authorization_enabled = true
}
# The Terraform caller needs crypto-officer rights to mint the key in an RBAC vault.
resource "azurerm_role_assignment" "tf_caller_kv_officer" {
scope = azurerm_key_vault.upload.id
role_definition_name = "Key Vault Crypto Officer"
principal_id = data.azurerm_client_config.current.object_id
}
resource "azurerm_key_vault_key" "upload" {
name = "anzenna-upload-${var.customer_label}"
key_vault_id = azurerm_key_vault.upload.id
key_type = "RSA"
key_size = 2048
key_opts = ["wrapKey", "unwrapKey"]
depends_on = [azurerm_role_assignment.tf_caller_kv_officer]
}
# Storage account: secure transfer + TLS 1.2, no anonymous access, no shared-key
# access (Entra-only), infrastructure (double) encryption, versioning + soft delete.
resource "azurerm_storage_account" "upload" {
name = local.storage_account_name
resource_group_name = azurerm_resource_group.upload.name
location = azurerm_resource_group.upload.location
account_tier = "Standard"
account_replication_type = "LRS"
min_tls_version = "TLS1_2"
https_traffic_only_enabled = true
allow_nested_items_to_be_public = false
shared_access_key_enabled = false
infrastructure_encryption_enabled = true
identity {
type = "SystemAssigned"
}
blob_properties {
versioning_enabled = true
delete_retention_policy {
days = 7
}
}
# The standalone CMK resource below owns the key. azurerm reads the live CMK back
# into this account's state, so without this every plan would try to null it out.
lifecycle {
ignore_changes = [customer_managed_key]
}
}
# Let the account's identity wrap/unwrap with the CMK, then point the account at it.
resource "azurerm_role_assignment" "sa_kv_encryption_user" {
scope = azurerm_key_vault.upload.id
role_definition_name = "Key Vault Crypto Service Encryption User"
principal_id = azurerm_storage_account.upload.identity[0].principal_id
}
resource "azurerm_storage_account_customer_managed_key" "upload" {
storage_account_id = azurerm_storage_account.upload.id
key_vault_id = azurerm_key_vault.upload.id
key_name = azurerm_key_vault_key.upload.name
depends_on = [azurerm_role_assignment.sa_kv_encryption_user]
}
# The caller needs blob-owner to create a container when shared-key access is off.
resource "azurerm_role_assignment" "tf_caller_blob_owner" {
scope = azurerm_storage_account.upload.id
role_definition_name = "Storage Blob Data Owner"
principal_id = data.azurerm_client_config.current.object_id
}
resource "azurerm_storage_container" "upload" {
name = var.container_name
storage_account_name = azurerm_storage_account.upload.name
container_access_type = "private"
depends_on = [azurerm_role_assignment.tf_caller_blob_owner]
}
# The grant: the Anzenna app can write blobs in this container only — scoped to
# the container, not the account. Least privilege.
resource "azurerm_role_assignment" "app_blob_writer" {
scope = "${azurerm_storage_account.upload.id}/blobServices/default/containers/${azurerm_storage_container.upload.name}"
role_definition_name = "Storage Blob Data Contributor"
principal_id = data.azuread_service_principal.federation_app.object_id
}
# Lifecycle expiry. The account-level CMK encrypts every write transparently, so
# there is no per-write encryption-header check like the AWS bucket policy needs.
resource "azurerm_storage_management_policy" "upload" {
storage_account_id = azurerm_storage_account.upload.id
rule {
name = "expire-objects"
enabled = true
filters {
blob_types = ["blockBlob"]
prefix_match = ["${var.container_name}/"]
}
actions {
base_blob {
delete_after_days_since_creation_greater_than = var.object_expiration_days
}
version {
delete_after_days_since_creation = 7
}
}
}
}
output "storage_account_name" {
value = azurerm_storage_account.upload.name
}
output "container_name" {
value = azurerm_storage_container.upload.name
}
output "blob_endpoint" {
value = azurerm_storage_account.upload.primary_blob_endpoint
}
output "tenant_id" {
value = var.tenant_id
}
Save the file (e.g.
main.tf) and create aterraform.tfvarswith your tenant, subscription, and label:tenant_id = "<your-tenant-id>" subscription_id = "<your-subscription-id>" customer_label = "yourorg"
Authenticate and select the subscription:
az login --tenant <your-tenant-id> az account set --subscription <your-subscription-id>
Run
terraform init && terraform apply.Read the outputs you'll paste into Anzenna in Part 3:
terraform outputThe destination form maps as: Storage account =
storage_account_name, Container =container_name, Tenant ID = yourtenant_id. Leave Object key prefix blank.
You're done with Azure. Return to the
Microsoft Azure setup guide
and continue at Part 3 (Register everything in Anzenna).
Need help? Contact Anzenna Support at support@anzenna.ai for assistance.