Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Virtual Machine Restore Point Collection Terraform Module

Creates the container that a virtual machine's restore points live in — scoped to one machine, holding nothing itself — targeting hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Blast radius


🧩 Overview

  • 📦 Creates the restore point collection for exactly one virtual machine.
  • 🔗 Emits the id the sibling restore-point module needs — it is the parent, created first.
  • 🏷️ Carries tags, the only argument that updates in place, and the only Terraform-managed place to attach a cost label.
  • 🚩 Reports where the machine actually is, since the collection need not share its resource group.
  • ⛔ Names the cascade no plan shows: destroying the collection destroys every restore point inside it, irreversibly.

💡 Why it matters: a collection is metadata — it stores nothing and costs nothing. Everything valuable is in the restore points inside it, and everything dangerous follows from that: four of its five arguments are force-new, a replacement destroys the container first, and a restore point captures a moment that cannot be re-taken.


❤️ Support this project

If this module saved you time:


🗺️ Where this fits in the family

flowchart TB
    VM["terraform-azurerm-linux-virtual-machine or -windows-virtual-machine"]
    RC["terraform-azurerm-virtual-machine-restore-point-collection"]
    RP["terraform-azurerm-virtual-machine-restore-point"]
    DISK["terraform-azurerm-managed-disk -- excluded when unsupported"]
    RESTORE["restoring -- create disks, then a NEW machine. NOT a Terraform operation"]

    VM -->|"id to source_virtual_machine_id -- ONE machine per collection"| RC
    RC -->|"id to virtual_machine_restore_point_collection_id"| RP
    DISK -.->|"id to excluded_disks -- the documented remedy, not an optimisation"| RP
    RP -.->|"produces recovery MATERIAL only"| RESTORE

    classDef self fill:#0078D4,stroke:#004578,color:#ffffff
    classDef keystone fill:#004578,stroke:#002B4A,color:#ffffff
    classDef ext fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    class RC self
    class VM keystone
    class RP,DISK,RESTORE ext
Loading

The collection sits between the machine and its restore points, and the direction matters: it is created first, it names the machine, and the restore-point module references it. The dashed edge to restoring is dashed because restoring is not a Terraform operation at all.


🧬 What this module builds

flowchart TB
    IN1["name -- the provider validates NOTHING"]
    IN2["resource_group_name / location"]
    IN3["source_virtual_machine_id -- ONE machine, force-new"]
    IN4["tags -- the ONLY in-place update"]
    RES["azurerm_virtual_machine_restore_point_collection.this"]
    OUT1["id -- consumed as the restore point parent"]
    OUT2["source_virtual_machine_name / spans_resource_groups"]
    OUT3["posture flags -- 500 per machine, 10000 per region"]
    OUT4["destroying this destroys every restore point inside"]

    IN1 --> RES
    IN2 --> RES
    IN3 --> RES
    IN4 --> RES
    RES --> OUT1
    RES --> OUT2
    RES --> OUT3
    RES --> OUT4

    classDef self fill:#0078D4,stroke:#004578,color:#ffffff
    classDef ext fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    class RES self
    class IN1,IN2,IN3,IN4,OUT1,OUT2,OUT3,OUT4 ext
Loading
Resource Name Cardinality
azurerm_virtual_machine_restore_point_collection this single

One resource, five arguments, and only tags updates in place.


✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module — the caller configures provider "azurerm" { features {} }, including authentication

Schema notes that bite

  • 🔴 Destroying the collection destroys every restore point in it. Four of five arguments are force-new, and a replacement destroys before it creates. There is no soft delete.
  • 🔴 name carries NO validation whatsoever — not a length limit, not a character rule, not even a non-empty check. The sibling restore-point resource has the same non-rule.
  • 🟠 Only tags updates in place. The provider retrieves the collection, replaces the tags and writes it back; it sends nothing else.
  • 🟠 The 500-restore-point limit is per MACHINE, not per collection. Adding collections does not raise it.
  • 🟠 10,000 collections per subscription per region is the other documented ceiling.
  • 🟠 Moving the source virtual machine breaks this. The collection's source reference is not updated by a resource group or subscription move, so new restore points fail against the old identity.
  • 🟡 One collection protects exactly one machine — including each member of a Flexible-orchestration scale set, separately.
  • 🟡 Uniform-orchestration scale sets are not supported at all, and the ID looks the same.
  • 🟡 instantAccess is not exposed by the provider's resource, though Azure's collection has it.
  • 🟡 No lock is taken on the source machine — which matters for the child, where concurrent creates are unsupported.
  • ✅ An import guard exists, and all four timeouts are honoured — the child module carries only three.

