IaC Azure

Terraform Module: Azure Backup Vault (Data Protection) — immutable, redundant backups in one reusable block

Quick take — Provision an Azure Data Protection Backup Vault with Terraform: system-assigned identity, redundancy and immutability controls, soft-delete retention, and backup policies wired up for production. 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 "backup_vault" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-backup-vault?ref=v1.0.0"

  name                = "..."  # Backup Vault name (3-50 chars, starts with a letter, no…
  resource_group_name = "..."  # Resource group for the vault.
  location            = "..."  # Azure region (e.g. `centralindia`).
}

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

What this module is

The Azure Backup Vault is the modern Data Protection control plane — distinct from the older Recovery Services Vault. It is the destination for backing up newer Azure-native workloads: Azure Disks, Blob Storage, Azure Database for PostgreSQL/MySQL Flexible Server, AKS clusters, and Azure Files (vaulted tier). Where a Recovery Services Vault leans on the legacy MARS/MABS agent model, the Backup Vault is built around BackupVaults in the Microsoft.DataProtection resource provider and uses declarative backup policies and backup instances.

This module wraps azurerm_data_protection_backup_vault so that every vault in your estate is created with the same opinionated defaults: a system-assigned managed identity (required so the vault can reach the data source’s resource and write recovery points), an explicit storage redundancy choice (LocallyRedundant, GeoRedundant, or ZoneRedundant), soft-delete with a sane retention window, and optional immutability to defend against ransomware. It also lets you ship one or more backup policies alongside the vault, so a consuming stack gets a vault and the policy it needs in a single module call instead of hand-wiring the data-protection resources every time.

When to use it

If you are still backing up classic IaaS VMs with the MARS agent or doing System Center DPM, you want a Recovery Services Vault instead — this module is deliberately Data-Protection-only.

Module structure

terraform-module-azure-backup-vault/
├── 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 {
  # Immutability can only be Locked once the vault has lived in Unlocked/Disabled.
  # We surface it as a variable but guard the apply order with this normalisation.
  immutability_state = var.immutability_state

  # Build a map of blob backup policies keyed by name for for_each stability.
  blob_policies = {
    for p in var.blob_backup_policies : p.name => p
  }
}

resource "azurerm_data_protection_backup_vault" "this" {
  name                = var.name
  resource_group_name = var.resource_group_name
  location            = var.location

  datastore_type = var.datastore_type
  redundancy     = var.redundancy

  # Soft delete: AfterRelease keeps deleted backup instances recoverable
  # for retention_duration before they are permanently purged.
  soft_delete                  = var.soft_delete
  retention_duration_in_days   = var.retention_duration_in_days

  # Defends recovery points against modification/early deletion.
  immutability = local.immutability_state

  identity {
    type = "SystemAssigned"
  }

  cross_region_restore_enabled = var.cross_region_restore_enabled

  tags = var.tags

  lifecycle {
    # Redundancy and datastore_type are immutable after creation in Azure;
    # prevent a silent destroy/recreate of a vault that may hold recovery points.
    ignore_changes = []
  }
}

# Operational backup policy for Azure Blob storage data sources.
resource "azurerm_data_protection_backup_policy_blob_storage" "this" {
  for_each = local.blob_policies

  name     = each.value.name
  vault_id = azurerm_data_protection_backup_vault.this.id

  # Operational (snapshot-based) retention lives on the source account.
  operational_default_retention_duration = each.value.operational_default_retention_duration

  # Vaulted retention requires a backup schedule + at least one rule.
  vault_default_retention_duration = each.value.vault_default_retention_duration
  backup_repeating_time_intervals  = each.value.backup_repeating_time_intervals
  time_zone                        = each.value.time_zone
}

# Optional: let the vault's managed identity write into a target storage account
# (Storage Account Backup Contributor) so blob backups actually succeed.
resource "azurerm_role_assignment" "vault_blob_contributor" {
  for_each = var.blob_backup_source_account_ids

  scope                = each.value
  role_definition_name = "Storage Account Backup Contributor"
  principal_id         = azurerm_data_protection_backup_vault.this.identity[0].principal_id
}

