Skip to content

microsoftexpert/tf-mod-azure-postgresql

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🚀 Azure PostgreSQL Terraform Module

A composite module that provisions a PostgreSQL Flexible Server and its entire dependent tree — databases, server parameters, firewall rules, Entra ID admins, virtual endpoints and on-demand backups — plus an optional legacy Single Server family for migration estates. Deeply-typed, secure-by-default, for_each-driven. Built for AzureRM v4.x.

Terraform AzureRM module type resources


🧩 Overview

  • 🐘 Creates one PostgreSQL Flexible Server (this) — the modern, recommended engine.
  • 🗄️ Fans out databases, server parameters (postgresql.conf), firewall rules, Entra ID administrators, virtual endpoints and on-demand backups, each a for_each collection keyed by a stable caller string.
  • 🔐 Secure by defaultpublic_network_access_enabled = false, password kept out of state via the write-only password, CMK + user-assigned identity supported.
  • 🌐 VNet-integratable — delegated subnet + private DNS zone for private access mode.
  • 🏛️ High availability (zone-redundant / same-zone), geo-redundant backups, and elastic clusters — all surfaced as typed, SKU-gated inputs.
  • 🪦 Ships an optional legacy Single Server companion (retired by Azure 2025-03-28) so a migration can hold source and target in one state.
  • 🔎 Two read-only data-source lookups for composing against pre-existing servers.

💡 Why it matters: one deeply-typed boundary turns a sprawling 14-resource PostgreSQL estate into a single reviewable module call — a malformed input fails at plan, not after an API round-trip.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


🗺️ Where this fits in the family

flowchart TD
 rg[tf-mod-azure-resource-group] -->|resource_group_name| pg
 subnet[tf-mod-azure-subnet] -->|delegated_subnet_id| pg
 dns[tf-mod-azure-private-dns-zone] -->|private_dns_zone_id| pg
 uai[tf-mod-azure-user-assigned-identity] -->|identity_ids| pg
 kv[tf-mod-azure-key-vault] -->|CMK key id / admin password| pg

 pg[tf-mod-azure-postgresql]

 pg -->|id| pe[tf-mod-azure-private-endpoint]
 pg -->|id, principal_id| ra[tf-mod-azure-role-assignment]
 pg -->|id| diag[diagnostic settings]

 style pg fill:#8957E5,color:#fff
 style kv fill:#0078D4,color:#fff
Loading
  • Consumes: a resource group, an optional delegated subnet + private DNS zone, an optional user-assigned identity, and Key Vault material (CMK key, admin password).
  • Emits: the server id, fqdn, identity_principal_id, and per-collection ID maps. See the Cross-Module Contract.

🧬 What this module builds

flowchart TD
 this["azurerm_postgresql_flexible_server<br/>(this — primary)"]

 this --> db["…_flexible_server_database<br/>(for_each: databases)"]
 this --> cfg["…_flexible_server_configuration<br/>(for_each: configurations)"]
 this --> fw["…_flexible_server_firewall_rule<br/>(for_each: firewall_rules)"]
 this --> aad["…_flexible_server_active_directory_administrator<br/>(for_each: active_directory_administrators)"]
 this --> ve["…_flexible_server_virtual_endpoint<br/>(for_each: virtual_endpoints)"]
 this --> bk["…_flexible_server_backup<br/>(for_each: backups)"]

 ss["azurerm_postgresql_server<br/>(single_server — optional companion)"]
 ss --> ssdb["…_database (for_each)"]
 ss --> sscfg["…_configuration (for_each)"]
 ss --> ssfw["…_firewall_rule (for_each)"]
 ss --> ssvnet["…_virtual_network_rule (for_each)"]
 ss --> ssaad["…_active_directory_administrator (0..1)"]
 ss --> sskey["…_server_key (0..1)"]

 style this fill:#8957E5,color:#fff
 style ss fill:#6E7681,color:#fff
Loading

