Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure PostgreSQL Flexible Server Backup Policy Terraform Module

Manages one azurerm_data_protection_backup_policy_postgresql_flexible_server β€” the schedule and retention contract a PostgreSQL backup instance points at. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat

🧩 Overview

  • 🎯 Creates one PostgreSQL Flexible Server backup policy inside a Backup Vault.
  • 🧊 Every argument is force-new and there is no update function β€” nothing here can be edited in place.
  • πŸ” But the repoint path exists: the instance's backup_policy_id updates in place, so a retention change costs no data.
  • πŸ›‘οΈ The schedule and time zone are checked for non-emptiness only β€” this module's shape checks are the real protection.
  • 🚫 Carries no tags β€” the resource exposes none.

πŸ’‘ Why it matters: on this service the provider checks that the schedule strings and the time zone are non-empty and nothing more. There is no interval parser and no time-zone validator file at all, where the MySQL Flexible Server policy parses its schedule and checks a closed set of 141 identifiers, and Data Lake Storage parses, caps at five and checks 142. So the shape checks in this module are its own work, not a mirror of the provider's β€” and a habit carried from either sibling will be wrong about what is caught. The criteria fields, by contrast, are properly validated.

❀️ Support this project

If this module saves you time:

πŸ—ΊοΈ Where this fits in the family

flowchart TB
  RG["azurerm_resource_group"]
  VAULT["azurerm_data_protection_backup_vault"]
  SRV["azurerm_postgresql_flexible_server"]
  MI["the vault managed identity"]
  ROLE["role assignment for the vault identity"]
  PRE["further prerequisites the provider documents and does not check"]
  POL["backup_policy_postgresql_flexible_server"]
  INS["backup_instance_postgresql_flexible_server"]

  RG -->|"resource group"| VAULT
  VAULT -->|"vault_id, FORCE-NEW"| POL
  VAULT -->|"vault_id, FORCE-NEW"| INS
  POL -->|"backup_policy_id, updates IN PLACE"| INS
  SRV -->|"server_id, FORCE-NEW"| INS
  SRV -->|"location, the source database region"| INS
  VAULT -->|"managed identity"| MI
  MI -->|"principal_id"| ROLE
  ROLE -.->|"on the server, BEFORE the instance"| INS
  PRE -.->|"unmet means the poll times out"| INS
  INS -->|"data copied INTO the vault"| VAULT

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff
  classDef key fill:#004578,stroke:#002b47,color:#ffffff
  classDef ext fill:#F0F3F6,stroke:#9AA5B1,color:#1F2933
  class POL,INS me
  class VAULT key
  class RG,SRV,MI,ROLE,PRE ext
Loading

The edge from the policy to the instance is the one that updates in place β€” which is what makes the create-new-then-repoint sequence work on this service, and what distinguishes it from the Kubernetes cluster service, where the same edge is force-new. The dashed edges are requirements Terraform cannot see.

🧬 What this module builds

flowchart TB
  VIN["name, vault_id"]
  VSCH["backup_repeating_time_intervals, REQUIRED -- provider checks NON-EMPTY ONLY"]
  VDEF["default_retention_rule, a REQUIRED BLOCK"]
  VRULE["retention_rule map -- criteria ARE properly validated"]
  VTZ["time_zone -- NO closed set on this service"]
  STORE["data_store_type is VaultStore, NEVER OperationalStore"]
  ALL["EVERY argument is FORCE-NEW, and there is NO update function"]
  R["azurerm_data_protection_backup_policy_postgresql_flexible_server.this"]
  OID["id -- the value a backup instance references"]
  OREPOINT["the_repoint_path_exists_on_this_service"]
  OSCHED["the_provider_only_checks_the_schedule_is_non_empty"]
  OTZ["the_time_zone_is_not_validated_against_a_closed_set_here"]

  VIN --> ALL
  VSCH --> ALL
  VDEF --> STORE
  VRULE --> STORE
  VTZ --> ALL
  STORE --> ALL
  ALL --> R
  R --> OID
  R --> OREPOINT
  R --> OSCHED
  R --> OTZ

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff
  classDef key fill:#004578,stroke:#002b47,color:#ffffff
  classDef ext fill:#F0F3F6,stroke:#9AA5B1,color:#1F2933
  class R key
  class ALL,STORE,OSCHED,OTZ me
  class VIN,VSCH,VDEF,VRULE,VTZ,OID,OREPOINT ext
Loading
Resource Count Role
azurerm_data_protection_backup_policy_postgresql_flexible_server 1 (this) The keystone, and the only resource here.

βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None here. The caller configures the provider, its authentication and its features {} block.