variables.tf

variable "name" {
  description = "Name of the Data Protection Backup Vault. Must be globally unique within the subscription's resource group scope."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z][a-zA-Z0-9-]{1,48}[a-zA-Z0-9]$", var.name))
    error_message = "Vault name must be 3-50 chars, start with a letter, contain only letters, numbers and hyphens, and not end with a hyphen."
  }
}

variable "resource_group_name" {
  description = "Resource group that will contain the Backup Vault."
  type        = string
}

variable "location" {
  description = "Azure region for the Backup Vault (e.g. 'centralindia', 'westeurope')."
  type        = string
}

variable "datastore_type" {
  description = "Default datastore for the vault. 'VaultStore' for long-term vaulted backups; 'OperationalStore' for snapshot-only; 'ArchiveStore' for tiering."
  type        = string
  default     = "VaultStore"

  validation {
    condition     = contains(["VaultStore", "OperationalStore", "ArchiveStore"], var.datastore_type)
    error_message = "datastore_type must be one of: VaultStore, OperationalStore, ArchiveStore."
  }
}

variable "redundancy" {
  description = "Storage redundancy for backup data. Immutable after creation."
  type        = string
  default     = "GeoRedundant"

  validation {
    condition     = contains(["LocallyRedundant", "GeoRedundant", "ZoneRedundant"], var.redundancy)
    error_message = "redundancy must be one of: LocallyRedundant, GeoRedundant, ZoneRedundant."
  }
}

variable "soft_delete" {
  description = "Soft-delete behaviour for deleted backup instances. 'AfterRelease' (recommended), 'On', or 'Off'."
  type        = string
  default     = "AfterRelease"

  validation {
    condition     = contains(["AfterRelease", "On", "Off"], var.soft_delete)
    error_message = "soft_delete must be one of: AfterRelease, On, Off."
  }
}

variable "retention_duration_in_days" {
  description = "Soft-delete retention in days (14-180). Only meaningful when soft_delete is 'On' or 'AfterRelease'."
  type        = number
  default     = 14

  validation {
    condition     = var.retention_duration_in_days >= 14 && var.retention_duration_in_days <= 180
    error_message = "retention_duration_in_days must be between 14 and 180."
  }
}

variable "immutability_state" {
  description = "Immutability setting. 'Disabled', 'Unlocked' (reversible), or 'Locked' (irreversible — cannot be turned off)."
  type        = string
  default     = "Unlocked"

  validation {
    condition     = contains(["Disabled", "Unlocked", "Locked"], var.immutability_state)
    error_message = "immutability_state must be one of: Disabled, Unlocked, Locked."
  }
}

variable "cross_region_restore_enabled" {
  description = "Enable cross-region restore. Requires redundancy = 'GeoRedundant'."
  type        = bool
  default     = false

  validation {
    condition     = var.cross_region_restore_enabled == false || var.redundancy == "GeoRedundant"
    error_message = "cross_region_restore_enabled can only be true when redundancy is 'GeoRedundant'."
  }
}

variable "blob_backup_policies" {
  description = "List of blob-storage backup policies to create in this vault."
  type = list(object({
    name                                   = string
    operational_default_retention_duration = optional(string, "P30D")
    vault_default_retention_duration       = optional(string, "P90D")
    backup_repeating_time_intervals        = optional(list(string), ["R/2026-01-01T02:00:00+00:00/P1D"])
    time_zone                              = optional(string, "India Standard Time")
  }))
  default = []
}

variable "blob_backup_source_account_ids" {
  description = "Map of friendly-name => storage account resource ID that the vault identity may back up (grants Storage Account Backup Contributor)."
  type        = map(string)
  default     = {}
}

variable "tags" {
  description = "Tags applied to the Backup Vault."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "Resource ID of the Backup Vault."
  value       = azurerm_data_protection_backup_vault.this.id
}

output "name" {
  description = "Name of the Backup Vault."
  value       = azurerm_data_protection_backup_vault.this.name
}

