IaC Azure

Terraform Module: Azure NetApp Files — enterprise NFS/SMB volumes on a delegated subnet, governed end to end

Quick take — A reusable hashicorp/azurerm ~> 4.0 module for azurerm_netapp_account, azurerm_netapp_pool, and azurerm_netapp_volume: validated service levels, delegated-subnet enforcement, export policy rules, and optional snapshot/replication data protection. 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 "netapp" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-netapp-files?ref=v1.0.0"

  account_name        = "..."   # NetApp account name (the top-level ANF container).
  resource_group_name = "..."   # Resource group for the account, pool, and volumes.
  location            = "..."   # Azure region with ANF capacity (e.g. centralindia).
  pool_name           = "..."   # Capacity pool name.
  pool_service_level  = "..."   # Standard | Premium | Ultra.
  pool_size_in_tb     = 4       # Pool size in TiB (>= 1; 4 is the practical floor).

  volumes = {
    # Each volume MUST land on a subnet delegated to Microsoft.Netapp/volumes.
    data = {
      volume_path         = "..."          # Unique export path (mount target suffix).
      service_level       = "..."          # Standard | Premium | Ultra.
      subnet_id           = "..."          # Delegated subnet ID.
      storage_quota_in_gb = 100            # Volume quota in GiB.
      protocols           = ["NFSv4.1"]    # ["NFSv3"] | ["NFSv4.1"] | ["CIFS"].
    }
  }
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure NetApp Files (ANF) is a first-party, NetApp-powered file-storage service that delivers enterprise NAS performance — sub-millisecond latency, NFSv3, NFSv4.1, and SMB/CIFS, with up to Ultra-tier throughput — as a fully managed Azure resource. It is the storage of choice for SAP HANA, Oracle, high-performance computing scratch space, EDA, and any lift-and-shift workload that expects a “real” filer rather than blob or managed disk.

ANF has a three-level resource hierarchy, and you cannot skip a level:

  1. azurerm_netapp_account — the regional container that owns pools, volumes, AD connections, and snapshot policies. It holds no capacity itself.
  2. azurerm_netapp_pool (capacity pool) — a billed block of provisioned capacity at a fixed service_level (Standard, Premium, or Ultra) and size_in_tb. Throughput is a function of the tier and the provisioned size; qos_type controls whether throughput is allocated automatically per volume or set manually.
  3. azurerm_netapp_volume — the actual mountable file system, carved from a pool, exported over protocols (["NFSv3"], ["NFSv4.1"], or ["CIFS"]), sized by storage_quota_in_gb, and reachable only through a dedicated delegated subnet.

The single sharpest edge in ANF is that networking requirement. A volume’s subnet_id must point at a subnet delegated to Microsoft.Netapp/volumes — and that delegation makes the subnet exclusive to ANF: you cannot put VMs, Private Endpoints, or anything else in it. Forget the delegation and apply fails with an opaque API error; share the subnet with other workloads and the delegation request is rejected. Teams discover this the hard way, then copy-paste a fragile snowflake config across environments.

Wrapping the whole hierarchy in one module encodes the correct shape once: the account and pool are created with validated service levels, every volume is forced through a for_each map that demands a subnet_id, export policy rules are first-class so NFS access controls are reviewed rather than left wide open, and optional snapshot policies and cross-region replication are gated behind flags. Downstream consumers get a clean volume-name → mount-target map and never have to remember the delegation rule again.

When to use it

Reach for plain Azure Files (azurerm_storage_share) when you need cheap SMB/NFS shares without the performance tier, or Managed Disks when a single VM needs block storage. ANF is the right tool specifically when you need filer-grade throughput, snapshots, and replication.

Module structure

terraform-module-azure-netapp-files/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # account, capacity pool, volumes, optional snapshot policy
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # account/pool ids, and volume-name => mount target map

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

locals {
  tags = merge(
    {
      managed_by = "terraform"
      module     = "azure-netapp-files"
    },
    var.tags,
  )
}