Resource inventory

  • azurerm_postgresql_flexible_server.thisprimary server (compute, storage, networking, auth, HA, CMK).
  • azurerm_postgresql_flexible_server_database.database — databases on the server.
  • azurerm_postgresql_flexible_server_configuration.configuration — server parameters.
  • azurerm_postgresql_flexible_server_firewall_rule.firewall_rule — public-access firewall allow-list.
  • azurerm_postgresql_flexible_server_active_directory_administrator.ad_administrator — Entra ID admins.
  • azurerm_postgresql_flexible_server_virtual_endpoint.virtual_endpoint — read/write virtual endpoints for replica routing.
  • azurerm_postgresql_flexible_server_backup.backup — on-demand backups.
  • azurerm_postgresql_server.single_serveroptional legacy Single Server.
  • azurerm_postgresql_database.single_server_database — legacy databases.
  • azurerm_postgresql_configuration.single_server_configuration — legacy server parameters.
  • azurerm_postgresql_firewall_rule.single_server_firewall_rule — legacy firewall rules.
  • azurerm_postgresql_virtual_network_rule.single_server_virtual_network_rule — legacy VNet service-endpoint rules.
  • azurerm_postgresql_active_directory_administrator.single_server_ad_administrator — legacy AD admin (0..1).
  • azurerm_postgresql_server_key.single_server_key — legacy CMK (0..1).
  • data.azurerm_postgresql_flexible_server.lookup / data.azurerm_postgresql_server.lookup — read-only lookups.

✅ Provider / Versions

  • Terraform >= 1.12.0
  • AzureRM >= 4.0, < 5.0
  • No provider {} block in the module — the root configures the provider.

⚠️ Schema notes that bite (4.x):

  • azurerm_postgresql_server and all legacy azurerm_postgresql_* resources are deprecated and removed in v5.0 — expect Deprecated Resource warnings at validate/plan.
  • storage_tier (#24892) is bounded by storage_mb; the provider re-derives the default tier when storage_mb changes (#25947).
  • Flexible Server identity supports UserAssigned only.
  • Write-only password (administrator_password_wo + _version) is a 4.x ephemeral feature — never stored in state.

📁 Module Structure

tf-mod-azure-postgresql/
├── providers.tf # Terraform + AzureRM version pins (no provider config)
├── variables.tf # Deeply-typed inputs, heredoc schemas, validation blocks
├── main.tf # Primary `this` + 6 Flexible children + 7 legacy resources + 2 data sources
├── outputs.tf # Composition contract: server attrs + per-collection ID maps
├── SCOPE.md # Composite scope contract (allow-list, consumes/emits, gotchas)
└── README.md # You are here

⚙️ Quick Start

module "postgresql" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-postgresql?ref=v1.0.0"

  name                = "psql-fie-dev-01"
  resource_group_name = module.resource_group.name
  location            = "eastus2"

  postgresql_version = "16"
  sku_name           = "GP_Standard_D2s_v3"
  storage_mb         = 32768

  administrator_login    = "pgadmin"
  administrator_password = var.pg_admin_password # from Key Vault; sensitive

  databases = {
    appdb = {}
  }

  tags = { environment = "dev", workload = "core-banking" }
}

🔌 Cross-Module Contract

Consumes

Input Type Source
📁 resource_group_name string tf-mod-azure-resource-group.name
🌐 delegated_subnet_id string tf-mod-azure-subnet.id (delegated to Microsoft.DBforPostgreSQL/flexibleServers)
🧭 private_dns_zone_id string tf-mod-azure-private-dns-zone.id (*.postgres.database.azure.com)
🪪 identity.identity_ids list(string) tf-mod-azure-user-assigned-identity.id
🔑 customer_managed_key.key_vault_key_id string tf-mod-azure-key-vault key ID
🔒 administrator_password / administrator_password_wo string (sensitive) tf-mod-azure-key-vault secret

Emits

Output Description Consumed by
id Flexible Server resource ID tf-mod-azure-private-endpoint, tf-mod-azure-role-assignment, diagnostic settings
name Server name Any module referencing the server by name
resource_group_name RG pass-through Sibling modules in the same RG
fqdn Server FQDN Connection-string assembly
identity_principal_id UAMI principal ID (null when no identity) tf-mod-azure-role-assignment (principal_id)
identity_tenant_id Identity tenant ID (null when no identity) Cross-tenant wiring
database_ids / configuration_ids / firewall_rule_ids / active_directory_administrator_ids / virtual_endpoint_ids / backup_ids Per-collection name → ID maps Downstream grants, audit
single_server_* Legacy companion outputs (null/empty when unset) Migration tooling
flexible_server_lookups / single_server_lookups label → { id, fqdn, version } Composition against existing servers

📚 Example Library