🔑 Required Azure RBAC Roles / Permissions

Least-privilege, scoped to the resource group holding the collection.

Permission Scope Why
Microsoft.Compute/restorePointCollections/read the resource group Refresh, and the create's import check.
Microsoft.Compute/restorePointCollections/write the resource group Create and update.
Microsoft.Compute/restorePointCollections/delete the resource group Destroy — and this takes the restore points with it.
Microsoft.Compute/virtualMachines/read the source machine The collection references it, possibly across resource groups.

Virtual Machine Contributor at both scopes covers all four.

🔒 Delete on this collection is delete on every recovery point in it. A role assignment that looks like it only permits managing a container permits destroying the recovery material inside, irreversibly — no soft delete, no recycle bin, and a restore point cannot be recreated because it captured a moment that has passed.

⚠️ The snapshots are the machine's data. This resource accepts and emits no credential, but the restore points it contains hold point-in-time copies of every captured managed disk. The access control that matters is on this collection's resource group — which may not be the machine's.


Azure Prerequisites

  • The Microsoft.Compute resource provider registered in the target subscription.
  • An existing virtual machine using managed disks. Unmanaged disks are unsupported.
  • A resource group and region. The collection should be in the machine's region; nothing checks that.
  • Not a Uniform-orchestration scale set — unsupported, and indistinguishable from a supported machine by Resource ID.
  • Quota headroom: 10,000 collections per subscription per region, 500 restore points per machine.

📁 Module Structure

terraform-azurerm-virtual-machine-restore-point-collection/
├── providers.tf    # required_version >= 1.12.0, azurerm ~> 4.0, no provider block
├── variables.tf    # 6 inputs, 4 validations -- the placement, the source machine, the tags
├── main.tf         # one keystone `this`; a dynamic timeouts block
├── outputs.tf      # 28 outputs -- id first, then the source machine, then the cascade
├── README.md       # this file
├── SCOPE.md        # the cross-module contract
├── LICENSE         # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "restore_point_collection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-machine-restore-point-collection.git?ref=v1.0.0"

  name                = "rpc-app01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  source_virtual_machine_id = module.app_vm.id
}

Creating the collection captures nothing. The restore points inside it do that.


🔌 Cross-Module Contract

Consumes

Input Type Source
name string the caller
resource_group_name string terraform-azurerm-resource-group → name
location string terraform-azurerm-resource-group → location
source_virtual_machine_id string terraform-azurerm-linux-virtual-machine → id
tags map(string) the caller
timeouts object(...) the caller — four keys

Emits

Output Description Consumed by
id Resource ID of the collection the restore-point module's virtual_machine_restore_point_collection_id
source_virtual_machine_name The protected machine, parsed reporting
spans_resource_groups Whether collection and machine are co-located review
the posture flags the cascade, the per-machine limits, the move hazard change review

📚 Example Library

1 · The smallest real call
module "restore_point_collection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-machine-restore-point-collection.git?ref=v1.0.0"

  name                = "rpc-app01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  source_virtual_machine_id = module.app_vm.id
}

ℹ️ Four required arguments. The collection is metadata — it stores nothing and costs nothing until restore points are created inside it.

2 · Wiring it to the restore-point module
module "restore_point_collection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-machine-restore-point-collection.git?ref=v1.0.0"

  name                = "rpc-app01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  source_virtual_machine_id = module.app_vm.id
}

module "nightly_restore_point" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-machine-restore-point.git?ref=v1.0.0"

  name = "rp-2026-08-13"

  virtual_machine_restore_point_collection_id = module.restore_point_collection.id
}