# The NetApp account is the regional container. It holds no capacity itself —
# pools, volumes, AD connections, and snapshot policies all hang off it.
resource "azurerm_netapp_account" "this" {
  name                = var.account_name
  resource_group_name = var.resource_group_name
  location            = var.location

  tags = local.tags
}

# A capacity pool is the billed block of provisioned storage at a fixed service
# level. Volumes are carved from it; the pool's tier caps a volume's throughput.
resource "azurerm_netapp_pool" "this" {
  name                = var.pool_name
  account_name        = azurerm_netapp_account.this.name
  resource_group_name = var.resource_group_name
  location            = var.location

  service_level = var.pool_service_level
  size_in_tb    = var.pool_size_in_tb

  # Auto = throughput allocated automatically per volume; Manual = you set it.
  qos_type = var.pool_qos_type

  tags = local.tags
}

# Optional snapshot policy, created once on the account and referenced by any
# volume that opts into scheduled snapshots via snapshot_policy_id.
resource "azurerm_netapp_snapshot_policy" "this" {
  count = var.snapshot_policy == null ? 0 : 1

  name                = var.snapshot_policy.name
  account_name        = azurerm_netapp_account.this.name
  resource_group_name = var.resource_group_name
  location            = var.location
  enabled             = var.snapshot_policy.enabled

  dynamic "hourly_schedule" {
    for_each = var.snapshot_policy.hourly == null ? [] : [var.snapshot_policy.hourly]
    content {
      snapshots_to_keep = hourly_schedule.value.snapshots_to_keep
      minute            = hourly_schedule.value.minute
    }
  }

  dynamic "daily_schedule" {
    for_each = var.snapshot_policy.daily == null ? [] : [var.snapshot_policy.daily]
    content {
      snapshots_to_keep = daily_schedule.value.snapshots_to_keep
      hour              = daily_schedule.value.hour
      minute            = daily_schedule.value.minute
    }
  }

  dynamic "weekly_schedule" {
    for_each = var.snapshot_policy.weekly == null ? [] : [var.snapshot_policy.weekly]
    content {
      snapshots_to_keep = weekly_schedule.value.snapshots_to_keep
      days_of_week      = weekly_schedule.value.days_of_week
      hour              = weekly_schedule.value.hour
      minute            = weekly_schedule.value.minute
    }
  }

  tags = local.tags
}

# Volumes are managed as a for_each map so adding one is a small, reviewed diff.
# Each volume's subnet_id MUST be a subnet delegated to Microsoft.Netapp/volumes.
resource "azurerm_netapp_volume" "this" {
  for_each = var.volumes

  name                = each.key
  account_name        = azurerm_netapp_account.this.name
  pool_name           = azurerm_netapp_pool.this.name
  resource_group_name = var.resource_group_name
  location            = var.location

  volume_path         = each.value.volume_path
  service_level       = each.value.service_level
  subnet_id           = each.value.subnet_id
  storage_quota_in_gb = each.value.storage_quota_in_gb
  protocols           = each.value.protocols

  # Basic (default) keeps ANF on the legacy network stack; Standard unlocks
  # NSGs/UDRs and higher IP limits on the delegated subnet.
  network_features = each.value.network_features

  # Export policy rules govern which clients may mount and with what access.
  # Without an explicit rule, NFS exports default wide — always pin allowed_clients.
  dynamic "export_policy_rule" {
    for_each = each.value.export_policy_rules
    content {
      rule_index      = export_policy_rule.value.rule_index
      allowed_clients = export_policy_rule.value.allowed_clients
      protocol        = export_policy_rule.value.protocol
      unix_read_only  = export_policy_rule.value.unix_read_only
      unix_read_write = export_policy_rule.value.unix_read_write
      root_access_enabled = export_policy_rule.value.root_access_enabled
    }
  }

  # Scheduled snapshots: reference the policy created above (primary volume only).
  dynamic "data_protection_snapshot_policy" {
    for_each = each.value.enable_snapshot_policy && var.snapshot_policy != null ? [1] : []
    content {
      snapshot_policy_id = azurerm_netapp_snapshot_policy.this[0].id
    }
  }

  # Cross-region replication: only the destination (secondary) volume carries
  # this block and points at the primary volume's resource ID.
  dynamic "data_protection_replication" {
    for_each = each.value.replication == null ? [] : [each.value.replication]
    content {
      endpoint_type             = "dst"
      remote_volume_location    = data_protection_replication.value.remote_volume_location
      remote_volume_resource_id = data_protection_replication.value.remote_volume_resource_id
      replication_frequency     = data_protection_replication.value.replication_frequency
    }
  }

  tags = local.tags
}