1️⃣ Minimal — smallest call that produces a real server
module "postgresql" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-postgresql?ref=v1.0.0"

  name                = "psql-fie-dev-01"
  resource_group_name = "rg-data-dev"
  location            = "eastus2"

  administrator_login    = "pgadmin"
  administrator_password = var.pg_admin_password
}

💡 Defaults: PostgreSQL 16, B_Standard_B1ms, 32 GiB, public_network_access_enabled = false, auto_grow_enabled = true.

2️⃣ Databases & server parameters
module "postgresql" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-postgresql?ref=v1.0.0"

  name                = "psql-fie-dev-02"
  resource_group_name = "rg-data-dev"
  location            = "eastus2"
  sku_name            = "GP_Standard_D2s_v3"

  administrator_login    = "pgadmin"
  administrator_password = var.pg_admin_password

  databases = {
    appdb     = { charset = "UTF8", collation = "en_US.utf8" }
    reporting = {}
  }

  configurations = {
    "log_min_duration_statement" = { value = "500" }
    "max_connections"            = { value = "200" } # static — triggers a restart
  }
}

⚠️ Some parameters are static: changing them restarts the server.

3️⃣ Entra ID (Azure AD) authentication + administrators
module "postgresql" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-postgresql?ref=v1.0.0"

  name                = "psql-fie-dev-03"
  resource_group_name = "rg-data-dev"
  location            = "eastus2"
  sku_name            = "GP_Standard_D2s_v3"

  authentication = {
    active_directory_auth_enabled = true
    password_auth_enabled         = false # Entra-only — no SQL password
    tenant_id                     = var.tenant_id
  }

  active_directory_administrators = {
    dba_group = {
      object_id      = var.dba_group_object_id
      principal_name = "pg-dba-admins"
      principal_type = "Group"
      tenant_id      = var.tenant_id
    }
  }
}

🔒 With password_auth_enabled = false, omit administrator_password entirely.

4️⃣ High availability (zone-redundant)
module "postgresql" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-postgresql?ref=v1.0.0"

  name                = "psql-fie-prd-01"
  resource_group_name = "rg-data-prd"
  location            = "eastus2"
  sku_name            = "GP_Standard_D4s_v3" # GP_/MO_ required for HA
  zone                = "1"

  high_availability = {
    mode                      = "ZoneRedundant"
    standby_availability_zone = "2"
  }

  administrator_login    = "pgadmin"
  administrator_password = var.pg_admin_password
}

⚠️ HA is rejected on Burstable (B_) SKUs and ZoneRedundant needs a multi-AZ region. You cannot switch HA mode in place — disable, then re-enable.

5️⃣ Storage sizing & tier + geo-redundant backups
module "postgresql" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-postgresql?ref=v1.0.0"

  name                = "psql-fie-prd-02"
  resource_group_name = "rg-data-prd"
  location            = "eastus2"
  sku_name            = "GP_Standard_D4s_v3"

  storage_mb                   = 131072 # 128 GiB
  storage_tier                 = "P15"  # bounded by storage_mb
  backup_retention_days        = 21
  geo_redundant_backup_enabled = true # IMMUTABLE — create-only

  administrator_login    = "pgadmin"
  administrator_password = var.pg_admin_password
}

⚠️ storage_mb only grows; geo_redundant_backup_enabled is ForceNew.

6️⃣ VNet integration (private access)
module "postgresql" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-postgresql?ref=v1.0.0"

  name                = "psql-fie-prd-03"
  resource_group_name = "rg-data-prd"
  location            = "eastus2"
  sku_name            = "GP_Standard_D2s_v3"

  public_network_access_enabled = false
  delegated_subnet_id           = module.subnet_postgres.id
  private_dns_zone_id           = module.private_dns_zone.id

  administrator_login    = "pgadmin"
  administrator_password = var.pg_admin_password
}

🔒 The subnet must be delegated to Microsoft.DBforPostgreSQL/flexibleServers and not shared. You cannot combine public access with VNet, nor move a server in/out of a VNet later (delegated_subnet_id is ForceNew).

7️⃣ Write-only password (kept out of state)
module "postgresql" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-postgresql?ref=v1.0.0"

  name                = "psql-fie-prd-04"
  resource_group_name = "rg-data-prd"
  location            = "eastus2"
  sku_name            = "GP_Standard_D2s_v3"

  administrator_login               = "pgadmin"
  administrator_password_wo         = var.pg_admin_password # never persisted to state
  administrator_password_wo_version = 1                     # bump to rotate
}