💡 The collection is the parent. It names the machine; the restore point does not, and takes no virtual machine argument at all.

⚠️ Referencing the collection's id gives Terraform the ordering for free — no depends_on needed.

3 · The collection in a different resource group from the machine
module "restore_point_collection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-machine-restore-point-collection.git?ref=v1.0.0"

  name                = "rpc-app01"
  resource_group_name = var.backup_resource_group_name # the backup team's group
  location            = var.backup_resource_group_location

  source_virtual_machine_id = module.app_vm.id # the application team's group
}

output "placement" {
  value = {
    collection_group = module.restore_point_collection.resource_group_name
    machine_group    = module.restore_point_collection.source_resource_group_name
    crosses          = module.restore_point_collection.spans_resource_groups
  }
}

💡 Legitimate and common — separating the recovery material from the workload is often the point.

🔒 It also separates who can read the snapshots from who can read the machine. A reviewer looking at the application's resource group sees no sign that point-in-time copies of its disks exist elsewhere. spans_resource_groups reports it.

4 · Tagging for cost, because the restore points cannot be tagged
module "restore_point_collection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-machine-restore-point-collection.git?ref=v1.0.0"

  name                = "rpc-app01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  source_virtual_machine_id = module.app_vm.id

  tags = {
    environment    = "production"
    cost-centre    = "platform"
    retention-days = "30"
  }
}

ℹ️ tags is the only argument that updates in place — and the only Terraform-managed label available for attributing snapshot cost, because the restore points inside expose no tags of their own.

💡 The retention-days tag above is a label, not a mechanism. Nothing in this module or the child enforces retention; that is the caller's automation.

5 · One collection per machine, across a fleet
module "restore_point_collection" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-machine-restore-point-collection.git?ref=v1.0.0"
  for_each = var.protected_virtual_machine_ids # map of name => vm id

  name                = "rpc-${each.key}"
  resource_group_name = module.rg.name
  location            = module.rg.location

  source_virtual_machine_id = each.value

  tags = module.rg.tags
}

ℹ️ A collection protects exactly one machine. source_virtual_machine_id is singular and force-new, so a fleet means a collection each.

⚠️ That includes the individual members of a Flexible-orchestration scale set and of an availability set — Microsoft documents restore points as created per instance. A Uniform-orchestration scale set is not supported at all, and its members' IDs look no different here.

6 · Why the force-new arguments deserve care
module "restore_point_collection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-machine-restore-point-collection.git?ref=v1.0.0"

  name                = "rpc-app01-v2" # <-- renaming is a REPLACEMENT
  resource_group_name = module.rg.name
  location            = module.rg.location

  source_virtual_machine_id = module.app_vm.id
}

⚠️ A replacement destroys the collection first — taking every restore point in it. Those are recovery points for moments that have passed; nothing can recreate them. Renaming a collection is not a cosmetic change.

💡 force_new_fields lists all four, and destroying_this_destroys_every_restore_point_inside_it is the output to read beside it.

7 · Reading the limits before automating
module "restore_point_collection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-machine-restore-point-collection.git?ref=v1.0.0"

  name                = "rpc-app01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  source_virtual_machine_id = module.app_vm.id
}

output "limits_to_design_around" {
  value = {
    per_machine       = module.restore_point_collection.the_five_hundred_restore_point_limit_is_per_machine_not_per_collection
    per_region        = module.restore_point_collection.ten_thousand_collections_per_subscription_per_region
    holds_nothing     = module.restore_point_collection.the_collection_holds_nothing_and_the_restore_points_hold_everything
  }
}

⚠️ The 500-restore-point ceiling is per machine, irrespective of how many collections they are spread across. A design that creates a collection per backup does not raise the ceiling — it only approaches the 10,000-collection one faster.

💡 Reuse one collection per machine and manage retention inside it. That is what the shape is for.

8 · The move that breaks everything, later
module "restore_point_collection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-machine-restore-point-collection.git?ref=v1.0.0"

  name                = "rpc-app01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  source_virtual_machine_id = module.app_vm.id
}