variables.tf

variable "account_name" {
  description = "NetApp account name (the regional top-level ANF container)."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$", var.account_name))
    error_message = "account_name must be 1-63 chars and start with a letter or digit."
  }
}

variable "resource_group_name" {
  description = "Resource group for the account, pool, and volumes."
  type        = string
}

variable "location" {
  description = "Azure region with ANF capacity (e.g. centralindia, eastus)."
  type        = string
}

variable "pool_name" {
  description = "Capacity pool name."
  type        = string
}

variable "pool_service_level" {
  description = "Pool service level: Standard, Premium, or Ultra."
  type        = string
  default     = "Premium"

  validation {
    condition     = contains(["Standard", "Premium", "Ultra"], var.pool_service_level)
    error_message = "pool_service_level must be one of: Standard, Premium, Ultra."
  }
}

variable "pool_size_in_tb" {
  description = "Provisioned pool size in TiB (1-2048). 4 is the practical floor for Basic network features."
  type        = number
  default     = 4

  validation {
    condition     = var.pool_size_in_tb >= 1 && var.pool_size_in_tb <= 2048
    error_message = "pool_size_in_tb must be between 1 and 2048."
  }
}

variable "pool_qos_type" {
  description = "Pool QoS type: Auto (per-volume throughput auto-allocated) or Manual."
  type        = string
  default     = "Auto"

  validation {
    condition     = contains(["Auto", "Manual"], var.pool_qos_type)
    error_message = "pool_qos_type must be Auto or Manual."
  }
}

variable "volumes" {
  description = <<-EOT
    Map of volumes keyed by volume name. Each value:
      volume_path         - unique export path / mount target suffix (required)
      service_level       - Standard | Premium | Ultra (required)
      subnet_id           - ID of a subnet DELEGATED to Microsoft.Netapp/volumes (required)
      storage_quota_in_gb - volume quota in GiB (required)
      protocols           - ["NFSv3"] | ["NFSv4.1"] | ["CIFS"] (default ["NFSv3"])
      network_features    - "Basic" | "Standard" (default "Standard")
      export_policy_rules - list of { rule_index, allowed_clients, protocol,
                            unix_read_only, unix_read_write, root_access_enabled }
      enable_snapshot_policy - attach the module's snapshot policy (default false)
      replication         - optional { remote_volume_location,
                            remote_volume_resource_id, replication_frequency }
  EOT
  type = map(object({
    volume_path         = string
    service_level       = string
    subnet_id           = string
    storage_quota_in_gb = number
    protocols           = optional(list(string), ["NFSv3"])
    network_features    = optional(string, "Standard")
    export_policy_rules = optional(list(object({
      rule_index          = number
      allowed_clients     = list(string)
      protocol            = string
      unix_read_only      = optional(bool, false)
      unix_read_write     = optional(bool, true)
      root_access_enabled = optional(bool, true)
    })), [])
    enable_snapshot_policy = optional(bool, false)
    replication = optional(object({
      remote_volume_location    = string
      remote_volume_resource_id = string
      replication_frequency     = string
    }))
  }))

  validation {
    condition = alltrue([
      for v in values(var.volumes) :
      contains(["Standard", "Premium", "Ultra"], v.service_level)
    ])
    error_message = "Each volume service_level must be Standard, Premium, or Ultra."
  }

  validation {
    condition = alltrue([
      for v in values(var.volumes) :
      length(v.protocols) == 1 &&
      contains(["NFSv3", "NFSv4.1", "CIFS"], v.protocols[0])
    ])
    error_message = "Each volume protocols must be a single value: NFSv3, NFSv4.1, or CIFS."
  }

  validation {
    condition = alltrue([
      for v in values(var.volumes) :
      contains(["Basic", "Standard"], v.network_features)
    ])
    error_message = "Each volume network_features must be Basic or Standard."
  }

  validation {
    condition = alltrue([
      for v in values(var.volumes) : v.storage_quota_in_gb >= 100
    ])
    error_message = "storage_quota_in_gb must be at least 100 (the ANF volume minimum)."
  }
}