🔒 administrator_password_wo is mutually exclusive with administrator_password and requires administrator_password_wo_version.

8️⃣ Customer-managed key (CMK) encryption
module "postgresql" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-postgresql?ref=v1.0.0"

  name                = "psql-fie-prd-05"
  resource_group_name = "rg-data-prd"
  location            = "eastus2"
  sku_name            = "GP_Standard_D2s_v3"

  identity = {
    type         = "UserAssigned" # the only valid type
    identity_ids = [module.uai_postgres.id]
  }

  customer_managed_key = {
    key_vault_key_id                  = module.key_vault.cmk_key_id
    primary_user_assigned_identity_id = module.uai_postgres.id
  }

  administrator_login    = "pgadmin"
  administrator_password = var.pg_admin_password
}

🔒 CMK requires a UserAssigned identity with wrap/unwrap rights on the key.

9️⃣ Read replica + virtual endpoint
# Replica server (create_mode = Replica, points at a source server)
module "postgresql_replica" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-postgresql?ref=v1.0.0"

  name                = "psql-fie-prd-replica"
  resource_group_name = "rg-data-prd"
  location            = "eastus2"
  sku_name            = "GP_Standard_D2s_v3"

  create_mode      = "Replica"
  source_server_id = module.postgresql_primary.id
}

# Virtual endpoint on the primary, routing to the replica
module "postgresql_primary" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-postgresql?ref=v1.0.0"

  name                = "psql-fie-prd-primary"
  resource_group_name = "rg-data-prd"
  location            = "eastus2"
  sku_name            = "GP_Standard_D2s_v3"

  administrator_login    = "pgadmin"
  administrator_password = var.pg_admin_password

  virtual_endpoints = {
    rw = {
      type              = "ReadWrite"
      replica_server_id = module.postgresql_replica.id
      # source_server_id defaults to this server
    }
  }
}
🔟 Point-in-time restore (PITR)
module "postgresql_restored" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-postgresql?ref=v1.0.0"

  name                = "psql-fie-prd-restored"
  resource_group_name = "rg-data-prd"
  location            = "eastus2"

  create_mode                       = "PointInTimeRestore"
  source_server_id                  = module.postgresql_primary.id
  point_in_time_restore_time_in_utc = "2026-06-15T08:00:00Z"
}

⚠️ A restored server is created without the source's firewall rules — re-declare them via firewall_rules.

1️⃣1️⃣ Security / hardening (-compliant)
module "postgresql" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-postgresql?ref=v1.0.0"

  name                = "psql-fie-prd-secure"
  resource_group_name = "rg-data-prd"
  location            = "eastus2"
  sku_name            = "GP_Standard_D4s_v3"
  postgresql_version  = "16"

  # Private only
  public_network_access_enabled = false
  delegated_subnet_id           = module.subnet_postgres.id
  private_dns_zone_id           = module.private_dns_zone.id

  # Entra-only auth, no SQL password in state
  authentication = {
    active_directory_auth_enabled = true
    password_auth_enabled         = false
    tenant_id                     = var.tenant_id
  }
  active_directory_administrators = {
    dba = {
      object_id      = var.dba_group_object_id
      principal_name = "pg-dba-admins"
      principal_type = "Group"
      tenant_id      = var.tenant_id
    }
  }

  # CMK + HA + geo backups
  identity                     = { type = "UserAssigned", identity_ids = [module.uai_postgres.id] }
  customer_managed_key         = { key_vault_key_id = module.key_vault.cmk_key_id, primary_user_assigned_identity_id = module.uai_postgres.id }
  high_availability            = { mode = "ZoneRedundant" }
  geo_redundant_backup_enabled = true

  tags = { environment = "prod", data_classification = "confidential" }
}

🔒 No public access, no SQL password, CMK at rest, zone-redundant HA, geo-redundant backups — the locked-down baseline.

1️⃣2️⃣ `for_each` at scale — many databases & firewall rules from maps
locals {
  tenants = ["alpha", "bravo", "charlie", "delta"]
}

