IaC Azure

Terraform Module: Azure Managed Disk — encryption-aware, tier-flexible block storage

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

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

TerraformAzureManaged DiskModuleIaC
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