variable "snapshot_policy" {
  description = <<-EOT
    Optional snapshot policy created on the account. Volumes opt in via
    enable_snapshot_policy. Structure:
      name    - policy name (required)
      enabled - whether the policy is active (default true)
      hourly  - optional { snapshots_to_keep, minute }
      daily   - optional { snapshots_to_keep, hour, minute }
      weekly  - optional { snapshots_to_keep, days_of_week, hour, minute }
  EOT
  type = object({
    name    = string
    enabled = optional(bool, true)
    hourly = optional(object({
      snapshots_to_keep = number
      minute            = number
    }))
    daily = optional(object({
      snapshots_to_keep = number
      hour              = number
      minute            = number
    }))
    weekly = optional(object({
      snapshots_to_keep = number
      days_of_week      = list(string)
      hour              = number
      minute            = number
    }))
  })
  default = null
}

variable "tags" {
  description = "Tags merged with module defaults and applied to every resource."
  type        = map(string)
  default     = {}
}

outputs.tf

output "account_id" {
  description = "Resource ID of the NetApp account."
  value       = azurerm_netapp_account.this.id
}

output "account_name" {
  description = "Name of the NetApp account."
  value       = azurerm_netapp_account.this.name
}

output "pool_id" {
  description = "Resource ID of the capacity pool."
  value       = azurerm_netapp_pool.this.id
}

output "volume_ids" {
  description = "Map of volume name => volume resource ID."
  value       = { for k, v in azurerm_netapp_volume.this : k => v.id }
}

output "volume_mount_ip_addresses" {
  description = "Map of volume name => list of mount target IP addresses."
  value       = { for k, v in azurerm_netapp_volume.this : k => v.mount_ip_addresses }
}

output "volume_paths" {
  description = "Map of volume name => export path (used to build NFS mount strings)."
  value       = { for k, v in azurerm_netapp_volume.this : k => v.volume_path }
}

output "snapshot_policy_id" {
  description = "Resource ID of the snapshot policy, or null when none was created."
  value       = try(azurerm_netapp_snapshot_policy.this[0].id, null)
}

How to use it

# The delegated subnet is a hard prerequisite. It must be exclusive to ANF.
resource "azurerm_subnet" "netapp" {
  name                 = "snet-netapp"
  resource_group_name  = module.rg.name
  virtual_network_name = module.virtual_network.name
  address_prefixes     = ["10.20.10.0/24"]

  delegation {
    name = "netapp-delegation"
    service_delegation {
      name = "Microsoft.Netapp/volumes"
      actions = [
        "Microsoft.Network/networkinterfaces/*",
        "Microsoft.Network/virtualNetworks/subnets/join/action",
      ]
    }
  }
}

