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:
azurerm_netapp_account— the regional container that owns pools, volumes, AD connections, and snapshot policies. It holds no capacity itself.azurerm_netapp_pool(capacity pool) — a billed block of provisioned capacity at a fixedservice_level(Standard,Premium, orUltra) andsize_in_tb. Throughput is a function of the tier and the provisioned size;qos_typecontrols whether throughput is allocated automatically per volume or set manually.azurerm_netapp_volume— the actual mountable file system, carved from a pool, exported overprotocols(["NFSv3"],["NFSv4.1"], or["CIFS"]), sized bystorage_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
- You are migrating SAP HANA, Oracle, or other latency-sensitive databases that need shared POSIX file storage with predictable, high throughput, and managed-disk or blob won’t meet the IOPS/latency SLA.
- You run HPC, EDA, rendering, or AI/ML pipelines that need a high-throughput scratch or dataset volume mounted across many compute nodes over NFS.
- You are lift-and-shifting an on-prem NetApp filer and want the same SnapMirror-style snapshots and cross-region replication semantics in Azure.
- You need consistent governance — every volume on a properly delegated subnet, export policies reviewed in PR, snapshot retention applied uniformly — across dev/stage/prod rather than portal-built snowflakes.
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, sincesubnet_id,volume_path, andservice_levelchanges 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 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/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
- Always use a dedicated subnet delegated to
Microsoft.Netapp/volumes. The delegation makes the subnet exclusive to ANF — never share it with VMs or Private Endpoints. Size it generously (a/24) up front, because changing a volume’s subnet forces recreation and data loss. - Lock down export policies; never leave NFS wide open. Always set
allowed_clientsto the specific application subnet CIDR and pick the matchingprotocol. Default exports without rules are far too permissive for production data. - Match
service_levelto the workload and right-size the pool. Ultra for HANA data/log and other latency-critical I/O, Premium for general database and HPC, Standard for capacity-oriented shares. Throughput scales with both tier and provisionedsize_in_tb, so over-provisioning the pool wastes money while under-provisioning starves volumes. - Turn on snapshots and replication for anything that matters. Attach the snapshot policy with sensible daily/weekly retention, and for DR-critical volumes deploy a secondary volume with
data_protection_replication. Snapshots are near-instant and space-efficient; treat them as your first line of recovery. - Prefer
Standardnetwork features. Standard unlocks NSGs, UDRs, and higher IP limits on the delegated subnet and is required to use the lower 2 TiB pool minimum — Basic exists mainly for legacy compatibility. - Pin protocols and align export rules. Converting between NFSv3 and NFSv4.1 requires updating export-policy
protocolvalues in lockstep; keep theprotocolslist to a single, deliberate value and review changes carefully to avoid drift.