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
- You are backing up Azure-native data sources (managed disks, blobs, PostgreSQL/MySQL Flexible Server, AKS, Azure Files vaulted tier) rather than IaaS VMs via the MARS agent.
- You need immutable, locked backups for compliance (ransomware protection, regulatory hold) and want immutability set as code, not clicked in the portal.
- You run a multi-subscription landing zone and want every team’s vault to be identical in redundancy, soft-delete, and identity posture.
- You want the vault and its operational backup policy (retention rules, schedule) provisioned together and version-pinned.
- You are standardising backup tagging and naming for cost allocation and audit.
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 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/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
- Start
Unlocked, thenLocked. ImmutabilityLockedis irreversible at the vault level. Run withUnlockeduntil backup schedules and restores are proven, then lock it — locking too early can strand a misconfigured retention rule you can never relax. - Match redundancy to RPO and cost.
GeoRedundantis the only option that supports cross-region restore but costs the most; useZoneRedundantfor in-region HA andLocallyRedundantonly for dev/test. Redundancy is immutable after creation, so choose deliberately. - Grant the identity least privilege at the data source, not the subscription. The vault’s system-assigned identity needs
Storage Account Backup Contributor(blobs) orDisk Backup Reader/Disk Snapshot Contributor(disks) scoped to each source — never grant it broad roles at the subscription. - Keep soft-delete on with at least 14 days.
AfterReleaseplus a 14-30 day window gives you a recovery path if a backup instance is deleted maliciously or by mistake; turning soft-delete off removes that safety net entirely. - Name and tag for cost allocation. Use a
bvault-<workload>-<env>-<region>convention and tagcostcenter/owner; Backup Vault charges (protected-instance fees plus storage) are easy to attribute when vaults are tagged per team. - One vault per region per workload domain. Backup Vaults are regional; co-locate the vault with the data sources it protects to avoid cross-region data-transfer cost and latency, and isolate blast radius between workload domains.