module "postgresql" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-postgresql?ref=v1.0.0"

  name                = "psql-fie-prd-multitenant"
  resource_group_name = "rg-data-prd"
  location            = "eastus2"
  sku_name            = "GP_Standard_D4s_v3"

  administrator_login    = "pgadmin"
  administrator_password = var.pg_admin_password

  # Stable keys = database names. Renaming a key destroys & recreates that DB.
  databases = { for t in local.tenants : "tenant_${t}" => { collation = "en_US.utf8" } }

  firewall_rules = {
    onprem_dc1 = { start_ip_address = "10.10.0.0", end_ip_address = "10.10.0.255" }
    onprem_dc2 = { start_ip_address = "10.20.0.0", end_ip_address = "10.20.0.255" }
    azure_svcs = { start_ip_address = "0.0.0.0", end_ip_address = "0.0.0.0" } # allow Azure services
  }
}

💡 Keys are identity and ForceNew — pick tenant_alpha, not an index, so inserting tenant_zeta later doesn't renumber the others.

1️⃣3️⃣ Read-only lookups of existing servers
module "postgresql" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-postgresql?ref=v1.0.0"

  name                = "psql-fie-prd-new"
  resource_group_name = "rg-data-prd"
  location            = "eastus2"

  administrator_login    = "pgadmin"
  administrator_password = var.pg_admin_password

  flexible_server_lookups = {
    legacy_primary = { name = "psql-existing-01", resource_group_name = "rg-legacy" }
  }
}

# module.postgresql.flexible_server_lookups["legacy_primary"].fqdn
1️⃣4️⃣ Legacy Single Server companion (migration estates)
module "postgresql" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-postgresql?ref=v1.0.0"

  # New Flexible Server (migration target)
  name                = "psql-fie-target"
  resource_group_name = "rg-data-prd"
  location            = "eastus2"
  sku_name            = "GP_Standard_D2s_v3"

  administrator_login    = "pgadmin"
  administrator_password = var.pg_admin_password

  # Existing legacy Single Server (migration source) — DEPRECATED, retired 2025-03-28
  single_server = {
    name     = "psql-legacy-single-01"
    sku_name = "GP_Gen5_4"
    version  = "11"
  }
  single_server_administrator_password = var.legacy_pg_admin_password # sensitive, split out

  single_server_databases      = { legacydb = { charset = "UTF8", collation = "English_United States.1252" } }
  single_server_firewall_rules = { onprem = { start_ip_address = "10.0.0.0", end_ip_address = "10.0.0.255" } }
}

⚠️ Every legacy resource emits a deprecation warning. Use this only to manage a server you are migrating off.

🏗️ End-to-end composition (mandatory) — full suite wired via outputs → inputs
module "resource_group" {
  source   = "git::https://github.com/microsoftexpert/tf-mod-azure-resource-group?ref=v1.0.0"
  name     = "rg-data-prd"
  location = "eastus2"
}

module "uai_postgres" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-user-assigned-identity?ref=v1.0.0"
  name                = "uai-postgres-prd"
  resource_group_name = module.resource_group.name
  location            = "eastus2"
}

module "private_dns_zone" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-private-dns-zone?ref=v1.0.0"
  name                = "privatelink.postgres.database.azure.com"
  resource_group_name = module.resource_group.name
}

module "subnet_postgres" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-subnet?ref=v1.0.0"
  # … delegated to Microsoft.DBforPostgreSQL/flexibleServers …
}

module "key_vault" {
  source              = "git::https://github.com/microsoftexpert/tf-mod-azure-key-vault?ref=v1.0.0"
  name                = "kv-data-prd"
  resource_group_name = module.resource_group.name
  location            = "eastus2"
}

module "postgresql" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-postgresql?ref=v1.0.0"

  name                = "psql-fie-prd-e2e"
  resource_group_name = module.resource_group.name
  location            = "eastus2"
  sku_name            = "GP_Standard_D4s_v3"

  public_network_access_enabled = false
  delegated_subnet_id           = module.subnet_postgres.id
  private_dns_zone_id           = module.private_dns_zone.id

  identity             = { type = "UserAssigned", identity_ids = [module.uai_postgres.id] }
  customer_managed_key = { key_vault_key_id = module.key_vault.cmk_key_id, primary_user_assigned_identity_id = module.uai_postgres.id }
  high_availability    = { mode = "ZoneRedundant" }

  administrator_login    = "pgadmin"
  administrator_password = var.pg_admin_password

  databases = { appdb = {} }
}

