Creates the bucket inside a Site Recovery fabric that replicated machines live in, targeting
hashicorp/azurerm ~> 4.0.
- 📦 Manages
azurerm_site_recovery_protection_container— the fabric's direct child, and where replicated machines live. - 🔗 Consumes
terraform-azurerm-site-recovery-fabric'sname, so the fabric arrives as a bare string. - 🧮 Reconstructs the container's, fabric's and vault's paths at plan time, in the form the fabric modules already emit.
- 🕳️ The create payload is completely empty. There is nothing to configure — only where the container is.
- 🔒 Nothing here can be edited. No Update function, so all four arguments are force-new and
timeoutshas three keys. ⚠️ Seven constants, including the one that makes this resource unlike three of its four siblings: it does not absorb the API's HTTP 400.
💡 Why it matters: the fabric arrives as a NAME, which means Terraform builds no ordering edge from a literal and a name matching somebody else's fabric is accepted without complaint. This module emits the fabric's path in the same form both fabric-creating modules emit, so a
checkblock can prove the container landed where the composition intended.
If this module saved you time:
- ⭐ Star the repository — it helps others find it.
- 💼 Connect on LinkedIn — linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee — buymeacoffee.com/microsoftexpert
This module is terraform-azurerm-site-recovery-protection-container, one of the fourteen blue nodes — all fourteen authored site_recovery_* modules share this diagram. The dark node is not a module: it is the single ARM resource type that the fabric and Hyper-V site modules both write, which is why those two can silently collide.
flowchart TB
RG["terraform-azurerm-resource-group"]
VAULT["terraform-azurerm-recovery-services-vault"]
FABRIC["terraform-azurerm-site-recovery-fabric"]
HVS["terraform-azurerm-site-recovery-services-vault-hyperv-site"]
PC["terraform-azurerm-site-recovery-protection-container"]
POLICY["terraform-azurerm-site-recovery-replication-policy"]
HVPOL["terraform-azurerm-site-recovery-hyperv-replication-policy"]
VMPOL["terraform-azurerm-site-recovery-vmware-replication-policy"]
PCM["terraform-azurerm-site-recovery-protection-container-mapping"]
HVASSOC["terraform-azurerm-site-recovery-hyperv-replication-policy-association"]
VMASSOC["terraform-azurerm-site-recovery-vmware-replication-policy-association"]
NM["terraform-azurerm-site-recovery-network-mapping"]
HVNM["terraform-azurerm-site-recovery-hyperv-network-mapping"]
RVM["terraform-azurerm-site-recovery-replicated-vm"]
RRP["terraform-azurerm-site-recovery-replication-recovery-plan"]
ARMF["ONE ARM type, TWO resource types: vaults replicationFabrics. Same name plus same vault means one object, silently"]
ARMP["ONE ARM type, THREE resource types: vaults replicationPolicies. A2A, Hyper-V and VMware policies are interchangeable at the API"]
ARMM["ONE ARM type, THREE resource types: replicationProtectionContainerMappings. The hardest group to assert on"]
VM["terraform-azurerm-linux-virtual-machine or windows-virtual-machine: wire its id, NOT its virtual_machine_id"]
VNET["terraform-azurerm-virtual-network: the failover network, the test network and the source network"]
RUNBOOK["an Azure Automation runbook: a plan action names it by id and it runs with the automation account's own permissions. NOT bounded by vault RBAC"]
VMWVM["terraform-azurerm-site-recovery-vmware-replicated-vm"]
HOSTS["the Hyper-V hosts themselves, registered with a five-day vault key. NO azurerm resource does this"]
APPL["the VMware replication appliance, deployed on-premises and registered against the vault. NO azurerm resource does this either"]
VMM["the System Center VMM server, registered by installing the Site Recovery Provider on it. NO azurerm resource does this either"]
VCENTER["the vCenter machines and the credentials stored on the appliance. source_vm_name and physical_server_credential_name are FRIENDLY names matched at apply time, and that credential is root or admin ON the source machine"]
RG -->|"resource_group_name and location"| VAULT
VAULT -->|"name, NOT id, so there is no dependency edge from a literal"| FABRIC
VAULT -->|"id, NOT name: this sibling takes the vault ID instead"| HVS
VAULT -->|"name, and NO fabric: a policy belongs to the vault"| POLICY
VAULT -->|"id, a second convention on the same parent"| HVPOL
VAULT -->|"id, and the association takes the SAME vault argument"| VMPOL
VAULT -->|"id: BOTH the fabric and the container are discovered under it at apply time"| VMASSOC
VAULT -->|"name plus resource group, a third convention again"| RVM
VAULT -->|"id: the ONLY Resource ID this module resolves anything from"| HVNM
VAULT -->|"id, and BOTH fabrics as ids: a fourth convention"| RRP
FABRIC -->|"hardcoded instance type: an Azure fabric"| ARMF
HVS -->|"hardcoded instance type: HyperVSite, absent from Swagger"| ARMF
POLICY -->|"hardcoded A2A payload"| ARMP
HVPOL -->|"hardcoded Hyper-V to Azure payload"| ARMP
VMPOL -->|"hardcoded InMageRcm payload: the MODERNIZED VMware provider"| ARMP
PCM -->|"container taken by name and by id"| ARMM
HVASSOC -->|"container discovered under the fabric at apply time"| ARMM
VMASSOC -->|"fabric AND container both discovered under the vault"| ARMM
FABRIC -->|"name, so a Hyper-V site's name is equally legal here"| PC
FABRIC -->|"name, for BOTH sides: ARM names, not friendly names"| NM
FABRIC -->|"id, for BOTH sides, and neither is compared to the vault"| RRP
PC -->|"name for the source side, id for the target side"| PCM
POLICY -->|"id, where the container beside it is taken by name"| PCM
HVPOL -->|"id, the only consumer of a Hyper-V policy"| HVASSOC
HVS -->|"id, and the protection container is discovered from it"| HVASSOC
VMPOL -->|"id, the only consumer of a VMware policy"| VMASSOC
VNET -->|"id, twice. Only the SOURCE id is parsed by the provider"| NM
VNET -->|"id, the target only. The SOURCE network is a VMM friendly name"| HVNM
FABRIC -->|"source fabric by NAME, target fabric by ID: one resource, two conventions"| RVM
PC -->|"source container by NAME, target container by ID"| RVM
POLICY -->|"id, and a Hyper-V or VMware policy id would also be accepted"| RVM
VM -->|"the machine being protected"| RVM
VNET -->|"the failover network and the test network, both Optional AND Computed"| RVM
PCM -->|"makes a container pair eligible before any item can replicate"| RVM
NM -->|"decides where an Azure-to-Azure failover lands"| RVM
HVNM -->|"decides where a VMM Hyper-V failover lands, for every machine on the source network"| VMM
RVM -->|"ids, into ORDERED boot groups: group 2 starts only after group 1 has finished"| RRP
VMWVM -->|"ids, into the same ORDERED boot groups as the Azure-to-Azure items"| RRP
VAULT -->|"id ALONE, a fifth convention: the fabric AND the container are discovered from it, and there must be exactly ONE container"| VMWVM
VMPOL -->|"id, its second consumer: the policy this VMware item replicates under"| VMWVM
VNET -->|"the failover network and the test network. test_network_id is read back and NEVER updated"| VMWVM
VMASSOC -->|"makes the discovered container eligible before any VMware machine can replicate"| VMWVM
RUNBOOK -->|"id, from a plan pre-action or post-action"| RRP
APPL -->|"appliance_name is its FRIENDLY name, matched against the fabric process servers. Microsoft: it cannot be changed once set"| VMWVM
VCENTER -->|"source_vm_name and physical_server_credential_name, both matched by name against records the appliance discovered"| VMWVM
HOSTS -->|"register into the site out of band, invisible to Terraform"| HVS
HOSTS -->|"registration is what creates the container this record needs"| HVASSOC
APPL -->|"required before any VMware machine can be enrolled"| VMASSOC
VMM -->|"the fabric AND the source network are found under it by FRIENDLY NAME at apply time"| HVNM
classDef me fill:#0078D4,stroke:#004578,color:#ffffff
classDef keystone fill:#004578,stroke:#002438,color:#ffffff
classDef sibling fill:#F3F6F9,stroke:#8A9BA8,color:#1B1F23
class FABRIC,HVS,PC,POLICY,HVPOL,VMPOL,PCM,HVASSOC,VMASSOC,NM,HVNM,RVM,RRP,VMWVM me
class ARMF,ARMP,ARMM keystone
class RG,VAULT,VM,VNET,RUNBOOK,VCENTER,HOSTS,APPL,VMM sibling
⚠️ Read the arrows into this module. The fabric arrives as aname, and so does the vault — neither is an ID, so Terraform holds no dependency edge from a literal.ℹ️ Because a Hyper-V site is the same ARM type as a fabric, its
nameis equally legal asrecovery_fabric_name.
One keystone resource, no children, and an empty payload. The timeouts block has three keys, not four.
flowchart TB
IN_ID["name plus recovery_fabric_name plus recovery_vault_name plus resource_group_name: which fabric, and what to call the container"]
THIS["azurerm_site_recovery_protection_container.this"]
EMPTY["the create payload: completely empty. This resource has no configuration at all"]
OUT_PATHS["three subscription-less paths: the container, its fabric, its vault"]
OUT_ID["id for a mapping's target side, name for its source side"]
OUT_CONST["seven constants: no update path, an empty payload, and the one resource here that does not absorb a 400"]
IN_ID --> THIS
THIS --> EMPTY
IN_ID -->|"recombined without the subscription, which a module cannot know"| OUT_PATHS
THIS -->|"id, known only after apply"| OUT_ID
THIS --> OUT_CONST
OUT_PATHS -->|"compare the fabric path against a fabric module's own, since the fabric arrives as a bare NAME"| OUT_CONST
classDef me fill:#0078D4,stroke:#004578,color:#ffffff
classDef keystone fill:#004578,stroke:#002438,color:#ffffff
classDef sibling fill:#F3F6F9,stroke:#8A9BA8,color:#1B1F23
class THIS keystone
class OUT_PATHS me
class IN_ID,EMPTY,OUT_ID,OUT_CONST sibling
Resource inventory
| Address | Count | Notes |
|---|---|---|
azurerm_site_recovery_protection_container.this |
1 | The keystone. No child records, no for_each. |
timeouts |
1 | Three operations — there is no update path. |
ℹ️ This module does not share a shape diagram with any sibling. The replication policy has two configuration arguments and a rule between them; this one has no configuration at all.
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 (pinned; v5.0 deliberately excluded) |
| Provider block | None here — the caller configures provider "azurerm" { features {} }, auth and subscription |
| Resource | azurerm_site_recovery_protection_container |
| API provider | Microsoft.RecoveryServices |
Schema notes that bite
- 🔴 The create payload is completely empty —
CreateProtectionContainerInput{Properties: &CreateProtectionContainerInputProperties{}}. The four arguments are not "the important ones", they are all of them. - 🔴 All four arguments are force-new and there is no Update function.
resource_group_namegetsForceNewinvisibly fromcommonschema.ResourceGroupName(). - 🔴 This resource does not tolerate the API's HTTP 400 for a missing record.
azurerm_site_recovery_fabric,azurerm_site_recovery_replication_policyandazurerm_site_recovery_network_mappingall guard with!WasNotFound(...) && !wasBadRequestWithNotExist(...); this one andazurerm_site_recovery_protection_container_mappingguard onWasNotFoundalone. ⚠️ Two of four arguments are effectively unvalidated upstream.nameandrecovery_fabric_nameget onlyStringIsNotEmpty, which accepts whitespace;recovery_vault_nameandresource_group_nameget real anchored validators.⚠️ The fabric is referenced by NAME, so Terraform holds no ordering edge and a name matching a fabric the configuration does not own is accepted.⚠️ A Hyper-V site's name is a legalrecovery_fabric_name, because that resource creates the same ARM type as a fabric.⚠️ The read sets every attribute from the parsed Resource ID, not from the response body — consistent with an empty payload, and it means drift beyond existence cannot be detected.- ℹ️ There is no
tags, nolocationand noSchemaVersion. Tag the vault instead.
| Principal | Scope | Requirement |
|---|---|---|
| The principal running Terraform | The vault or its resource group | Site Recovery Contributor — carries Microsoft.RecoveryServices/vaults/replicationFabrics/*, which covers the containers nested beneath a fabric |
| Alternatively | The same scope | Contributor or Owner |
- 🔒
Site Recovery Contributorcannot create or delete the vault, per Microsoft's own description of the role. - 🔒
Site Recovery OperatorandSite Recovery Readerare not sufficient. The Operator role explicitly cannot "register new infrastructure"; the Reader role is read-only. ⚠️ Backup Contributoris the wrong role, and it is an easy mistake — a Recovery Services vault serves both Azure Backup and Site Recovery, and Backup Contributor grants nothing overreplicationFabrics.- ✅ Plan access is not credential access. No secret is read or emitted, and no output is sensitive.
Microsoft.RecoveryServicesregistered in the subscription.- An existing Recovery Services vault.
- An existing Site Recovery fabric, referenced here by name — a Hyper-V site counts.
- A name unique within that fabric. Nothing enforces it across configurations.
- An understanding that this container is empty and stays empty until mappings and replicated items fill it.
terraform-azurerm-site-recovery-protection-container/
├── providers.tf # required_version + the pinned azurerm; no provider block
├── variables.tf # 5 variables, 7 validations
├── main.tf # 3 locals + the keystone azurerm_site_recovery_protection_container.this
├── outputs.tf # 15 outputs: 5 passthrough, 3 derived, 7 constant
├── README.md # this file
├── SCOPE.md # the cross-module contract
├── LICENSE # MIT
└── .gitignore
module "dr_container_primary" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-protection-container.git?ref=v1.0.0"
name = "container-eastus"
recovery_fabric_name = module.dr_fabric_primary.name
recovery_vault_name = module.dr_vault.name
resource_group_name = module.dr_rg.name
}
⚠️ Every argument is force-new. There is no in-place edit on this resource, and replacing a container affects the mappings that reference it and the replicated items inside it.ℹ️ The caller configures the provider, its authentication and its
features {}block. This module declares none of them.
Consumes
| Input | Type | Source |
|---|---|---|
recovery_fabric_name |
string |
terraform-azurerm-site-recovery-fabric output name |
recovery_vault_name |
string |
terraform-azurerm-recovery-services-vault output name |
resource_group_name |
string |
terraform-azurerm-resource-group output name |
name |
string |
caller |
Emits
| Output | Description |
|---|---|
id |
The container's Resource ID (first) — a mapping's target side takes this |
name |
A mapping's source side takes this instead |
container_path_within_the_subscription |
The container's path at plan time |
fabric_path_within_the_subscription |
Comparable with either fabric module's own output |
vault_path_within_the_subscription |
The parent vault's path |
| the seven constants | Documentation and check blocks |
1 · The smallest legal call
module "dr_container_primary" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-protection-container.git?ref=v1.0.0"
name = "container-eastus"
recovery_fabric_name = module.dr_fabric_primary.name
recovery_vault_name = module.dr_vault.name
resource_group_name = module.dr_rg.name
}ℹ️ Four arguments and no options. There is no emptier call and nothing optional to add except
timeouts.
2 · 🕳️ The create payload is empty, and that is the whole resource
# There is no size, no tier, no policy, no network, no encryption setting.
# The provider sends: CreateProtectionContainerInputProperties{}🔴 A protection container is a named bucket and nothing else. So the only thing a caller can get wrong is where it is — which is why this module's derived outputs are all about location and its constants are all about the lifecycle.
ℹ️ It also explains the read path: every attribute is set from the parsed Resource ID rather than from the response body, so drift beyond "does it exist" cannot be detected. Read
module.dr_container_primary.the_create_payload_is_completely_empty.
3 · The fabric arrives as a NAME, so wire the module output
# CORRECT -- creates a dependency edge, so Terraform orders the fabric first
recovery_fabric_name = module.dr_fabric_primary.name
# WRONG -- a literal gives Terraform nothing to order against
# recovery_fabric_name = "fabric-eastus"
⚠️ A container created before its fabric exists fails at apply, not at plan. Wiringmodule.<fabric>.nameis what creates the edge.
⚠️ The quiet direction is worse: a literal that matches a fabric somebody else created is accepted without complaint. Example 4 closes that.ℹ️
module.dr_fabric_primary.idis the plausible wrong value here — the fabric module emits both. This module rejects an ID with a message naming thenameoutput.
4 · Proving the container landed in the intended fabric
check "the_container_is_in_our_own_fabric" {
assert {
condition = (
module.dr_container_primary.fabric_path_within_the_subscription
== module.dr_fabric_primary.fabric_path_within_the_subscription
)
error_message = "The protection container's fabric is not the fabric this configuration declares."
}
}✅ Both sides emit the value under the same name and in the same subscription-less form — this module derives it from a name plus a vault plus a resource group, and the fabric module derives it from its own arguments. That is what makes the comparison meaningful rather than circular.
ℹ️ It works against
terraform-azurerm-site-recovery-services-vault-hyperv-sitetoo, since that module emits the same output and creates the same ARM type.
5 · A Hyper-V site is a legal fabric here
# The Hyper-V site. A DIFFERENT Terraform resource that writes the SAME ARM type as a fabric.
module "hyperv_site" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-services-vault-hyperv-site.git?ref=v1.0.0"
name = "contoso-hyperv-site"
recovery_vault_id = module.dr_vault.id # note: the ID here, where the fabric module takes a name
}
module "hyperv_container" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-protection-container.git?ref=v1.0.0"
name = "container-hyperv"
recovery_fabric_name = module.hyperv_site.name # a Hyper-V site, not an "azurerm_site_recovery_fabric"
recovery_vault_name = module.hyperv_site.recovery_vault_name
resource_group_name = module.hyperv_site.resource_group_name
}ℹ️
azurerm_site_recovery_services_vault_hyperv_sitecreates the same ARM type asazurerm_site_recovery_fabric— aMicrosoft.RecoveryServices/vaults/replicationFabricsrecord — so its name is equally valid asrecovery_fabric_name. That is usually what a Hyper-V topology wants.
⚠️ Nothing here distinguishes the two. Readmodule.hyperv_container.a_hyperv_site_is_also_a_valid_fabric_name_here, and note the same fact is why the two fabric-creating modules can collide with each other.
6 · Both identity outputs have a consumer, in different arguments
module "dr_mapping" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-protection-container-mapping.git?ref=v1.0.0"
name = "mapping-primary-to-secondary"
recovery_fabric_name = module.dr_container_primary.recovery_fabric_name
recovery_vault_name = module.dr_container_primary.recovery_vault_name
resource_group_name = module.dr_container_primary.resource_group_name
# The SOURCE container by name, the TARGET container by id. Both from this module.
recovery_source_protection_container_name = module.dr_container_primary.name
recovery_target_protection_container_id = module.dr_container_secondary.id
recovery_replication_policy_id = module.dr_policy.id
}ℹ️ One downstream resource takes the source container by name and the target container by ID. So both of this module's identity outputs are load-bearing, and which one you need depends on which side of the mapping the container is on.
⚠️ The two container references are not interchangeable. Passing the target'sidwhere the source'snamebelongs fails the mapping module's own anchored rule at plan; passing the source'snamewhere the target'sidbelongs fails it too, which is the point of carrying both rules there.
7 · There is no update path
# Changing ANY of these four destroys and recreates the container:
name = "container-eastus"
recovery_fabric_name = module.dr_fabric_primary.name
recovery_vault_name = module.dr_vault.name
resource_group_name = module.dr_rg.name🔴 The provider declares Create, Read and Delete and no Update function. Three arguments carry explicit
ForceNew;resource_group_namegets it invisibly fromcommonschema.ResourceGroupName(), so the schema shows three of four.
⚠️ Replacement is not local: mappings reference the container and replicated items live inside it. Readmodule.dr_container_primary.there_is_no_update_path_so_every_argument_is_force_new.
8 · The `timeouts` block has three keys, and a fourth is discarded silently
timeouts = {
create = "45m"
read = "10m"
delete = "45m"
} # WRONG, and it produces no error, no warning and no plan diff:
# timeouts = { create = "45m", update = "45m" }🔴 This module's
timeoutstype declares exactly the three keys the resource has. Terraform's object-type conversion silently discards an attribute the type does not declare — the one class of wrong input in this suite that produces no signal at all.ℹ️ The replication policy sibling has four keys, because it is the one resource in this family with an update path. The shape has to match the resource rather than be copied across.
9 · 🔴 The one sibling difference that changes a first apply
azurerm_site_recovery_fabric !WasNotFound(...) && !wasBadRequestWithNotExist(...)
azurerm_site_recovery_replication_policy !WasNotFound(...) && !wasBadRequestWithNotExist(...)
azurerm_site_recovery_network_mapping !WasNotFound(...) && !wasBadRequestWithNotExist(...)
azurerm_site_recovery_protection_container !WasNotFound(...) <-- this one
azurerm_site_recovery_protection_container_mapping !WasNotFound(...) <-- and this one
🔴 The Site Recovery API can answer HTTP 400 rather than 404 for a record that does not exist (
Azure/azure-rest-api-specsissue 12759). Across all fourteen resources in this service, twelve perform an existence check and six of those reference a 400 at all. This one does not — nor do the protection container mapping, the Hyper-V network mapping, either replicated-VM resource, or the VMware policy association.
⚠️ So a first apply can fail during the pre-create existence check with "checking for presence of existing site recovery protection container ..." wrapping a 400 — an error about looking for the container, not about creating it, which sends a reader to the wrong place.ℹ️ If it happens, the caller's own provider
featuresblock can skip the check entirely. Readmodule.dr_container_primary.this_resource_does_not_tolerate_the_apis_bad_request_for_a_missing_record.
10 · Both unvalidated names get module rules; the other two do not
name = "container-eastus" # StringIsNotEmpty upstream -> 2 module rules
recovery_fabric_name = module.dr_fabric_primary.name # StringIsNotEmpty upstream -> 2 module rules
recovery_vault_name = module.dr_vault.name # anchored regex upstream -> none, deliberately
resource_group_name = module.dr_rg.name # three checks upstream -> none, deliberatelyℹ️
StringIsNotEmptyaccepts a whitespace-only string, so a blank container name or fabric name reaches Azure. Those two arguments get a blank check and a no-slash check each — genuine coverage.ℹ️
validate.RecoveryServicesVaultName(^[a-zA-Z][-a-zA-Z0-9]{1,49}$) andresourcegroups.ValidateNameare both anchored, both exclude the forward slash, and both state the whole rule in their messages — so a Resource ID and a blank are already rejected. Restating them would buy wording rather than coverage.
11 · Asserting two containers are distinct
check "no_two_containers_collide" {
assert {
condition = length(distinct([
module.dr_container_primary.container_path_within_the_subscription,
module.dr_container_secondary.container_path_within_the_subscription,
])) == 2
error_message = "Two protection containers resolve to the same path, so they are one Azure object."
}
}ℹ️ Useful at plan time, which the resource's own
idis not —idis unknown until apply. Two containers with the same name in the same fabric collide; the same name in different fabrics does not, and the path captures that distinction.ℹ️ The subscription is omitted for the same reason every site-recovery module in this suite omits it: a module cannot read the caller's provider configuration, and two modules under one provider share a subscription anyway.
12 · `for_each` over the containers a fabric holds
locals {
containers = {
web = "container-web"
db = "container-db"
app = "container-app"
}
}
module "dr_containers" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-protection-container.git?ref=v1.0.0"
for_each = local.containers
name = each.value
recovery_fabric_name = module.dr_fabric_primary.name
recovery_vault_name = module.dr_vault.name
resource_group_name = module.dr_rg.name
}
output "container_names" {
value = { for k, m in module.dr_containers : k => m.name }
}ℹ️ Key on your own identifier rather than the container name, so renaming a container does not re-index the map. The
nameoutput is what a mapping's source side consumes, so emitting the map of names is usually what a composition wants.
13 · Tagging, and why there is nothing to tag
# There is no `tags` argument on this resource. Tag the vault instead:
module "dr_vault" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-recovery-services-vault.git?ref=v1.0.0"
name = "vault-dr-prod"
location = module.dr_rg.location
resource_group_name = module.dr_rg.name
sku = "Standard"
tags = { environment = "prod", owner = "platform" }
}ℹ️ Confirmed against both the schema and the provider source: no
tagsand nolocation. The universal tail on this module istimeoutsalone, and a cost or ownership report has to reach the container through its vault.
14 · 🏗️ End-to-end composition
A resource group, a vault, both ends of a replication pair, a container in each fabric, a policy — and the checks that hold the topology together.
module "dr_rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-dr-prod"
location = "eastus"
tags = local.platform_tags
}
module "dr_vault" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-recovery-services-vault.git?ref=v1.0.0"
name = "vault-dr-prod"
location = module.dr_rg.location
resource_group_name = module.dr_rg.name
sku = "Standard"
tags = local.platform_tags
}
# Both ends of the pair. These take the vault by NAME.
module "dr_fabric_primary" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-fabric.git?ref=v1.0.0"
name = "fabric-eastus"
location = "eastus"
recovery_vault_name = module.dr_vault.name
resource_group_name = module.dr_rg.name
}
module "dr_fabric_secondary" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-fabric.git?ref=v1.0.0"
name = "fabric-westus2"
location = "westus2"
recovery_vault_name = module.dr_vault.name
resource_group_name = module.dr_rg.name
}
# A container in each fabric. The source side of a mapping takes the name, the target side the id.
module "dr_container_primary" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-protection-container.git?ref=v1.0.0"
name = "container-eastus"
recovery_fabric_name = module.dr_fabric_primary.name
recovery_vault_name = module.dr_vault.name
resource_group_name = module.dr_rg.name
}
module "dr_container_secondary" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-protection-container.git?ref=v1.0.0"
name = "container-westus2"
recovery_fabric_name = module.dr_fabric_secondary.name
recovery_vault_name = module.dr_vault.name
resource_group_name = module.dr_rg.name
}
# The policy takes NO fabric -- it belongs to the vault, so it can be created alongside the fabrics.
module "dr_policy" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-replication-policy.git?ref=v1.0.0"
name = "policy-24h"
recovery_vault_name = module.dr_vault.name
resource_group_name = module.dr_rg.name
recovery_point_retention_in_minutes = 1440
application_consistent_snapshot_frequency_in_minutes = 240
}
check "both_containers_are_in_our_own_fabrics" {
assert {
condition = (
module.dr_container_primary.fabric_path_within_the_subscription == module.dr_fabric_primary.fabric_path_within_the_subscription
&& module.dr_container_secondary.fabric_path_within_the_subscription == module.dr_fabric_secondary.fabric_path_within_the_subscription
)
error_message = "A protection container is attached to a fabric this configuration does not declare."
}
}
check "the_whole_topology_is_in_one_vault" {
assert {
condition = length(distinct([
module.dr_fabric_primary.vault_path_within_the_subscription,
module.dr_fabric_secondary.vault_path_within_the_subscription,
module.dr_container_primary.vault_path_within_the_subscription,
module.dr_container_secondary.vault_path_within_the_subscription,
module.dr_policy.vault_path_within_the_subscription,
])) == 1
error_message = "The replication topology spans more than one Recovery Services vault, which cannot work."
}
}
⚠️ The composition is incomplete on purpose. What is missing is the record that ties it together:azurerm_site_recovery_protection_container_mappingjoins the source container (by name), the target container (by id) and the policy (by id), and is owned byterraform-azurerm-site-recovery-protection-container-mapping— every input it needs is emitted above. The ordering of a multi-machine failover across those items is owned byterraform-azurerm-site-recovery-replication-recovery-plan, and the replicated items themselves byterraform-azurerm-site-recovery-replicated-vm.✅ The second
checkis worth copying. Thirteen modules across thirteen resource types all emitvault_path_within_the_subscriptionin the same form, so one assertion covers the whole topology.
Identity — name, recovery_fabric_name, recovery_vault_name, resource_group_name, all force-new.
Universal tail — timeouts only. There is no tags and no location on this resource.
Full schemas
variable "name" { type = string } # REQUIRED, force-new
variable "recovery_fabric_name" { type = string } # REQUIRED, force-new -- a NAME, not an id
variable "recovery_vault_name" { type = string } # REQUIRED, force-new
variable "resource_group_name" { type = string } # REQUIRED, force-new via commonschema.ResourceGroupName()
variable "timeouts" {
type = object({
create = optional(string, "30m")
read = optional(string, "5m")
delete = optional(string, "30m")
})
default = {}
} # THREE keys -- there is no update path7 validations. Two on name and two on recovery_fabric_name (blank, and no forward slash — both are validated upstream only by StringIsNotEmpty, which accepts whitespace); three on timeouts. recovery_vault_name and resource_group_name carry none, deliberately: both provider validators are anchored, exclude the forward slash, reject a blank and state the whole rule.
| Output | Description |
|---|---|
id |
The container's Resource ID (first) — a mapping's recovery_target_protection_container_id |
name |
A mapping's recovery_source_protection_container_name |
recovery_fabric_name, recovery_vault_name, resource_group_name |
As configured |
container_path_within_the_subscription |
This container's path, at plan time |
fabric_path_within_the_subscription |
The fabric's path, in the form both fabric modules emit |
vault_path_within_the_subscription |
The parent vault's path |
the_create_payload_is_completely_empty |
constant |
there_is_no_update_path_so_every_argument_is_force_new |
constant |
the_timeouts_block_has_no_update_key_and_a_supplied_one_is_silently_discarded |
constant |
the_fabric_is_referenced_by_name_so_terraform_holds_no_dependency_edge |
constant |
a_hyperv_site_is_also_a_valid_fabric_name_here |
constant |
this_resource_does_not_tolerate_the_apis_bad_request_for_a_missing_record |
constant |
whether_a_first_apply_is_refused_depends_on_caller_provider_configuration |
constant |
15 outputs: 5 passthrough, 3 derived, 7 constant. No output is sensitive.
ℹ️ The count is low on purpose. All four arguments are identity, the create payload is empty, and there is no update path — so there is no posture to derive. Padding the list would mean emitting values no input can change.
The empty payload is the organising fact. CreateProtectionContainerInputProperties{} carries nothing, so the only thing a caller can get wrong about a protection container is where it is. That shapes everything else in the module: the derived outputs are all locations, the constants are all lifecycle, and the read path sets every attribute from the parsed Resource ID rather than from the response body — which means drift beyond "does it exist" is undetectable by design rather than by omission.
The fabric arrives as a bare name, and that fails in two directions. Loudly, when a literal means Terraform builds no ordering edge and the apply hits a fabric that does not exist yet. Quietly, when a literal matches a fabric somebody else created and the container attaches to it without complaint. Only the first is a plan problem; the second is why this module reconstructs fabric_path_within_the_subscription in exactly the form both fabric-creating modules emit under the same name, so a one-line check can close it.
Two of four arguments are unvalidated upstream and two are well covered, which is why the module's rule list looks lopsided. StringIsNotEmpty on name and recovery_fabric_name accepts a whitespace-only string; validate.RecoveryServicesVaultName and resourcegroups.ValidateName are anchored, exclude the forward slash and state their whole rule. Adding rules to the latter two would buy wording, so they carry none — recorded as examined rather than left to look inconsistent.
A Hyper-V site is a fabric here, because it is a fabric everywhere. azurerm_site_recovery_services_vault_hyperv_site writes the same Microsoft.RecoveryServices/vaults/replicationFabrics type as azurerm_site_recovery_fabric, so its name is a legal recovery_fabric_name and nothing in this resource distinguishes the two. That is usually convenient; it is the same fact that lets those two modules silently collide with each other.
The 400-tolerance asymmetry is the sharpest sibling difference — and it is narrower than it looks. Six of the twelve resources in this service that perform an existence check reference the API's Bad Request at all, and this one does not. Five of those six call the service's wasBadRequestWithNotExist helper, which fires only for the SubscriptionIdNotRegisteredWithSrs detail code and only for a legacy autorest.DetailedError these clients do not return, so what they actually gain over this resource is close to nothing. The consequence is a first-apply failure whose message is about looking for the container, which is a different debugging path from a failure to create one. Nothing in the schema or the documentation shows it, and the workaround being present on four of the seven that check at all is what makes it look like an omission rather than a decision.
Nothing can be edited, and the replacement is not local. No Update function means all four arguments are force-new — three explicitly, one invisibly through a commonschema helper. Mappings reference this container and replicated items live inside it, so a one-character rename is the start of a much larger rebuild. The visible trace is the three-key timeouts block, which in turn is why this module's timeouts variable declares three keys: an object type silently discards an undeclared attribute, so a caller who supplies update loses it without being told.
There is nothing to harden, and that is the honest starting point. Four identity arguments and an empty create payload; the resource has no boolean, no enum, no network surface and no encryption setting. A secure-defaults table would be empty, so what follows records what was examined.
| Concern | Provider's value | This module | Why |
|---|---|---|---|
| Exposure controls | none exist | — | No public-access, TLS, identity or encryption surface of any kind. |
name validation |
StringIsNotEmpty |
two rules added | It accepts a whitespace-only string. Genuine coverage. |
recovery_fabric_name validation |
StringIsNotEmpty |
two rules added | Same gap, and the message names the fabric module's name output because its id is the plausible wrong value. |
recovery_vault_name |
anchored regex, accurate message | kept, no rule | Restating it would buy wording, not coverage. |
resource_group_name |
anchored class, three checks | kept, no rule | Same. The class excludes /, so a Resource ID is already rejected. |
timeouts shape |
three keys | mirrored exactly | Declaring a fourth would be a validate error; omitting it is why the silent-discard trap is documented. |
| Fabric attachment | unchecked | emitted as a comparable path | The provider cannot see whether the fabric was declared by this configuration; a check block can. |
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module at a tag, never a branch:
source = "git::https://github.com/microsoftexpert/terraform-azurerm-site-recovery-protection-container.git?ref=v1.0.0"Everything above is plan-only static analysis. A human applies from CI.
What validate and fmt -check cover. Every type, the optional() defaults, all 7 validations as expressions, and the locals block's total-ness. The three derived paths were each evaluated offline and printed rather than read, across fixtures that vary the vault, the resource group, the fabric and the container name independently — so no value was confirmed by coincidence.
What only plan against a real subscription exercises. That the fabric exists and its name was spelled correctly; that Microsoft.RecoveryServices is registered; whether an existing container causes the create to fail or be overwritten; and whether the API answers 400 for a missing container in your region.
What no plan can show. That the fabric named here is one this configuration owns, and that replacing this container will cascade through every mapping and replicated item referencing it. Both are why the path outputs and the constants exist.
id = "/subscriptions/00000000-.../resourceGroups/rg-dr-prod/providers/Microsoft.RecoveryServices/vaults/vault-dr-prod/replicationFabrics/fabric-eastus/replicationProtectionContainers/container-eastus"
name = "container-eastus"
recovery_fabric_name = "fabric-eastus"
recovery_vault_name = "vault-dr-prod"
resource_group_name = "rg-dr-prod"
container_path_within_the_subscription = "/resourceGroups/rg-dr-prod/providers/Microsoft.RecoveryServices/vaults/vault-dr-prod/replicationFabrics/fabric-eastus/replicationProtectionContainers/container-eastus"
fabric_path_within_the_subscription = "/resourceGroups/rg-dr-prod/providers/Microsoft.RecoveryServices/vaults/vault-dr-prod/replicationFabrics/fabric-eastus"
vault_path_within_the_subscription = "/resourceGroups/rg-dr-prod/providers/Microsoft.RecoveryServices/vaults/vault-dr-prod"
| Symptom | Cause | Fix |
|---|---|---|
name must not be blank or whitespace-only |
An empty or space-only name | Supply a real name. The provider's StringIsNotEmpty would have accepted the whitespace |
recovery_fabric_name is the fabric's own short name, not a Resource ID |
module.<fabric>.id was wired instead of .name |
Pass module.<fabric>.name |
recovery_fabric_name must not be blank |
An unset variable reached it | Wire the fabric module's name output |
| An apply fails saying the fabric does not exist, although the plan succeeded | The fabric is referenced by NAME, so Terraform built no ordering edge | Wire module.<fabric>.name rather than a literal |
| The container was created in a fabric this configuration does not own | A literal fabric name matched an existing fabric | Add the check block from example 4 |
checking for presence of existing site recovery protection container ... wrapping a 400 |
This resource alone does not absorb the API's Bad Request for a missing record | Set the provider's overwrite feature knowingly, or retry — see example 9 |
already exists on a first apply |
A container exists at this name in this fabric | Import it, or set the provider's overwrite feature knowingly |
| A plan proposes replacing the container after a one-character edit | Every argument is force-new and there is no update path | Expected — and the replacement cascades to mappings and replicated items |
A timeouts value seems to be ignored |
An update key was supplied; the object type discarded it silently |
Use only create, read and delete |
A mapping rejects this module's id |
The mapping's source side takes a name; only its target side takes an id | Pass name for the source, id for the target — see example 6 |
| The container exists but nothing is replicating | Expected — the container is empty | Add a protection container mapping and replicated-item resources |
azurerm_site_recovery_protection_container— the provider resourceazurerm_site_recovery_protection_container_mapping— the record that consumes both of this module's identity outputs- Manage Site Recovery access with Azure RBAC — the three built-in roles
- Sibling modules:
terraform-azurerm-site-recovery-fabric,terraform-azurerm-site-recovery-replication-policy,terraform-azurerm-site-recovery-services-vault-hyperv-site,terraform-azurerm-recovery-services-vault,terraform-azurerm-resource-group - This module's
SCOPE.md— the cross-module contract
💙 "Infrastructure as Code should be standardized, consistent, and secure."