output "principal_id" {
  description = "Principal (object) ID of the vault's system-assigned managed identity — grant this RBAC on data sources."
  value       = azurerm_data_protection_backup_vault.this.identity[0].principal_id
}

output "tenant_id" {
  description = "Tenant ID of the vault's system-assigned managed identity."
  value       = azurerm_data_protection_backup_vault.this.identity[0].tenant_id
}

output "blob_backup_policy_ids" {
  description = "Map of blob backup policy name => policy resource ID."
  value       = { for k, v in azurerm_data_protection_backup_policy_blob_storage.this : k => v.id }
}

How to use it

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

  name                = "bvault-platform-prod-cin"
  resource_group_name = azurerm_resource_group.backup.name
  location            = "centralindia"

  datastore_type = "VaultStore"
  redundancy     = "GeoRedundant"

  soft_delete                  = "AfterRelease"
  retention_duration_in_days   = 30
  immutability_state           = "Unlocked" # promote to "Locked" once validated
  cross_region_restore_enabled = true

  blob_backup_policies = [
    {
      name                                   = "blob-daily-90d"
      operational_default_retention_duration = "P30D"
      vault_default_retention_duration       = "P90D"
      backup_repeating_time_intervals        = ["R/2026-01-01T19:30:00+00:00/P1D"] # 01:00 IST daily
      time_zone                              = "India Standard Time"
    }
  ]

  # Let the vault identity back up these storage accounts.
  blob_backup_source_account_ids = {
    appdata = azurerm_storage_account.appdata.id
  }

  tags = {
    environment = "prod"
    owner       = "platform-team"
    costcenter  = "cc-1042"
  }
}

# Downstream: wire a blob backup instance to the policy the module created.
resource "azurerm_data_protection_backup_instance_blob_storage" "appdata" {
  name               = "bi-appdata-blob"
  vault_id           = module.backup_vault_data_protection_prod.id
  location           = "centralindia"
  storage_account_id = azurerm_storage_account.appdata.id
  backup_policy_id   = module.backup_vault_data_protection_prod.blob_backup_policy_ids["blob-daily-90d"]

  depends_on = [module.backup_vault_data_protection_prod]
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  name = "..."
  resource_group_name = "..."
  location = "..."
}

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

cd live/prod/backup_vault && 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 Backup Vault name (3-50 chars, starts with a letter, no trailing hyphen).
resource_group_name string Yes Resource group for the vault.
location string Yes Azure region (e.g. centralindia).
datastore_type string "VaultStore" No VaultStore, OperationalStore, or ArchiveStore.
redundancy string "GeoRedundant" No LocallyRedundant, GeoRedundant, or ZoneRedundant. Immutable after creation.
soft_delete string "AfterRelease" No AfterRelease, On, or Off.
retention_duration_in_days number 14 No Soft-delete retention, 14-180 days.
immutability_state string "Unlocked" No Disabled, Unlocked, or Locked (irreversible).
cross_region_restore_enabled bool false No Enable CRR; requires GeoRedundant.
blob_backup_policies list(object) [] No Blob-storage backup policies to create in the vault.
blob_backup_source_account_ids map(string) {} No Storage accounts to grant the vault identity Storage Account Backup Contributor.
tags map(string) {} No Tags applied to the vault.

Outputs

Name Description
id Resource ID of the Backup Vault.
name Name of the Backup Vault.
principal_id Object ID of the vault’s system-assigned managed identity.
tenant_id Tenant ID of the vault’s managed identity.
blob_backup_policy_ids Map of blob backup policy name => policy resource ID.

Enterprise scenario

A fintech running a multi-tenant SaaS on Azure must keep 90 days of immutable, geo-redundant backups of customer document blobs to satisfy an auditor’s ransomware-recovery requirement. The platform team consumes this module once per regional landing zone, pinned to v1.0.0, with redundancy = "GeoRedundant", cross_region_restore_enabled = true, and a blob-daily-90d policy. After a two-week validation window they flip immutability_state to "Locked", which guarantees no operator — not even a compromised Owner — can shorten retention or delete recovery points before they expire.

Best practices

TerraformAzureBackup Vault (Data Protection)ModuleIaC
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