output "before_you_move_the_vm" {
  value = module.restore_point_collection.moving_the_source_virtual_machine_breaks_this
}

⚠️ Do not move the source machine between resource groups or subscriptions while restore points exist. Microsoft states the collection's source reference is not updated by the move, so the Resource Manager IDs stop matching: new restore point creation fails against the old identity, and the existing points refer to a machine that is no longer at that path.

💡 Clean up the collection before the move, and create a new one afterwards. There is no repair.

9 · What NOT to do
# WRONG -- a scale set ID where a virtual machine belongs.
source_virtual_machine_id = var.app_scale_set_id

ℹ️ Refused at terraform plan, offline and without credentials. The check is anchored to .../virtualMachines/<vm>. Restore points are created per instance — and a Uniform-orchestration scale set is unsupported outright.

# WRONG -- a disk, or the collection's own ID.
source_virtual_machine_id = var.data_disk_id

ℹ️ Also refused, for the same anchoring reason.

# ACCEPTED BY THE PROVIDER, refused here -- an empty name.
name = ""

⚠️ The provider applies no validation to name at all — not a length limit, not a character rule, not even a non-empty check. This module adds only the non-empty check, because an empty name cannot form a Resource ID. Nothing further is invented, because there is no documented rule to mirror.

10 · Instant Access, and what this module cannot do
module "restore_point_collection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-machine-restore-point-collection.git?ref=v1.0.0"

  name                = "rpc-app01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  source_virtual_machine_id = module.app_vm.id
}

output "not_managed_here" {
  value = module.restore_point_collection.instant_access_is_not_exposed_by_this_provider_resource
}

ℹ️ Azure's restore point collection carries an instantAccess property, which shortens the time to restore a disk. The provider's resource does not expose it, so a collection created here has whatever default Azure applies, and enabling or disabling it is an out-of-band change this module will neither make nor detect.

⚠️ That means an operator can change it in the portal and no plan will ever notice.

11 · Reading the posture outputs before applying
module "restore_point_collection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-machine-restore-point-collection.git?ref=v1.0.0"

  name                = "rpc-app01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  source_virtual_machine_id = module.app_vm.id
}

output "collection_posture" {
  value = {
    holds_nothing      = module.restore_point_collection.the_collection_holds_nothing_and_the_restore_points_hold_everything
    destroy_cascades   = module.restore_point_collection.destroying_this_destroys_every_restore_point_inside_it
    one_machine_only   = module.restore_point_collection.one_collection_protects_exactly_one_virtual_machine
    uniform_unsupported = module.restore_point_collection.uniform_orchestration_scale_sets_are_not_supported
    no_lock            = module.restore_point_collection.the_provider_takes_no_lock_on_the_source_virtual_machine
    tags_only_update   = module.restore_point_collection.only_tags_change_in_place
    force_new          = module.restore_point_collection.force_new_fields
  }
}

🔒 Every one of these except force_new is a constant true. They are outputs rather than prose because each is consequential, invisible in state, and inferable from nothing else in the plan — and because an output can be surfaced in a review pipeline, where a README cannot.

12 · Serialising restore points, which nothing else will
module "restore_point_collection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-machine-restore-point-collection.git?ref=v1.0.0"

  name                = "rpc-app01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  source_virtual_machine_id = module.app_vm.id
}

module "restore_point_first" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-machine-restore-point.git?ref=v1.0.0"

  name                                        = "rp-full"
  virtual_machine_restore_point_collection_id = module.restore_point_collection.id
}

# Chained deliberately -- Azure does not support concurrent creation for one
# machine, and neither resource takes a lock.
module "restore_point_second" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-machine-restore-point.git?ref=v1.0.0"

  name                                        = "rp-crash"
  virtual_machine_restore_point_collection_id = module.restore_point_collection.id
  crash_consistency_mode_enabled              = true

  depends_on = [module.restore_point_first]
}

⚠️ depends_on here is load-bearing, not decorative. Microsoft states that concurrent creation of restore points for the same machine is unsupported, and the_provider_takes_no_lock_on_the_source_virtual_machine is true — Terraform would otherwise start both at once.