Schema notes that bite, each verified against the provider source rather than inferred:

  • πŸ”΄ The schedule is validated for NON-EMPTINESS ONLY. No interval parser on this service, so a malformed repeating interval is accepted by Terraform and rejected by Azure. The MySQL Flexible Server and Data Lake Storage policies do parse theirs.
  • πŸ”΄ time_zone is validated for NON-EMPTINESS ONLY, and no time-zone validator exists for this service at all. MySQL checks a closed set of 141; Data Lake Storage 142.
  • βœ… But the criteria fields ARE properly validated β€” closed case-sensitive sets for the absolute criteria, weekdays, months and week numbers, plus a real RFC 3339 parser. The weakness is confined to the schedule and the time zone.
  • πŸ”΄ There is no update function and every argument is force-new, hence the three-key timeouts. The backup instance for this service does have an update function and takes four.
  • βœ… The repoint path exists. The instance's backup_policy_id updates in place β€” unlike on the Kubernetes cluster service.
  • πŸ”΄ data_store_type is VaultStore here and OperationalStore on the Kubernetes cluster backup policy. Both are closed, case-sensitive, one-member checks carrying a source comment about a future value, so the blocks look identical and accept opposite values.
  • πŸ”΄ default_retention_rule is a required BLOCK, as on the MySQL and Kubernetes policies. The blob storage and Data Lake Storage policies use plain duration strings.
  • life_cycle has no maximum, so several entries are permitted; the cap of one sits on the nested criteria block.
  • The schedule has a minimum of one entry and no maximum, where Data Lake Storage caps at five. Neither bound appears in the exported schema.
  • priority is required on every rule and its documented uniqueness rule is enforced by nothing.
  • There is no days_of_month β€” month-end goes through weeks_of_month = ["Last"] with a day selector.
  • Four selector lists carry a minimum of one item, invisible in the exported schema.
  • The requires-import guard can be disabled by a provider feature.
  • No CustomizeDiff, no version gate, no tags.

πŸ”‘ Required Azure RBAC Roles / Permissions