# Wire the server into a private endpoint and a role assignment
module "private_endpoint" {
  source      = "git::https://github.com/microsoftexpert/tf-mod-azure-private-endpoint?ref=v1.0.0"
  resource_id = module.postgresql.id
  #
}

module "role_assignment" {
  source       = "git::https://github.com/microsoftexpert/tf-mod-azure-role-assignment?ref=v1.0.0"
  principal_id = module.postgresql.identity_principal_id
  #
}

📥 Inputs

Corename, resource_group_name, location, postgresql_version, sku_name, storage_mb, storage_tier, auto_grow_enabled, backup_retention_days, geo_redundant_backup_enabled. Networkingpublic_network_access_enabled, delegated_subnet_id, private_dns_zone_id, zone. Credentialsadministrator_login, administrator_password (sensitive), administrator_password_wo (sensitive), administrator_password_wo_version. Provenancecreate_mode, source_server_id, point_in_time_restore_time_in_utc, replication_role. Blocksauthentication, high_availability, maintenance_window, identity, customer_managed_key, cluster. Flexible collectionsdatabases, configurations, firewall_rules, active_directory_administrators, virtual_endpoints, backups. Legacy companionsingle_server, single_server_administrator_password (sensitive), single_server_threat_detection_storage_access_key (sensitive), single_server_databases, single_server_configurations, single_server_firewall_rules, single_server_virtual_network_rules, single_server_active_directory_administrator, single_server_key. Lookupsflexible_server_lookups, single_server_lookups. Universal tailtags, timeouts.

Full object schemas for the nested map / object inputs
# Optional single blocks ------------------------------------------------------
authentication = object({
  active_directory_auth_enabled = optional(bool, false)
  password_auth_enabled         = optional(bool, true)
  tenant_id                     = optional(string)
})

high_availability = object({
  mode                      = string           # "ZoneRedundant" | "SameZone" (SKU-GATED: GP_/MO_ only)
  standby_availability_zone = optional(string) # "1" | "2" | "3"
})

maintenance_window = object({
  day_of_week  = optional(number, 0) # 0=Sun … 6=Sat
  start_hour   = optional(number, 0) # 0-23 UTC
  start_minute = optional(number, 0) # 0-59
})

identity = object({
  type         = optional(string, "UserAssigned") # only "UserAssigned"
  identity_ids = list(string)
})

customer_managed_key = object({
  key_vault_key_id                     = string
  primary_user_assigned_identity_id    = optional(string)
  geo_backup_key_vault_key_id          = optional(string)
  geo_backup_user_assigned_identity_id = optional(string)
})

cluster = object({
  size                  = number # node count (SKU-GATED / preview)
  default_database_name = optional(string)
})

# Flexible Server child collections (map key = name/label) --------------------
databases = map(object({
  charset   = optional(string, "UTF8")       # IMMUTABLE
  collation = optional(string, "en_US.utf8") # IMMUTABLE
}))

configurations = map(object({ value = string }))

firewall_rules = map(object({
  start_ip_address = string
  end_ip_address   = string
}))

active_directory_administrators = map(object({
  object_id      = string
  principal_name = string
  principal_type = string # "User" | "Group" | "ServicePrincipal"
  tenant_id      = string
}))

virtual_endpoints = map(object({
  type              = optional(string, "ReadWrite") # only "ReadWrite"
  replica_server_id = string
  source_server_id  = optional(string) # defaults to this server
}))

backups = map(object({})) # key = backup name

# Legacy Single Server companion ---------------------------------------------
single_server = object({
  name                              = string # IMMUTABLE
  sku_name                          = string # B_/GP_/MO_ tiers
  version                           = string # "9.5" | "9.6" | "10" | "10.0" | "10.2" | "11"
  ssl_enforcement_enabled           = optional(bool, true)
  ssl_minimal_tls_version_enforced  = optional(string, "TLS1_2")
  public_network_access_enabled     = optional(bool, false)
  administrator_login               = optional(string) # IMMUTABLE
  auto_grow_enabled                 = optional(bool, true)
  backup_retention_days             = optional(number, 7)
  geo_redundant_backup_enabled      = optional(bool, false) # IMMUTABLE
  storage_mb                        = optional(number)
  infrastructure_encryption_enabled = optional(bool, false) # IMMUTABLE
  create_mode                       = optional(string, "Default")
  creation_source_server_id         = optional(string)
  restore_point_in_time             = optional(string)
  identity_type                     = optional(string) # "SystemAssigned" or null
  threat_detection_policy = optional(object({
    enabled              = optional(bool, true)
    disabled_alerts      = optional(list(string))
    email_account_admins = optional(bool)
    email_addresses      = optional(list(string))
    retention_days       = optional(number)
    storage_endpoint     = optional(string)
  }))
})
# secrets split out (sensitive): single_server_administrator_password,
# single_server_threat_detection_storage_access_key