⚠️ Azure also throttles creation to 3 per hour per machine, and the two consistency modes have different minimum intervals. Two in one apply is fine; a fleet-wide loop is not.

13 · 🏗️ End-to-end composition
provider "azurerm" {
  features {}
}

module "rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-app-eastus"
  location = "eastus"

  tags = {
    environment = "production"
    workload    = "line-of-business-app"
  }
}

module "app_vnet" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network.git?ref=v1.0.0"

  name                = "vnet-app-eastus"
  resource_group_name = module.rg.name
  location            = module.rg.location
  address_space       = ["10.60.0.0/16"]

  subnets = {
    snet-app = {
      address_prefixes = ["10.60.1.0/24"]
    }
  }

  tags = module.rg.tags
}

module "app_nic" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface.git?ref=v1.0.0"

  name                = "nic-app01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  ip_configurations = {
    internal = {
      subnet_id                     = module.app_vnet.subnet_ids["snet-app"]
      private_ip_address_allocation = "Dynamic"
    }
  }

  tags = module.rg.tags
}

module "app_vm" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-linux-virtual-machine.git?ref=v1.0.0"

  name                = "vm-app01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  size           = "Standard_D2s_v5"
  admin_username  = "azureadmin"

  network_interface_ids = [module.app_nic.id]

  tags = module.rg.tags
}

# The container. Metadata only -- it stores nothing.
module "restore_point_collection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-machine-restore-point-collection.git?ref=v1.0.0"

  name                = "rpc-app01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  source_virtual_machine_id = module.app_vm.id

  tags = merge(module.rg.tags, {
    contains = "point-in-time-disk-snapshots"
  })
}

# The capture. This is what stores data and accrues cost.
module "app_restore_point" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-machine-restore-point.git?ref=v1.0.0"

  name = "rp-2026-08-13T0400Z"

  virtual_machine_restore_point_collection_id = module.restore_point_collection.id
}

output "protection" {
  value = {
    machine          = module.app_vm.id
    collection       = module.restore_point_collection.id
    restore_point    = module.app_restore_point.id
    consistency      = module.app_restore_point.requested_consistency_mode
    crosses_groups   = module.restore_point_collection.spans_resource_groups
    destroy_cascades = module.restore_point_collection.destroying_this_destroys_every_restore_point_inside_it
  }
}

💡 Terraform derives the ordering from the Resource ID references — the restore point waits for the collection, which waits for the machine.

⚠️ consistency reports (omitted -- Azure defaults to ApplicationConsistent) here, which is the stronger of the two modes and the right default. It is a choice, not an absence.

🔒 The contains tag is deliberate. The collection's resource group now holds point-in-time copies of the machine's disks, and a tag is the cheapest way to make that visible to whoever reviews the group later.


📥 Inputs

Required: name, resource_group_name, location, source_virtual_machine_id Optional: tags, timeouts

This module carries the universal tags tail; the sibling restore-point module does not, because the provider exposes tags here and not there.

Full input schemas
variable "name" {
  type = string
  # REQUIRED, FORCE-NEW. The provider applies NO validation to this field at
  # all. This module adds only a non-empty check.
}

variable "resource_group_name" {
  type = string
  # REQUIRED, FORCE-NEW. Need not match the source machine's.
}

variable "location" {
  type = string
  # REQUIRED, FORCE-NEW. Deliberately NOT validated against a region list.
}

variable "source_virtual_machine_id" {
  type = string
  # REQUIRED, FORCE-NEW. Anchored to .../virtualMachines/<vm>, so a scale set
  # is refused. ONE machine per collection.
}

variable "tags" {
  type    = map(string)
  default = {}
  # The ONLY argument that updates in place.
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    update = optional(string)
    delete = optional(string)
  })
  default = null
  # All four honoured. The CHILD module carries only three.
}

🧾 Outputs