module "netapp" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-netapp-files?ref=v1.0.0"

  account_name        = "anf-hana-prod-cin"
  resource_group_name = module.rg.name
  location            = module.rg.location

  pool_name          = "pool-hana-prod"
  pool_service_level = "Ultra"
  pool_size_in_tb    = 8
  pool_qos_type      = "Auto"

  snapshot_policy = {
    name    = "snap-daily-prod"
    enabled = true
    daily   = { snapshots_to_keep = 14, hour = 2, minute = 30 }
    weekly  = { snapshots_to_keep = 4, days_of_week = ["Sunday"], hour = 3, minute = 0 }
  }

  volumes = {
    hana-data = {
      volume_path         = "hana-data-prod"
      service_level       = "Ultra"
      subnet_id           = azurerm_subnet.netapp.id
      storage_quota_in_gb = 2048
      protocols           = ["NFSv4.1"]
      network_features    = "Standard"
      enable_snapshot_policy = true

      export_policy_rules = [{
        rule_index          = 1
        allowed_clients     = ["10.20.1.0/24"]
        protocol            = "NFSv4.1"
        unix_read_write     = true
        root_access_enabled = true
      }]
    }

    hana-log = {
      volume_path         = "hana-log-prod"
      service_level       = "Ultra"
      subnet_id           = azurerm_subnet.netapp.id
      storage_quota_in_gb = 512
      protocols           = ["NFSv4.1"]
      export_policy_rules = [{
        rule_index      = 1
        allowed_clients = ["10.20.1.0/24"]
        protocol        = "NFSv4.1"
      }]
    }
  }

  tags = {
    env         = "prod"
    workload    = "sap-hana"
    cost_center = "FIN-880"
  }
}

# Downstream: hand the mount IP and path to a VM bootstrap script.
output "hana_data_mount" {
  value = "${module.netapp.volume_mount_ip_addresses["hana-data"][0]}:/${module.netapp.volume_paths["hana-data"]}"
}

Pin the module with ?ref=<tag> so a stack never silently picks up a breaking change — doubly important here, since subnet_id, volume_path, and service_level changes force volume recreation and can destroy data.

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/netapp/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-netapp-files?ref=v1.0.0"
}

inputs = {
  account_name = "..."
  resource_group_name = "..."
  location = "..."
  pool_name = "..."
  pool_service_level = "..."
  pool_size_in_tb = 4
  volumes = {}
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/netapp && 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
account_name string Yes NetApp account name (regional ANF container).
resource_group_name string Yes Resource group for the account, pool, and volumes.
location string Yes Azure region with ANF capacity.
pool_name string Yes Capacity pool name.
pool_service_level string Premium No Pool tier: Standard, Premium, or Ultra.
pool_size_in_tb number 4 No Provisioned pool size in TiB (1–2048).
pool_qos_type string Auto No Pool QoS type: Auto or Manual.
volumes map(object) Yes Volumes keyed by name (path, service level, delegated subnet, quota, protocols, export rules, snapshot/replication).
snapshot_policy object null No Optional account-level snapshot policy with hourly/daily/weekly schedules.
tags map(string) {} No Tags merged with module defaults.

Outputs

Name Description
account_id Resource ID of the NetApp account.
account_name Name of the NetApp account.
pool_id Resource ID of the capacity pool.
volume_ids Map of volume name → volume resource ID.
volume_mount_ip_addresses Map of volume name → list of mount target IP addresses.
volume_paths Map of volume name → export path.
snapshot_policy_id Snapshot policy resource ID, or null when none was created.

Enterprise scenario

An SAP team runs production HANA on Azure across centralindia (primary) and southindia (DR). The platform team publishes this module at v1.0.0 so every HANA system gets an identical layout: an Ultra-tier capacity pool, separate data and log volumes over NFSv4.1, export policies locked to the HANA application subnet, and a daily-plus-weekly snapshot policy with 14-day retention. The delegated snet-netapp subnet is enforced by the module’s subnet_id requirement, so no engineer can accidentally land a volume on a shared subnet and trip the API. For DR, a secondary volume in southindia carries a data_protection_replication block pointing at the primary’s resource ID with a 10-minute replication frequency — the entire primary-plus-DR topology is two reviewed module calls instead of dozens of portal clicks, and a quarterly audit confirms every volume is snapshot-protected and exported only to authorized clients.

Best practices

TerraformAzureNetApp FilesModuleIaC
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