🧾 Outputs

Output Description Notes
id Flexible Server resource ID
name Server name
resource_group_name RG pass-through
fqdn Server FQDN
public_network_access_enabled Public traffic flag
identity_principal_id UAMI principal ID null when no identity (try)
identity_tenant_id Identity tenant ID null when no identity (try)
database_ids Map: db name → ID {} when none
configuration_ids Map: parameter → ID {} when none
firewall_rule_ids Map: rule → ID {} when none
active_directory_administrator_ids Map: label → ID {} when none
virtual_endpoint_ids Map: endpoint → ID {} when none
backup_ids Map: backup → ID {} when none
single_server_id / single_server_name / single_server_fqdn Legacy server attrs null when single_server unset
single_server_identity_principal_id Legacy system-assigned principal ID null when unset
single_server_database_ids / single_server_firewall_rule_ids / single_server_virtual_network_rule_ids Legacy child maps {} when unset
flexible_server_lookups / single_server_lookups label → { id, fqdn, version } {} when no lookups

ℹ️ No output exposes a password — secrets stay out of state by design (write-only password + split sensitive vars).


🧠 Architecture Notes

  • for_each keys are identity. Every child collection iterates a map(object(...)) keyed by the resource's own name/label, and that name is ForceNew. Renaming a key (appdbapp_db) destroys and recreates that database — losing its data. Treat keys as permanent; choose meaningful, stable strings (tenant_alpha, never list indices). This is why the module uses for_each, never count: with count, inserting an item mid-list renumbers everything after it and triggers a cascade of destroy/creates.
  • SKU gating, in-place vs recreate. Tier prefix is the gate: Burstable (B_) has no HA, no zone redundancy, no built-in PgBouncer — only GP_/MO_ do. Scaling vCores/tier (B_GP_, D2sD4s) is an in-place compute change with a brief restart. Enabling/disabling HA is online. But HA mode cannot change in place (SameZoneZoneRedundant — disable then re-enable), major version downgrade forces recreate, and storage_mb shrink forces recreate (storage only grows).
  • VNet integration is one-way and exclusive. A subnet given to delegated_subnet_id must be delegated to Microsoft.DBforPostgreSQL/flexibleServers, not shared with another service, and sized for the server (a /28+ is the practical floor — Azure reserves 5 IPs per subnet and HA consumes more). You cannot mix public access with VNet, and you cannot move a server in or out of a VNet after creation — both delegated_subnet_id and private_dns_zone_id are ForceNew. Private endpoint vs service endpoint: Flexible Server uses VNet injection (delegated subnet), not private endpoints, for private access; the legacy Single Server uses service-endpoint rules (virtual_network_rule) or private endpoints. Prefer VNet injection + private DNS for the modern engine.
  • Large composite, safe partial applies. With 14 resource types behind one module, use targeted applies to move one collection at a time: terraform apply -target='module.postgresql.azurerm_postgresql_flexible_server_database.database["appdb"]'. Because children depend on this (via server_id/server_name), Terraform always creates/destroys them in the right order; an empty collection plans zero resources, so unused features cost nothing.
  • The sensitive split & nonsensitive. Terraform forbids for_each over a sensitive value (the key could leak as an instance key). The legacy server's secrets (administrator_login_password, threat-detection storage key) are therefore lifted into dedicated sensitive variables, leaving single_server non-sensitive so its name can key the conditional for_each map. (If you ever feed a sensitive map into a collection's for_each, wrap the keys with nonsensitive — never the values.)
  • Flexible AD admin addressing. The provider addresses azurerm_postgresql_flexible_server_active_directory_administrator by server_name + resource_group_name, not server_idmain.tf wires this.name so ordering is implicit.
  • Identity is UserAssigned-only on Flexible Server; CMK requires that identity to hold wrap/unwrap on the key. The legacy server, conversely, is SystemAssigned-only.