Output Type Notes
id string .../restorePointCollections/<name> — the restore-point module's parent
name string As configured; force-new
resource_group_name / location string The collection's own placement
source_virtual_machine_id / source_virtual_machine_name string The protected machine
source_resource_group_name / source_subscription_id string Where that machine is
tags map(string) The only in-place-updatable argument
spans_resource_groups bool Whether collection and machine are co-located
tag_count number How many tags are applied
the_collection_holds_nothing_and_the_restore_points_hold_everything bool Constant true
destroying_this_destroys_every_restore_point_inside_it bool Constant true
the_five_hundred_restore_point_limit_is_per_machine_not_per_collection bool Constant true
ten_thousand_collections_per_subscription_per_region bool Constant true
moving_the_source_virtual_machine_breaks_this bool Constant true
one_collection_protects_exactly_one_virtual_machine bool Constant true
uniform_orchestration_scale_sets_are_not_supported bool Constant true
instant_access_is_not_exposed_by_this_provider_resource bool Constant true
only_tags_change_in_place bool Constant true
the_provider_takes_no_lock_on_the_source_virtual_machine bool Constant true
an_import_guard_exists_on_this_resource bool Constant true
a_deleted_collection_disappears_from_state_without_an_error bool Constant true
all_four_timeouts_are_honoured_here bool Constant true
force_new_fields list(string) Everything except tags
fields_that_can_change_after_creation list(string) tags only
fields_azure_returns_on_read list(string) Where drift is detectable
no_secret_is_accepted_or_emitted_by_this_module bool Constant true — read the caveat

🧠 Architecture Notes

The collection holds nothing. It names a virtual machine and groups restore points; the point-in-time managed-disk snapshots, and the storage charges, belong to the restore points inside it. An empty collection stores nothing and costs nothing. That is worth understanding first, because everything else here follows from it — including why the destroy is so much more dangerous than the create.

Destroying the collection destroys the recovery points. Four of the five arguments are force-new, and a replacement destroys before it creates. A restore point captured a moment that has passed and cannot be recreated; there is no soft delete and no recycle bin. Renaming a collection, moving it between resource groups, or changing its region are all, in effect, "delete every recovery point for this machine".

The limits are counted where you would not expect. The 500-restore-point ceiling is per virtual machine, irrespective of how many collections the points are spread across — so a design that creates a collection per backup does not raise it, and approaches the 10,000-collections-per-subscription-per-region ceiling faster. Reuse one collection per machine.

Moving the machine breaks this, silently and later. Microsoft states that a machine with restore points should not be moved between resource groups or subscriptions: the collection's source reference is not updated, so the Resource Manager IDs stop matching. New restore point creation then fails against the old identity, and the existing points refer to a machine that is not at that path. Clean up the collection before the move.

Nothing here is validated by the provider. name carries no rule of any kind — not length, not characters, not even non-empty. This module adds the non-empty check alone, because an empty name cannot form a Resource ID, and adds nothing more because there is no documented rule to mirror. The sibling restore-point resource has exactly the same non-rule, which is the only thing the two names agree on.

Only tags change in place. The provider retrieves the collection, replaces the tags and writes it back — nothing else. That makes tags the only Terraform-managed label available for attributing snapshot cost, since the restore points expose none of their own.

No lock, and it matters downstream. Neither this resource nor the child takes an internal lock, while Microsoft states that concurrent creation of restore points for one machine is unsupported. A configuration that creates several restore points must serialise them itself — see example 12.

Instant Access is invisible here. Azure's collection carries an instantAccess property; the provider's resource does not expose it. A collection created here takes whatever default Azure applies, and someone toggling it in the portal will never show up in a plan.

lifecycle is not valid inside a module block. A caller cannot add prevent_destroy here — which matters more than usual given the cascade. Where accidental destruction is a real risk, apply a CanNotDelete management lock to the resource group, noting that a lock prevents deletion but not replacement, and four of five arguments force replacement.


🧱 Design Principles

Concern This module's position What the caller must type to change it
Parent/child direction The collection is the parent — created first, referenced by the restore point —
The destroy cascade Emitted as its own output, separate from force_new_fields, because readers act on the consequence not the mechanism —
name Non-empty only — the provider validates nothing, and inventing a rule would reject legal names —
source_virtual_machine_id Anchored to virtualMachines, so a scale set is refused —
location Not validated against a region list — Azure's region set changes —
Service limits Emitted as constant outputs, never enforced — none is derivable from a single resource —
spans_subscriptions Deliberately absent — the collection's own subscription comes from the provider and cannot be read —
tags Present, because the provider exposes it here — and it is the only cost label available —

