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.
- 🐘 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 afor_eachcollection keyed by a stable caller string. - 🔐 Secure by default —
public_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.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
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!
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
- 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.
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
Resource inventory
azurerm_postgresql_flexible_server.this— primary 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_server— optional 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.
- 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_serverand all legacyazurerm_postgresql_*resources are deprecated and removed in v5.0 — expectDeprecated Resourcewarnings atvalidate/plan.storage_tier(#24892) is bounded bystorage_mb; the provider re-derives the default tier whenstorage_mbchanges (#25947).- Flexible Server
identitysupports UserAssigned only.- Write-only password (
administrator_password_wo+_version) is a 4.x ephemeral feature — never stored in state.
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
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" }
}| 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 |
| 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 |
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, omitadministrator_passwordentirely.
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 andZoneRedundantneeds 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_mbonly grows;geo_redundant_backup_enabledis 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/flexibleServersand not shared. You cannot combine public access with VNet, nor move a server in/out of a VNet later (delegated_subnet_idis 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_wois mutually exclusive withadministrator_passwordand requiresadministrator_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 viafirewall_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 insertingtenant_zetalater 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"].fqdn1️⃣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
# …
}Core — name, resource_group_name, location, postgresql_version, sku_name, storage_mb, storage_tier, auto_grow_enabled, backup_retention_days, geo_redundant_backup_enabled.
Networking — public_network_access_enabled, delegated_subnet_id, private_dns_zone_id, zone.
Credentials — administrator_login, administrator_password (sensitive), administrator_password_wo (sensitive), administrator_password_wo_version.
Provenance — create_mode, source_server_id, point_in_time_restore_time_in_utc, replication_role.
Blocks — authentication, high_availability, maintenance_window, identity, customer_managed_key, cluster.
Flexible collections — databases, configurations, firewall_rules, active_directory_administrators, virtual_endpoints, backups.
Legacy companion — single_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.
Lookups — flexible_server_lookups, single_server_lookups.
Universal tail — tags, 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| 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).
for_eachkeys are identity. Every child collection iterates amap(object(...))keyed by the resource's own name/label, and that name is ForceNew. Renaming a key (appdb→app_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 usesfor_each, nevercount: withcount, 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 — onlyGP_/MO_do. Scaling vCores/tier (B_→GP_,D2s→D4s) is an in-place compute change with a brief restart. Enabling/disabling HA is online. But HA mode cannot change in place (SameZone⇆ZoneRedundant— disable then re-enable), major version downgrade forces recreate, andstorage_mbshrink forces recreate (storage only grows). - VNet integration is one-way and exclusive. A subnet given to
delegated_subnet_idmust be delegated toMicrosoft.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 — bothdelegated_subnet_idandprivate_dns_zone_idare 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 onthis(viaserver_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 forbidsfor_eachover 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 dedicatedsensitivevariables, leavingsingle_servernon-sensitive so itsnamecan key the conditionalfor_eachmap. (If you ever feed a sensitive map into a collection'sfor_each, wrap the keys withnonsensitive— never the values.) - Flexible AD admin addressing. The provider addresses
azurerm_postgresql_flexible_server_active_directory_administratorbyserver_name+resource_group_name, notserver_id—main.tfwiresthis.nameso 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.
- 🔒 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 aplan-time type error. - 🎯 Small blast radius —
for_eachkeyed by identity; an empty collection is zero resources. - 🧮
main.tfis a total renderer —dynamicblocks 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.
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.
terraform fmt -check # zero formatting drift
terraform validate # zero errors (deprecation warnings expected)
tflint # provider-aware lintingid = "/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)
Deprecated Resourcewarnings forazurerm_postgresql_*. Expected — Single Server was retired 2025-03-28 and the resources are removed in azurerm v5.0. They are warnings, not errors;validatestill passes. Leavesingle_servernull on green-field deployments to silence them.high_availabilityrejected / "not supported on Burstable". HA needsGP_/MO_. The module's cross-field validation catches this atplan; switchsku_nameoffB_.- "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_idare 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_versionforces a new server; migrate data instead. storage_tierinvalid for the chosenstorage_mb. The legal tier set is bounded by size (e.g. 32 GiB → P4/P6/P10). Leavestorage_tier = nullto let Azure derive it, or raisestorage_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/flexibleServersand large enough for the server (and standby, with HA). Use a dedicated /28 or larger. for_eachvalue 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.
- Azure Database for PostgreSQL Flexible Server — overview, high availability, networking, and limits (Microsoft Learn)
- Terraform Registry —
azurerm_postgresql_flexible_serverand 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."