Quick take — A production-ready Terraform module for azurerm_managed_disk on azurerm ~> 4.0: SKU selection, customer-managed key encryption, bursting, zone placement, and safe resize controls. 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 "managed_disk" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-managed-disk?ref=v1.0.0"
name = "..." # Name of the managed disk (1-80 chars, validated).
resource_group_name = "..." # Resource group to create the disk in.
location = "..." # Azure region for the disk.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
An Azure Managed Disk is a block-storage volume that Azure manages on your behalf — you pick the SKU (Standard HDD, Standard SSD, Premium SSD, Premium SSD v2, or Ultra), the size, and the redundancy, and Azure handles the underlying storage stamp, replication, and availability. Managed disks back the OS and data disks of virtual machines and VM scale sets, but they are also first-class standalone resources: you can create one, snapshot it, attach it to a VM, detach it, and re-attach it elsewhere.
The raw azurerm_managed_disk resource hides a lot of sharp edges. The valid combinations of storage_account_type, disk_iops_read_write, disk_mbps_read_write, tier, and disk_size_gb differ per SKU — Premium SSD v2 and Ultra let you provision IOPS and throughput independently, while classic Premium/Standard SSD derive performance from a size tier and only allow opt-in on_demand_bursting. Zone placement, customer-managed key (CMK) encryption via Disk Encryption Sets, and network_access_policy are all easy to get subtly wrong. Wrapping the resource in a reusable module lets you bake those rules — and your org’s tagging and naming conventions — into one tested, versioned unit so every team provisions disks the same safe way.
When to use it
- You provision standalone data disks that outlive the VMs they attach to (databases, log volumes, persistent application state) and want them in source control independent of the compute.
- You need customer-managed key encryption (Disk Encryption Set) enforced consistently for compliance — PCI, HIPAA, or an internal “encrypt everything with our keys” mandate.
- You run performance-sensitive workloads (SQL Server, SAP HANA, Kafka, Elasticsearch) on Premium SSD v2 or Ultra Disks and want IOPS/throughput provisioned explicitly, not guessed from a size tier.
- You want zone-pinned disks so an attached zonal VM and its data live in the same availability zone.
- You need disks created from a source — a snapshot, an image, or an existing disk — for restore, clone, or golden-image workflows.
Skip the standalone module when the disk is just an inline OS disk of a VM — define it in the VM/scale-set resource instead. This module shines for data disks and detachable volumes.
Module structure
terraform-module-azure-managed-disk/
├── 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
locals {
# IOPS/throughput are only valid for UltraSSD_LRS and PremiumV2_LRS.
perf_provisionable = contains(["UltraSSD_LRS", "PremiumV2_LRS"], var.storage_account_type)
# On-demand bursting is only supported on Premium SSD (classic) >= 512 GiB.
bursting_eligible = var.storage_account_type == "Premium_LRS" && var.disk_size_gb >= 512
}
resource "azurerm_managed_disk" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
storage_account_type = var.storage_account_type
create_option = var.create_option
disk_size_gb = var.disk_size_gb
# Performance tier only applies to Premium SSD (classic). null lets Azure
# default it from disk_size_gb; setting it enables performance-plus billing.
tier = local.perf_provisionable ? null : var.tier
# Independent IOPS/throughput — only set for Ultra / Premium SSD v2.
disk_iops_read_write = local.perf_provisionable ? var.disk_iops_read_write : null
disk_mbps_read_write = local.perf_provisionable ? var.disk_mbps_read_write : null
# Source wiring for create_option = Copy / FromImage / Restore.
source_resource_id = var.source_resource_id
image_reference_id = var.image_reference_id
gallery_image_reference_id = var.gallery_image_reference_id
source_uri = var.source_uri
storage_account_id = var.source_storage_account_id
# Zone placement (e.g. "1", "2", "3"). Null = regional, no zone pinning.
zone = var.zone
# Encryption: platform-managed by default; pass a Disk Encryption Set for CMK.
disk_encryption_set_id = var.disk_encryption_set_id
# Network exposure of the disk export endpoint.
network_access_policy = var.network_access_policy
disk_access_id = var.network_access_policy == "AllowPrivate" ? var.disk_access_id : null
public_network_access_enabled = var.public_network_access_enabled
# Opt-in bursting for eligible Premium SSD disks.
on_demand_bursting_enabled = local.bursting_eligible ? var.on_demand_bursting_enabled : null
# Shared disk for clustered workloads (SQL FCI, etc.). Requires Premium/Ultra.
max_shares = var.max_shares
# Trusted launch / confidential VM guest state support.
trusted_launch_enabled = var.trusted_launch_enabled
security_type = var.security_type
secure_vm_disk_encryption_set_id = var.secure_vm_disk_encryption_set_id
hyper_v_generation = var.hyper_v_generation
tags = var.tags
lifecycle {
# Resizing down is destructive on Azure and forces replacement; block it.
precondition {
condition = var.disk_size_gb >= 1
error_message = "disk_size_gb must be a positive integer."
}
}
}
variables.tf
variable "name" {
type = string
description = "Name of the managed disk."
validation {
condition = can(regex("^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,78}[a-zA-Z0-9_]$", var.name))
error_message = "Disk name must be 1-80 chars: letters, numbers, '_', '.', '-'; cannot start with '-' or end with '.' or '-'."
}
}
variable "resource_group_name" {
type = string
description = "Resource group in which to create the disk."
}
variable "location" {
type = string
description = "Azure region for the disk."
}
variable "storage_account_type" {
type = string
description = "Disk SKU."
default = "Premium_LRS"
validation {
condition = contains([
"Standard_LRS", "StandardSSD_LRS", "StandardSSD_ZRS",
"Premium_LRS", "Premium_ZRS", "PremiumV2_LRS", "UltraSSD_LRS"
], var.storage_account_type)
error_message = "storage_account_type must be one of Standard_LRS, StandardSSD_LRS, StandardSSD_ZRS, Premium_LRS, Premium_ZRS, PremiumV2_LRS, UltraSSD_LRS."
}
}
variable "create_option" {
type = string
description = "How the disk is created (Empty, Copy, FromImage, Import, Restore)."
default = "Empty"
validation {
condition = contains(["Empty", "Copy", "FromImage", "Import", "Restore"], var.create_option)
error_message = "create_option must be Empty, Copy, FromImage, Import, or Restore."
}
}
variable "disk_size_gb" {
type = number
description = "Size of the disk in GiB. Must be >= the source when copying."
default = 128
validation {
condition = var.disk_size_gb >= 1 && var.disk_size_gb <= 65536
error_message = "disk_size_gb must be between 1 and 65536 GiB."
}
}
variable "tier" {
type = string
description = "Performance tier for Premium SSD (classic), e.g. P30. Null = derive from size."
default = null
}
variable "disk_iops_read_write" {
type = number
description = "Provisioned IOPS (UltraSSD_LRS / PremiumV2_LRS only)."
default = null
}
variable "disk_mbps_read_write" {
type = number
description = "Provisioned throughput in MBps (UltraSSD_LRS / PremiumV2_LRS only)."
default = null
}
variable "zone" {
type = string
description = "Availability zone to pin the disk to (\"1\", \"2\", or \"3\"). Null = regional."
default = null
validation {
condition = var.zone == null || contains(["1", "2", "3"], var.zone)
error_message = "zone must be \"1\", \"2\", \"3\", or null."
}
}
variable "disk_encryption_set_id" {
type = string
description = "Disk Encryption Set ID for customer-managed key (CMK) encryption. Null = platform-managed keys."
default = null
}
variable "network_access_policy" {
type = string
description = "Disk export network policy: AllowAll, AllowPrivate, or DenyAll."
default = "DenyAll"
validation {
condition = contains(["AllowAll", "AllowPrivate", "DenyAll"], var.network_access_policy)
error_message = "network_access_policy must be AllowAll, AllowPrivate, or DenyAll."
}
}
variable "disk_access_id" {
type = string
description = "Disk Access resource ID, required when network_access_policy = AllowPrivate."
default = null
}
variable "public_network_access_enabled" {
type = bool
description = "Whether public network access to the disk is allowed."
default = false
}
variable "on_demand_bursting_enabled" {
type = bool
description = "Enable on-demand bursting (Premium SSD classic >= 512 GiB only)."
default = false
}
variable "max_shares" {
type = number
description = "Max simultaneous VM attachments for a shared disk (clustered workloads). Null = not shared."
default = null
validation {
condition = var.max_shares == null || (var.max_shares >= 1 && var.max_shares <= 10)
error_message = "max_shares must be between 1 and 10, or null."
}
}
variable "trusted_launch_enabled" {
type = bool
description = "Enable Trusted Launch on the disk."
default = null
}
variable "security_type" {
type = string
description = "Security type for confidential VM disks (e.g. ConfidentialVM_VMGuestStateOnlyEncryptedWithPlatformKey)."
default = null
}
variable "secure_vm_disk_encryption_set_id" {
type = string
description = "Disk Encryption Set ID for confidential VM disk encryption."
default = null
}
variable "hyper_v_generation" {
type = string
description = "Hyper-V generation (V1 or V2). Only set for FromImage/Import sources that require it."
default = null
validation {
condition = var.hyper_v_generation == null || contains(["V1", "V2"], var.hyper_v_generation)
error_message = "hyper_v_generation must be V1, V2, or null."
}
}
variable "source_resource_id" {
type = string
description = "Source disk/snapshot ID for create_option = Copy or Restore."
default = null
}
variable "image_reference_id" {
type = string
description = "Platform/marketplace image ID for create_option = FromImage."
default = null
}
variable "gallery_image_reference_id" {
type = string
description = "Shared Image Gallery image version ID for create_option = FromImage."
default = null
}
variable "source_uri" {
type = string
description = "Blob URI for create_option = Import."
default = null
}
variable "source_storage_account_id" {
type = string
description = "Storage account ID that holds source_uri (for Import)."
default = null
}
variable "tags" {
type = map(string)
description = "Tags applied to the disk."
default = {}
}
outputs.tf
output "id" {
description = "The resource ID of the managed disk."
value = azurerm_managed_disk.this.id
}
output "name" {
description = "The name of the managed disk."
value = azurerm_managed_disk.this.name
}
output "disk_size_gb" {
description = "The provisioned size of the disk in GiB."
value = azurerm_managed_disk.this.disk_size_gb
}
output "storage_account_type" {
description = "The SKU the disk was created with."
value = azurerm_managed_disk.this.storage_account_type
}
output "zone" {
description = "The availability zone the disk is pinned to (empty if regional)."
value = azurerm_managed_disk.this.zone
}
output "disk_iops_read_write" {
description = "Effective provisioned read/write IOPS."
value = azurerm_managed_disk.this.disk_iops_read_write
}
How to use it
# A Disk Encryption Set + zonal Premium SSD v2 data disk for a SQL workload,
# then attached to an existing VM via its output id.
module "sql_data_disk" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-managed-disk?ref=v1.0.0"
name = "disk-sql-data-prod-eus2-01"
resource_group_name = azurerm_resource_group.data.name
location = "eastus2"
storage_account_type = "PremiumV2_LRS"
create_option = "Empty"
disk_size_gb = 1024
zone = "1"
# Provision performance explicitly for the database tier.
disk_iops_read_write = 8000
disk_mbps_read_write = 600
# Customer-managed key encryption via a pre-existing Disk Encryption Set.
disk_encryption_set_id = azurerm_disk_encryption_set.cmk.id
# Lock the disk export surface down to private only.
network_access_policy = "DenyAll"
tags = {
environment = "prod"
workload = "sql-server"
owner = "data-platform"
}
}
# Downstream: attach the disk to a VM using the module's id output.
resource "azurerm_virtual_machine_data_disk_attachment" "sql_data" {
managed_disk_id = module.sql_data_disk.id
virtual_machine_id = azurerm_linux_virtual_machine.sql.id
lun = 10
caching = "None" # None is recommended for write-heavy DB log/data on Premium v2.
}
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/managed_disk/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-managed-disk?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/managed_disk && 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 | Name of the managed disk (1-80 chars, validated). |
resource_group_name |
string |
— | Yes | Resource group to create the disk in. |
location |
string |
— | Yes | Azure region for the disk. |
storage_account_type |
string |
"Premium_LRS" |
No | Disk SKU (Standard_LRS … UltraSSD_LRS). |
create_option |
string |
"Empty" |
No | Empty, Copy, FromImage, Import, or Restore. |
disk_size_gb |
number |
128 |
No | Disk size in GiB (1-65536). |
tier |
string |
null |
No | Premium SSD (classic) performance tier, e.g. P30. |
disk_iops_read_write |
number |
null |
No | Provisioned IOPS (Ultra / Premium SSD v2 only). |
disk_mbps_read_write |
number |
null |
No | Provisioned throughput in MBps (Ultra / Premium SSD v2 only). |
zone |
string |
null |
No | Availability zone (“1”/“2”/“3”), or null for regional. |
disk_encryption_set_id |
string |
null |
No | Disk Encryption Set ID for CMK encryption. |
network_access_policy |
string |
"DenyAll" |
No | AllowAll, AllowPrivate, or DenyAll for the export endpoint. |
disk_access_id |
string |
null |
No | Disk Access ID, required when policy is AllowPrivate. |
public_network_access_enabled |
bool |
false |
No | Allow public network access to the disk. |
on_demand_bursting_enabled |
bool |
false |
No | Enable on-demand bursting (Premium SSD classic ≥ 512 GiB). |
max_shares |
number |
null |
No | Max VM attachments for a shared disk (1-10). |
trusted_launch_enabled |
bool |
null |
No | Enable Trusted Launch on the disk. |
security_type |
string |
null |
No | Confidential VM disk security type. |
secure_vm_disk_encryption_set_id |
string |
null |
No | DES ID for confidential VM disk encryption. |
hyper_v_generation |
string |
null |
No | Hyper-V generation (V1/V2) for image-based sources. |
source_resource_id |
string |
null |
No | Source disk/snapshot ID for Copy/Restore. |
image_reference_id |
string |
null |
No | Platform image ID for FromImage. |
gallery_image_reference_id |
string |
null |
No | Shared Image Gallery version ID for FromImage. |
source_uri |
string |
null |
No | Blob URI for Import. |
source_storage_account_id |
string |
null |
No | Storage account ID holding source_uri. |
tags |
map(string) |
{} |
No | Tags applied to the disk. |
Outputs
| Name | Description |
|---|---|
id |
The resource ID of the managed disk. |
name |
The name of the managed disk. |
disk_size_gb |
The provisioned size of the disk in GiB. |
storage_account_type |
The SKU the disk was created with. |
zone |
The availability zone the disk is pinned to (empty if regional). |
disk_iops_read_write |
Effective provisioned read/write IOPS. |
Enterprise scenario
A financial-services platform runs SQL Server Always On availability groups across three zones in East US 2. Each replica’s transaction-log and data volumes are provisioned through this module as zonal PremiumV2_LRS disks with IOPS and throughput sized per replica, all encrypted with a single Disk Encryption Set wired to a Key Vault HSM key so auditors see one CMK boundary. Because the disks are standalone Terraform resources, the DBAs can re-attach a surviving replica’s data disk to a freshly built VM during DR drills without rebuilding the volume, and network_access_policy = "DenyAll" keeps the disk export surface closed to satisfy the firm’s data-exfiltration controls.
Best practices
- Encrypt with customer-managed keys for regulated data: pass a
disk_encryption_set_idso the disk is encrypted with a Key Vault key you rotate and revoke, rather than relying solely on platform-managed keys. - Lock down the export surface: default
network_access_policytoDenyAll(orAllowPrivatewith a Disk Access) and keeppublic_network_access_enabled = falseso disk blobs can’t be exported over the public internet. - Right-size the SKU, not just the size: use
Standard_LRS/StandardSSD_LRSfor dev and cold data, reservePremium_LRS/PremiumV2_LRS/UltraSSD_LRSfor production, and provision IOPS/throughput explicitly on v2/Ultra instead of over-buying capacity to hit a performance tier. - Pin zonal workloads to a zone: set
zoneto match the attached VM’s zone so compute and storage stay co-located — a regional disk cannot attach to a zonal VM in a different zone. - Never shrink a disk in place: Azure does not support online shrink, and lowering
disk_size_gbforces a destructive replace. Grow only, and snapshot before any size change. - Name and tag consistently: follow a
disk-<workload>-<env>-<region>-<nn>convention (enforced by thenamevalidation) and requireenvironment/ownertags so cost allocation and ownership are queryable across the estate.