🧱 Design Principles

  • 🔒 Secrets stay out of state — write-only password, split sensitive variables, no secret outputs.
  • 🧱 Deeply-typed inputs — every provider block mirrored in an object; a typo is a plan-time type error.
  • 🎯 Small blast radiusfor_each keyed by identity; an empty collection is zero resources.
  • 🧮 main.tf is a total rendererdynamic blocks for every optional/repeating block, try(x, null) on every optional nested field.
  • 🛡️ Secure by default — private network, HA/CMK opt-in but first-class, validations for every enum and the SKU/HA cross-field rule.
  • 🔌 Composes, never configures — no provider {} block; consumes siblings by ID, emits IDs for the next module.

🚀 Runbook

terraform init -backend=false
terraform validate # passes with Single Server deprecation warnings only
terraform fmt -check
terraform plan -out tfplan
terraform apply tfplan
terraform output

⚠️ Pin the version. Always source with ?ref=v1.0.0 (an immutable tag) — never a branch.


🧪 Testing

terraform fmt -check # zero formatting drift
terraform validate # zero errors (deprecation warnings expected)
tflint # provider-aware linting

💬 Example Output

id = "/subscriptions/…/flexibleServers/psql-fie-prd-e2e"
name = "psql-fie-prd-e2e"
fqdn = "psql-fie-prd-e2e.postgres.database.azure.com"
identity_principal_id = "00000000-0000-0000-0000-000000000000"
database_ids = {
 "appdb" = "/subscriptions/…/flexibleServers/psql-fie-prd-e2e/databases/appdb"
}
administrator_password = (sensitive value)

🔍 Troubleshooting

  • Deprecated Resource warnings for azurerm_postgresql_*. Expected — Single Server was retired 2025-03-28 and the resources are removed in azurerm v5.0. They are warnings, not errors; validate still passes. Leave single_server null on green-field deployments to silence them.
  • high_availability rejected / "not supported on Burstable". HA needs GP_/MO_. The module's cross-field validation catches this at plan; switch sku_name off B_.
  • "Zone redundant high availability is not supported in a single availability zone region." The region has one AZ — use mode = "SameZone", or pick a multi-AZ region.
  • "Cannot switch …HighAvailability.Mode directly from SameZone to ZoneRedundant." HA mode is not editable in place. Set high_availability = null (disable), apply, then re-add with the new mode.
  • Cannot enable public access + VNet together / server won't move VNets. These are exclusive and immutable. delegated_subnet_id/private_dns_zone_id are ForceNew — to change network posture you must recreate (use PITR into a new server).
  • Major version won't downgrade. Azure supports only major-version upgrade. Lowering postgresql_version forces a new server; migrate data instead.
  • storage_tier invalid for the chosen storage_mb. The legal tier set is bounded by size (e.g. 32 GiB → P4/P6/P10). Leave storage_tier = null to let Azure derive it, or raise storage_mb.
  • Restored (PITR/GeoRestore) server has no firewall rules. Azure does not copy them — re-declare via firewall_rules.
  • Old client can't authenticate after upgrade to PG 14+. PG 14+ disables MD5 and uses SCRAM-SHA-256 only — update the client driver / connection settings.
  • v5 compute server costs doubled after enabling HA. Since April 2024 the v5 tier bills primary and standby; HA-enabled v5 quantities are ×2. Expected, not a bug.
  • Subnet too small / delegation error. The delegated subnet must be delegated solely to Microsoft.DBforPostgreSQL/flexibleServers and large enough for the server (and standby, with HA). Use a dedicated /28 or larger.
  • for_each value depends on resource attributes that cannot be determined…`. A collection key is being derived from an unknown (apply-time) value. Keys must be known at plan — use literal strings / input-derived names, not other resources' computed IDs.

🔗 Related Docs

  • Azure Database for PostgreSQL Flexible Server — overview, high availability, networking, and limits (Microsoft Learn)
  • Terraform Registry — azurerm_postgresql_flexible_server and its child resources
  • Terraform Registry — azurerm_postgresql_server (deprecated) and legacy children
  • module library — tf-mod-azure-resource-group, tf-mod-azure-subnet, tf-mod-azure-private-dns-zone, tf-mod-azure-user-assigned-identity, tf-mod-azure-key-vault, tf-mod-azure-private-endpoint, tf-mod-azure-role-assignment

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