🔒 There is no security toggle here. The posture is who can delete this collection and who can read the resource group its snapshots land in.


🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module by tag — ?ref=v1.0.0 — never a branch.

Plan-only: this repository performs no cloud apply. A human applies from CI — and for this resource, reads the destroy plan carefully, because a replacement is not recoverable.


🧪 Testing

What terraform validate covers, offline and without credentials:

  • name, resource_group_name and location are non-empty and carry no leading or trailing whitespace — each is sent to Azure exactly as written, and the provider validates name not at all;
  • name is at most 80 characters and is not a pasted Resource ID (two separate checks, so each failure gets its own message);
  • resource_group_name is at most 90 characters and does not end in a period;
  • source_virtual_machine_id is a virtualMachines Resource ID with no surrounding whitespace;
  • tags holds at most 50 entries, with keys up to 512 characters and values up to 256.
  • The anchored source_virtual_machine_id check — a scale set, a disk and a collection ID are all refused.

What only terraform plan exercises (credentials required):

  • That the resource group and the source machine exist.
  • The import guard, if a collection of this name already exists in the group.

What only terraform apply reveals:

  • Whether the machine uses managed disks, and whether it is a Uniform-orchestration scale set member.
  • Whether the subscription is at its collection quota.

What nothing reveals at all:

  • Whether instantAccess has been toggled out of band.
  • Whether someone is about to move the source machine.

ℹ️ terraform console fires root-module variable validations, unlike terraform validate on a calling configuration. It is the honest offline harness for the first list and for both derived flags.


💬 Example Output

Outputs:

id                          = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-app-eastus/providers/Microsoft.Compute/restorePointCollections/rpc-app01"
name                        = "rpc-app01"
resource_group_name         = "rg-app-eastus"
location                    = "eastus"
source_virtual_machine_name = "vm-app01"
source_resource_group_name  = "rg-app-eastus"
spans_resource_groups       = false
tag_count                   = 3
force_new_fields            = [
  "name",
  "resource_group_name",
  "location",
  "source_virtual_machine_id",
]
fields_that_can_change_after_creation = [
  "tags",
]
destroying_this_destroys_every_restore_point_inside_it = true

ℹ️ fields_that_can_change_after_creation holding only tags is the whole risk profile of this resource in one line: everything else is a replacement, and a replacement is a cascade.


🔍 Troubleshooting

Symptom Cause Fix
name must not be empty or whitespace A blank name The provider validates this field not at all; this module adds only the non-empty check
source_virtual_machine_id must be a virtual machine Resource ID A scale set, a disk, or a collection ID The check is anchored to virtualMachines/<vm>. Restore points are per instance
Apply fails with an already-exists error A collection of this name is already in the resource group The create carries an import guard. terraform import it, or choose another name
Apply fails on the source machine It uses unmanaged disks, or it is a Uniform-orchestration scale set member Neither is supported, and neither is visible from a Resource ID
Apply fails at the subscription level 10,000 collections per subscription per region reached Reuse one collection per machine rather than one per backup
Restore point creation fails though the collection is fine The machine already has 500 restore points, across all collections The ceiling is per machine. Delete older points
New restore points fail after a resource-group move The collection's source reference still points at the old ID Not repairable. Delete the collection and create a new one
A rename destroyed the recovery points name is force-new, and a replacement destroys first Expected and irreversible. Read force_new_fields before editing
The collection reappears as a create on every plan It was removed outside Terraform; the read marks it gone silently Applying recreates an empty collection — the restore points are gone
Instant Access changed and no plan noticed The provider does not expose instantAccess Out-of-band by design. Manage it outside Terraform or not at all
Two restore points failed when applied together Concurrent creation for one machine is unsupported, and nothing locks Chain them with depends_on — see example 12

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."