Role Scope Why
Backup Contributor (or a custom role with Microsoft.DataProtection/backupVaults/backupPolicies/*) the Backup Vault Create, read and delete backup policies. There is no update permission to need, because there is no update.
Reader the Backup Vault Sufficient for terraform plan to refresh an existing policy.

ℹ️ This policy is not a grant. It names no server and no identity. The permissions that decide whether backup actually works belong to the instance β€” and PostgreSQL Flexible Server backup has further prerequisites the provider documents and does not check.

Azure Prerequisites

  • The Microsoft.DataProtection resource provider registered in the subscription.
  • An existing Backup Vault. The policy is created inside it and cannot be moved between vaults.
  • A retention decision taken before the first apply β€” though the instances can be repointed rather than replaced.
  • The caller's provider "azurerm" { features {} } block. The module declares none.

πŸ“ Module Structure

terraform-azurerm-data-protection-backup-policy-postgresql-flexible-server/
β”œβ”€β”€ providers.tf     # required_version + the azurerm ~> 4.0 pin. No provider block.
β”œβ”€β”€ variables.tf     # 7 variables, 23 validations, the shape checks the provider omits
β”œβ”€β”€ main.tf          # one keystone `this`, a required default_retention_rule + keyed retention rules
β”œβ”€β”€ outputs.tf       # 45 outputs; id first, then name, then the facts
β”œβ”€β”€ README.md        # this file
β”œβ”€β”€ SCOPE.md         # the cross-module contract
β”œβ”€β”€ LICENSE          # MIT
└── .gitignore

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "postgresql_backup_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-policy-postgresql-flexible-server.git?ref=v1.0.0"

  name     = "bp-pgsql-daily-30d"
  vault_id = module.backup_vault.id

  backup_repeating_time_intervals = ["R/2026-09-01T23:00:00+00:00/P1D"]

  default_retention_rule = {
    life_cycle = [{
      # VaultStore, NOT OperationalStore.
      data_store_type = "VaultStore"
      duration        = "P30D"
    }]
  }
}

πŸ”’ The caller configures the provider, its authentication and its features {} block. This module declares none of them.

πŸ”Œ Cross-Module Contract

Consumes

Input Type Source
vault_id Resource ID terraform-azurerm-data-protection-backup-vault β†’ id
name string, required caller
backup_repeating_time_intervals list(string), required caller
default_retention_rule object, required caller
retention_rule, time_zone, timeouts see Inputs caller

Emits

Output Description Consumed by
id The policy's Resource ID …-backup-instance-postgresql-flexible-server β†’ backup_policy_id
the_provider_only_checks_the_schedule_is_non_empty What the provider does not do review
the_repoint_path_exists_on_this_service Retention changes cost no data here change review
force_new_fields All six arguments change review

πŸ“š Example Library

1 Β· The smallest real policy
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-policy-postgresql-flexible-server.git?ref=v1.0.0"

  name     = "bp-pgsql-daily-30d"
  vault_id = module.backup_vault.id

  backup_repeating_time_intervals = ["R/2026-09-01T23:00:00+00:00/P1D"]

  default_retention_rule = {
    life_cycle = [{
      data_store_type = "VaultStore"
      duration        = "P30D"
    }]
  }
}

ℹ️ There is no shorter call. The schedule and the default retention rule are both Required, and the retention rule is a nested block rather than a duration string.

2 Β· The schedule the provider does not parse
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-policy-postgresql-flexible-server.git?ref=v1.0.0"

  name     = "bp-pgsql-schedule"
  vault_id = module.backup_vault.id

  # The provider checks only that each entry is a NON-EMPTY STRING on this
  # service. "not-an-interval" would pass its check and be rejected by Azure.
  # The checks that catch it are THIS MODULE'S.
  backup_repeating_time_intervals = ["R/2026-09-01T23:00:00+00:00/P1D"]

  default_retention_rule = {
    life_cycle = [{ data_store_type = "VaultStore", duration = "P30D" }]
  }
}

πŸ”΄ Do not carry a habit from the MySQL module here. That resource parses each entry as a repeating time interval, so a malformed schedule is refused by the provider itself. This one does not. See the_provider_only_checks_the_schedule_is_non_empty.

3 Β· Five services, five answers on the same argument
# backup_repeating_time_intervals, across this family:
#
#   blob storage        no validator at all
#   Data Lake Storage   parsed as an interval, AND capped at 5 entries
#   Kubernetes cluster  no validator at all
#   MySQL Flexible      parsed as an interval, uncapped
#   PostgreSQL Flexible non-empty check only, uncapped   <-- THIS RESOURCE
#
# The protection you get is per-service, not per-family.

output "taxonomy" {
  value = module.policy.schedule_validation_differs_across_this_family
}

⚠️ This is the kind of difference that survives review, because the argument has the same name and the same meaning on all five. Only the enforcement differs.

4 Β· time_zone β€” no closed set on this service
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-policy-postgresql-flexible-server.git?ref=v1.0.0"

  name     = "bp-pgsql-eastern"
  vault_id = module.backup_vault.id

  backup_repeating_time_intervals = ["R/2026-09-01T23:00:00-05:00/P1D"]

  # A WINDOWS identifier. The provider checks ONLY that this is non-empty on
  # this service -- there is no time-zone validator for it at all -- so a
  # misspelling reaches Azure and means nothing there.
  time_zone = "Eastern Standard Time"

  default_retention_rule = {
    life_cycle = [{ data_store_type = "VaultStore", duration = "P30D" }]
  }
}

πŸ”΄ The MySQL policy validates the same field against 141 identifiers and Data Lake Storage against 142. Neither set applies here. This module refuses only the detectable mistake β€” a value containing /, the shape of an IANA name β€” because the legal set cannot be enumerated from this resource without risking a legal value being refused. See the_time_zone_is_not_validated_against_a_closed_set_here.

ℹ️ Note the interaction: the start instants in backup_repeating_time_intervals already carry their own UTC offset.

5 Β· The criteria ARE validated β€” the weakness is not uniform
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-policy-postgresql-flexible-server.git?ref=v1.0.0"

  name     = "bp-pgsql-criteria"
  vault_id = module.backup_vault.id

  backup_repeating_time_intervals = ["R/2026-09-01T23:00:00+00:00/P1D"]

  default_retention_rule = {
    life_cycle = [{ data_store_type = "VaultStore", duration = "P30D" }]
  }

  retention_rule = {
    "weekly" = {
      priority   = 20
      life_cycle = [{ data_store_type = "VaultStore", duration = "P12W" }]
      criteria = {
        # These ARE checked by the provider: closed case-sensitive sets, and a
        # real RFC 3339 parser for scheduled_backup_times. "firstofweek" or
        # "Sundays" is refused by the provider itself.
        absolute_criteria = "FirstOfWeek"
      }
    }
  }
}

output "not_uniformly_weak" {
  value = module.policy.the_criteria_are_validated_even_though_the_schedule_is_not
}

πŸ’‘ Worth knowing so the caveat is not over-generalised. "This resource validates weakly" would be too broad: only the schedule and the time zone are left to a bare non-empty check.

6 Β· VaultStore, never OperationalStore
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-policy-postgresql-flexible-server.git?ref=v1.0.0"

  name     = "bp-pgsql-store-type"
  vault_id = module.backup_vault.id

  backup_repeating_time_intervals = ["R/2026-09-01T23:00:00+00:00/P1D"]

  default_retention_rule = {
    life_cycle = [{
      # The ONLY legal value on this resource.
      data_store_type = "VaultStore"

      # REFUSED here -- and it is the only legal value on the KUBERNETES CLUSTER
      # backup policy:
      #   data_store_type = "OperationalStore"
      duration = "P30D"
    }]
  }
}

πŸ”΄ Both providers apply a closed, case-sensitive check containing exactly one member, and both carry a source comment saying another value may be supported in future β€” so the blocks look identical and accept opposite values.

ℹ️ PostgreSQL Flexible Server backup is vaulted: the data is copied into the Backup Vault and survives loss of the server. That is why the value is what it is, and why nothing on this service names a snapshot resource group.

7 Β· The default retention rule is a block, not a string
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-policy-postgresql-flexible-server.git?ref=v1.0.0"

  name     = "bp-pgsql-block-shape"
  vault_id = module.backup_vault.id

  backup_repeating_time_intervals = ["R/2026-09-01T23:00:00+00:00/P1D"]

  # A BLOCK, as on the MySQL and Kubernetes cluster policies. The blob storage
  # and Data Lake Storage policies take plain duration STRINGS instead.
  default_retention_rule = {
    life_cycle = [{
      data_store_type = "VaultStore"
      duration        = "P30D"
    }]
  }
}

ℹ️ life_cycle is a list with no maximum on this resource, so several entries are permitted. The cap of one sits on the nested criteria block instead.

8 Β· A retention rule, with its required priority
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-policy-postgresql-flexible-server.git?ref=v1.0.0"

  name     = "bp-pgsql-weekly"
  vault_id = module.backup_vault.id

  backup_repeating_time_intervals = ["R/2026-09-01T23:00:00+00:00/P1D"]

  default_retention_rule = {
    life_cycle = [{ data_store_type = "VaultStore", duration = "P30D" }]
  }

  retention_rule = {
    "keep-weekly" = {
      priority   = 20
      life_cycle = [{ data_store_type = "VaultStore", duration = "P12W" }]
      criteria   = { absolute_criteria = "FirstOfWeek" }
    }
  }
}

πŸ’‘ Lower priority wins, and the numbers must be unique. The provider documents that and checks nothing, so this module enforces it β€” two rules sharing a priority have no defined order. Note the Data Lake Storage policy has no priority field at all.

9 Β· Several rules at different retentions
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-policy-postgresql-flexible-server.git?ref=v1.0.0"

  name     = "bp-pgsql-tiered"
  vault_id = module.backup_vault.id

  backup_repeating_time_intervals = ["R/2026-09-01T23:00:00+00:00/P1D"]

  default_retention_rule = {
    life_cycle = [{ data_store_type = "VaultStore", duration = "P7D" }]
  }

  retention_rule = {
    "keep-weekly" = {
      priority   = 20
      life_cycle = [{ data_store_type = "VaultStore", duration = "P12W" }]
      criteria   = { absolute_criteria = "FirstOfWeek" }
    }
    "keep-monthly" = {
      priority   = 15
      life_cycle = [{ data_store_type = "VaultStore", duration = "P12M" }]
      criteria   = { absolute_criteria = "FirstOfMonth" }
    }
    "keep-yearly" = {
      priority   = 10
      life_cycle = [{ data_store_type = "VaultStore", duration = "P7Y" }]
      criteria   = { absolute_criteria = "FirstOfYear" }
    }
  }
}

output "rule_names" {
  value = module.policy.retention_rule_names
}

ℹ️ Keying by rule name keeps the map stable. Adding a fourth rule never re-indexes the other three, which matters here more than usual: every argument is force-new.

10 Β· Selecting by weekday
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-policy-postgresql-flexible-server.git?ref=v1.0.0"

  name     = "bp-pgsql-sundays"
  vault_id = module.backup_vault.id

  backup_repeating_time_intervals = ["R/2026-09-01T23:00:00+00:00/P1D"]

  default_retention_rule = {
    life_cycle = [{ data_store_type = "VaultStore", duration = "P30D" }]
  }

  retention_rule = {
    "sundays" = {
      priority   = 25
      life_cycle = [{ data_store_type = "VaultStore", duration = "P8W" }]
      criteria   = { days_of_week = ["Sunday"] }
    }
  }
}

ℹ️ All seven weekdays are legal, Wednesday included. The provider's documentation for the blob storage and Kubernetes cluster policies omits Wednesday from its equivalent list, so a reader carrying that list across would refuse a legal value. all_seven_weekdays_are_legal emits the real set, taken from the provider's SDK constants.

11 Β· Month-end, without a days_of_month field
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-policy-postgresql-flexible-server.git?ref=v1.0.0"

  name     = "bp-pgsql-month-end"
  vault_id = module.backup_vault.id

  backup_repeating_time_intervals = ["R/2026-09-01T23:00:00+00:00/P1D"]

  default_retention_rule = {
    life_cycle = [{ data_store_type = "VaultStore", duration = "P30D" }]
  }

  retention_rule = {
    "month-end" = {
      priority   = 18
      life_cycle = [{ data_store_type = "VaultStore", duration = "P36M" }]
      criteria = {
        # No days_of_month on this resource -- the LAST week plus a day selector.
        days_of_week   = ["Sunday"]
        weeks_of_month = ["Last"]
      }
    }
  }
}

ℹ️ The blob storage backup policy encodes month-end as days_of_month = [0]. That idiom does not transfer. See there_is_no_days_of_month_on_this_resource.

12 Β· Narrowing, and the empty-list trap
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-policy-postgresql-flexible-server.git?ref=v1.0.0"

  name     = "bp-pgsql-quarterly"
  vault_id = module.backup_vault.id

  backup_repeating_time_intervals = ["R/2026-09-01T23:00:00+00:00/P1D"]

  default_retention_rule = {
    life_cycle = [{ data_store_type = "VaultStore", duration = "P30D" }]
  }

  retention_rule = {
    "quarterly-first-sunday" = {
      priority   = 12
      life_cycle = [{ data_store_type = "VaultStore", duration = "P5Y" }]
      criteria = {
        days_of_week   = ["Sunday"]
        weeks_of_month = ["First"]
        months_of_year = ["January", "April", "July", "October"]

        # Do NOT write `scheduled_backup_times = []`. Omit the field instead --
        # all four selector lists carry an invisible minimum of one item.
      }
    }
  }
}

⚠️ weeks_of_month and months_of_year NARROW a selection; they do not make one. rules_narrowing_an_absolute_criteria names rules that set either while selecting only by absolute_criteria; rules_with_no_selector names rules that select nothing at all. Both reported, never refused β€” nothing in the provider requires a selector.

13 Β· Three timeout keys, not four
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-policy-postgresql-flexible-server.git?ref=v1.0.0"

  name     = "bp-pgsql-timeouts"
  vault_id = module.backup_vault.id

  backup_repeating_time_intervals = ["R/2026-09-01T23:00:00+00:00/P1D"]

  default_retention_rule = {
    life_cycle = [{ data_store_type = "VaultStore", duration = "P30D" }]
  }

  timeouts = {
    create = "30m"
    read   = "5m"
    delete = "30m"
  }
}

πŸ”’ There is no update key, and that is correct. The resource has no update function, so Terraform rejects the key outright rather than accepting and ignoring it β€” worth stating because object-type conversion normally silently discards an undeclared key. The backup instance for this service does have an update function and takes four keys.

14 Β· Changing retention β€” repoint, do not rebuild
# The policy cannot be edited: every argument is force-new and there is no update
# function. But the INSTANCE's backup_policy_id updates IN PLACE, so:
#
#   1. create a NEW policy alongside the old one
#   2. point the instance at it -- an in-place update, no data disturbed
#   3. remove the old policy, once nothing references it
#
# That route does NOT exist on the Kubernetes cluster service.

module "policy_v2" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-policy-postgresql-flexible-server.git?ref=v1.0.0"

  name     = "bp-pgsql-365d"
  vault_id = module.backup_vault.id

  backup_repeating_time_intervals = ["R/2026-09-01T23:00:00+00:00/P1D"]

  default_retention_rule = {
    life_cycle = [{ data_store_type = "VaultStore", duration = "P365D" }]
  }
}

module "instance" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-instance-postgresql-flexible-server.git?ref=v1.0.0"

  name      = "bi-pgsql-prod"
  location  = module.postgresql.location
  vault_id  = module.backup_vault.id
  server_id = module.postgresql.id

  # The in-place update.
  backup_policy_id = module.policy_v2.id
}

output "repoint_available" {
  value = module.policy_v2.the_repoint_path_exists_on_this_service
}

πŸ’‘ Sequence it. Create the new policy, repoint the instances, then remove the old one β€” nothing in Terraform records which instances reference a policy, because the reference is held on the instance.

15 Β· πŸ—οΈ 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-backup-eastus"
  location = "eastus"
}

module "postgresql" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-postgresql-flexible-server.git?ref=v1.0.0"

  name                = "pgsql-prod-eastus"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

module "backup_vault" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-vault.git?ref=v1.0.0"

  name                = "bv-corp"
  resource_group_name = module.rg.name
  location            = module.rg.location
  datastore_type      = "VaultStore"

  identity = {
    type = "SystemAssigned"
  }
}

# THIS MODULE.
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-policy-postgresql-flexible-server.git?ref=v1.0.0"

  name     = "bp-pgsql-daily"
  vault_id = module.backup_vault.id

  backup_repeating_time_intervals = ["R/2026-09-01T23:00:00+00:00/P1D"]
  time_zone                       = "UTC"

  default_retention_rule = {
    life_cycle = [{ data_store_type = "VaultStore", duration = "P30D" }]
  }

  retention_rule = {
    "keep-monthly" = {
      priority   = 15
      life_cycle = [{ data_store_type = "VaultStore", duration = "P12M" }]
      criteria   = { absolute_criteria = "FirstOfMonth" }
    }
  }
}

# The grant the INSTANCE needs, on the SERVER, held by the VAULT's identity.
# Sequence it ahead of the instance: the create blocks on a protection poll.
module "vault_grant" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.postgresql.id

  role_assignments = {
    "vault-backup" = {
      role_definition_name = "Reader"
      principal_id         = module.backup_vault.identity_principal_id
    }
  }
}

module "instance" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-backup-instance-postgresql-flexible-server.git?ref=v1.0.0"

  name     = "bi-pgsql-prod"
  vault_id = module.backup_vault.id

  # The SERVER's own region and ID.
  location  = module.postgresql.location
  server_id = module.postgresql.id

  backup_policy_id = module.policy.id

  timeouts = {
    create = "90m"
  }

  depends_on = [module.vault_grant]
}

output "policy_id" {
  value = module.policy.id
}

output "protection_state" {
  value = module.instance.protection_state
}

output "prerequisites_nothing_checks" {
  value = module.instance.this_service_has_prerequisites_the_provider_documents_and_does_not_check
}

πŸ”΄ depends_on is load-bearing. Nothing in the instance's arguments references the role assignment, so without it Terraform may create the instance first β€” and because that resource polls, the apply then blocks for the full budget waiting for access it does not have.

⚠️ The service has further prerequisites the provider documents and Terraform cannot verify. Check protection_state after the first apply; that is where an unmet one surfaces.

πŸ“₯ Inputs

Required: name, vault_id, backup_repeating_time_intervals, default_retention_rule. Optional: retention_rule, time_zone, timeouts.

Full schemas
variable "default_retention_rule" {
  type = object({
    life_cycle = list(object({
      data_store_type = string   # "VaultStore" -- NOT "OperationalStore"
      duration        = string
    }))
  })
  # REQUIRED -- no default. `life_cycle` has no maximum on this resource.
}

variable "retention_rule" {
  type = map(object({
    priority = number
    life_cycle = list(object({
      data_store_type = string
      duration        = string
    }))
    criteria = object({
      absolute_criteria      = optional(string)
      days_of_week           = optional(list(string))
      months_of_year         = optional(list(string))
      weeks_of_month         = optional(list(string))
      scheduled_backup_times = optional(list(string))
    })
  }))
  default = {}
  # No days_of_month on this resource. The criteria fields ARE provider-validated.
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    delete = optional(string)
  })
  default = null
  # Provider defaults: create 30m, read 5m, delete 30m. There is no update.
}

🧾 Outputs

Output Description Notes
id Policy Resource ID Emitted first. The value an instance references.
name, vault_id, vault_name, resource_group_name, subscription_id Identity and owning vault Parsed from the vault ID.
default_retention_durations, default_life_cycle_count The default retention Known at plan.
backup_repeating_time_intervals, schedule_count The schedule Known at plan.
retention_rule_names, retention_rule_count The rules Known at plan.
rules_with_no_selector, rules_narrowing_an_absolute_criteria Reported, not refused Known at plan.
the_provider_only_checks_the_schedule_is_non_empty, the_time_zone_is_not_validated_against_a_closed_set_here, schedule_validation_differs_across_this_family What the provider does NOT check here Constant.
the_criteria_are_validated_even_though_the_schedule_is_not The asymmetry inside this resource Constant.
the_data_store_type_accepts_exactly_one_value, the_absolute_criteria_set_is_closed_and_case_sensitive, all_seven_weekdays_are_legal, the_week_number_set The real enum sets, as values From the provider's SDK constants.
force_new_fields All six arguments β€”
this_policy_can_never_be_updated, changing_retention_means_a_new_policy_not_an_edit, the_repoint_path_exists_on_this_service, destroying_this_policy_breaks_every_instance_using_it The lifecycle facts Change review.
the_data_store_type_is_the_opposite_of_the_kubernetes_policy, the_default_retention_rule_is_a_block_not_a_duration_string, there_is_no_days_of_month_on_this_resource Differences from the sibling policies Constant.
this_service_backs_up_to_the_vault Where the data goes Constant.
the_schedule_has_a_minimum_but_no_maximum, a_repeating_interval_is_not_a_duration, the_selector_lists_reject_an_empty_list, the_priority_uniqueness_rule_is_documented_but_unenforced Validation traps Constant.
time_zone, time_zone_was_left_to_the_service Time zone β€”
the_provider_declares_no_update_timeout_and_that_is_correct, each_timeout_bounds_only_its_own_operation, no_customize_diff_guards_this_resource Where checks do and do not fire Constant.
this_resource_supports_no_azure_resource_tags Why there is no tags tail Constant.
import_address, the_import_guard_names_this_resource_correctly, the_import_check_can_be_disabled_by_a_provider_feature Import The guard is correct by construction.
lifecycle_prevent_destroy_is_not_available_to_a_module_caller A caller cannot add prevent_destroy Constant.

No output is a secret, and nothing here is marked sensitive β€” there is nothing sensitive to mark.

🧠 Architecture Notes

The interesting property of this resource is what its provider does not check, and the documentation is written to say that plainly rather than to imply otherwise. The schedule strings and the time zone are validated for non-emptiness and nothing more: there is no interval parser, and no time-zone validator file exists for this service at all. Both are validated on siblings β€” the MySQL Flexible Server policy parses its schedule and checks a closed set of 141 identifiers; Data Lake Storage parses, caps at five and checks 142. Reusing that phrasing here would be a protective-claim defect, which this suite treats as the most dangerous documentation error there is, because nobody tests a safeguard they believe exists. So the shape checks in this module are described as the module's own work, which is what they are.

The weakness is not uniform, and over-generalising it would be its own error. The criteria fields are properly validated β€” closed, case-sensitive sets for the absolute criteria, weekdays, months and week numbers, plus a real RFC 3339 parser for the scheduled times. That asymmetry is emitted as the_criteria_are_validated_even_though_the_schedule_is_not so a reader does not conclude the whole resource is unchecked.

The policy is entirely immutable, and on this service that is survivable. All six arguments are force-new and there is no update function, so any change replaces the policy. But the backup instance's backup_policy_id updates in place, so the supported route β€” create a new policy, repoint the instances, remove the old one β€” costs no data. That is worth stating explicitly because it is not true across this family: on the Kubernetes cluster service the instance's policy reference is force-new too, and replacing a policy there replaces every instance using it.

data_store_type is the trap shared with the rest of the family. This resource accepts exactly VaultStore; the Kubernetes cluster policy accepts exactly OperationalStore. Both checks are closed, case-sensitive and one member long, and both carry a provider source comment promising another value later β€” so the two blocks are textually near-identical and accept opposite values. It follows from the storage model: PostgreSQL backup is vaulted, with the data copied into the vault rather than snapshotted into a resource group you nominate.

What the provider does not enforce, this module reports rather than refuses. Nothing requires a retention rule to select anything, so rules_with_no_selector names the rules that select nothing instead of rejecting them. The one place it crosses into enforcement is priority uniqueness: a duplicate leaves two rules with no defined order, which is unambiguously wrong rather than merely unusual.

lifecycle is not valid inside a module block, so a caller cannot add prevent_destroy. Where deletion protection matters, put a CanNotDelete management lock on the vault β€” noting that a lock prevents deletion, not replacement.

🧱 Design Principles

Concern This module's default Opt-out
data_store_type VaultStore enforced β€” the provider's only legal value none exists
Schedule shape Checked by this module, because the provider checks only non-emptiness n/a
time_zone Only the detectable mistake refused (an IANA-shaped value) n/a β€” the legal set cannot be enumerated here
Retention and schedule Required β€” the provider gives no choice n/a
Unenforced selector rule Reported through outputs n/a β€” never refused
Priority uniqueness Enforced, because a duplicate is unambiguously wrong none
Criteria enum sets From the provider's SDK constants, resolved not transcribed n/a
Empty selector list Sent as null, so omit and clear plan alike n/a
retention_rule keying A map keyed by rule name, so nothing re-indexes n/a
tags Not offered β€” the resource exposes none tag the vault instead
Secrets None accepted, none emitted n/a

ℹ️ Secure-by-default has nothing to act on here, and this module says so rather than implying otherwise. Every security-relevant argument on this resource is Required and none of them is permissive β€” there is no empty call to make safe. The risk here is a malformed value the provider will accept and Azure will reject, so the defaults are chosen to catch what the provider does not.

πŸš€ Runbook

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

Pin the module at a tag β€” ?ref=v1.0.0 β€” never a branch. This library is plan-only: a human applies from CI.

πŸ§ͺ Testing

Check Covered by Needs credentials?
HCL parses; types are consistent terraform validate No
Formatting terraform fmt -check No
πŸ”΄ The schedule's shape and the IANA-name time-zone check β€” the provider does neither terraform plan on a configuration that calls the module No
This module's other validation {} blocks β€” the name pattern, the anchored vault_id, the VaultStore check, the enum sets, priority uniqueness, the selector minimums terraform plan No
The provider's own schema checks β€” the criteria enum sets, the RFC 3339 times, the block cardinalities terraform plan No
Whether Azure accepts the schedule strings and the time zone identifier terraform apply Yes

⚠️ terraform validate run against a configuration that calls this module evaluates none of the module's variable values, so neither this module's validation {} blocks nor the provider's schema checks are reached β€” validate reports success. The refusal lands at terraform plan, which needs no credentials for these checks.

πŸ”΄ Note which row matters most here. On this service the schedule and time zone are checked by nothing in the provider, so the first row is the only offline protection there is.

πŸ’¬ Example Output

$ terraform output

id                                    = "/subscriptions/.../resourceGroups/rg-backup-eastus/providers/Microsoft.DataProtection/backupVaults/bv-corp/backupPolicies/bp-pgsql-daily"
name                                  = "bp-pgsql-daily"
vault_name                            = "bv-corp"
backup_repeating_time_intervals       = ["R/2026-09-01T23:00:00+00:00/P1D"]
schedule_count                        = 1
default_retention_durations           = ["P30D"]
default_life_cycle_count              = 1
retention_rule_names                  = ["keep-monthly"]
retention_rule_count                  = 1
rules_with_no_selector                = []
rules_narrowing_an_absolute_criteria  = []
time_zone                             = "UTC"
this_policy_can_never_be_updated      = true
the_repoint_path_exists_on_this_service = true
the_provider_only_checks_the_schedule_is_non_empty = true
the_time_zone_is_not_validated_against_a_closed_set_here = true
the_data_store_type_accepts_exactly_one_value = ["VaultStore"]
force_new_fields                      = [
  "name",
  "vault_id",
  "backup_repeating_time_intervals",
  "default_retention_rule",
  "retention_rule",
  "time_zone",
]

πŸ” Troubleshooting

Symptom Cause Fix
Apply fails on a schedule string that passed every offline check The provider checks only non-emptiness here; Azure parses it Use R/<start instant>/P<period>. This module's shape checks catch the common forms; Azure is the final word.
A schedule that works on the MySQL policy is rejected by Azure here Nothing changed about the string β€” that resource parses it and this one does not, so the same mistake surfaces later Read the_provider_only_checks_the_schedule_is_non_empty.
A schedule runs at an unexpected hour time_zone takes a Windows identifier and the provider checks only non-emptiness on this service Use e.g. UTC or Eastern Standard Time. Note the start instants carry their own UTC offset.
Plan shows the policy being replaced after a small change Every argument is force-new and there is no update function Expected. Create a new policy and repoint the instances β€” the instance's reference updates in place. See example 14.
data_store_type must be "VaultStore" A life_cycle block copied from the Kubernetes cluster backup policy, where the only legal value is OperationalStore Change it. The two services accept exactly opposite values.
A default_retention_rule copied from the blob or Data Lake policy will not parse Those take a plain duration string; this one requires a block Wrap it: default_retention_rule = { life_cycle = [{ ... }] }.
retention_rule priorities must be distinct Two rules share a priority The provider documents the rule and checks nothing; this module enforces it.
A "not enough list items" error naming no field An empty days_of_week / months_of_year / weeks_of_month / scheduled_backup_times Omit the field rather than passing [].
A rule appears to select nothing Nothing in the provider requires a selector Read rules_with_no_selector in the plan output.
A month-end rule cannot be expressed There is no days_of_month on this resource Use weeks_of_month = ["Last"] with a day selector.
Unsupported argument: update on timeouts The resource has no update function Remove the key. Three are supported: create, read, delete.
A create silently overwrote an existing policy The provider's skip-import-check feature is enabled in the caller's provider block That is provider configuration, not module configuration.

πŸ”— Related Docs

πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."