Quick take — A production-grade Terraform module for azurerm_postgresql_flexible_server: VNet-injected private access, zone-redundant HA, PITR backups, server parameters and databases — wired for hashicorp/azurerm ~> 4.0. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "azurerm" {
features {}
}
module "postgresql_flexible" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-postgresql-flexible?ref=v1.0.0"
name = "..." # Server name; 3-63 lowercase alphanumeric/hyphen chars, …
resource_group_name = "..." # Resource group for the server and optional DNS zone.
location = "..." # Azure region supporting Flexible Server and the chosen …
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Azure Database for PostgreSQL Flexible Server is the deployment model that replaced Single Server as the default managed Postgres on Azure. Unlike Single Server, it gives you a real placement story: you choose the availability zone, you can run zone-redundant or same-zone high availability with a hot standby, and you can inject the server straight into a delegated subnet so it never gets a public IP. It also exposes flexibleServerConfigurations (the postgresql.conf-style knobs like max_connections, shared_buffers, log_min_duration_statement), maintenance windows, and burstable/general-purpose/memory-optimized SKUs that let you scale compute and storage independently.
The catch is that a correct deployment is never a single resource. You almost always need: the server, a private DNS zone (privatelink.postgres.database.azure.com style) linked to the VNet so name resolution works for VNet integration, one or more databases, a fistful of server parameters, and a maintenance window. Worse, several arguments are immutable — zone, delegated_subnet_id, private_dns_zone_id, and create_mode force a replace if you get them wrong, and HA can only be enabled in regions/SKUs that support it. Wrapping all of this in a module turns “read three doc pages and hope” into a vetted, variable-driven block that bakes in the ordering (DNS zone link before the server), sane validations, and the production sub-resources every team re-implements by hand.
When to use it
- You are standing up Postgres for an app that must stay off the public internet — VNet-injected, resolved through a private DNS zone, reachable only from peered/internal networks.
- You want zone-redundant HA with automatic failover and a defined maintenance window, rather than the best-effort Single Server SLA.
- You are running many databases or many environments (dev/test/prod, or per-team servers) and want one reviewed pattern instead of copy-pasted HCL that drifts.
- You need to manage server parameters as code (e.g.
azure.extensions,max_connections,log_min_duration_statement) so DBA tuning is captured in Git and applied consistently. - You want predictable cost control by pinning SKU, storage tier, and backup retention as inputs and reviewing them in PRs.
If you only need a throwaway, publicly-accessible Postgres for a five-minute spike, this is overkill — use a single resource with public_network_access_enabled = true and a firewall rule.
Module structure
terraform-module-azure-postgresql-flexible/
├── versions.tf # provider + version pins
├── main.tf # private DNS, server, HA/maintenance, databases, parameters
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id/name/fqdn + connection helpers
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
main.tf
locals {
# Flexible Server requires a private DNS zone whose name ends in
# ".postgres.database.azure.com" when using VNet (delegated subnet) integration.
create_dns_zone = var.delegated_subnet_id != null && var.private_dns_zone_id == null
private_dns_zone_id = local.create_dns_zone ? azurerm_private_dns_zone.this[0].id : var.private_dns_zone_id
}
# Generate an admin password only when the caller did not supply one
# and is not using Entra-only authentication.
resource "random_password" "admin" {
count = var.administrator_password == null && var.password_auth_enabled ? 1 : 0
length = 28
special = true
override_special = "!#%*-_=+?"
min_lower = 2
min_upper = 2
min_numeric = 2
min_special = 2
}
resource "azurerm_private_dns_zone" "this" {
count = local.create_dns_zone ? 1 : 0
name = "${var.name}.private.postgres.database.azure.com"
resource_group_name = var.resource_group_name
tags = var.tags
}
resource "azurerm_private_dns_zone_virtual_network_link" "this" {
count = local.create_dns_zone ? 1 : 0
name = "${var.name}-vnet-link"
resource_group_name = var.resource_group_name
private_dns_zone_name = azurerm_private_dns_zone.this[0].name
virtual_network_id = var.private_dns_vnet_id
registration_enabled = false
tags = var.tags
}
resource "azurerm_postgresql_flexible_server" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
version = var.postgresql_version
sku_name = var.sku_name
storage_mb = var.storage_mb
storage_tier = var.storage_tier
auto_grow_enabled = var.auto_grow_enabled
# Admin auth. With Entra-only auth, administrator_login/password are omitted.
administrator_login = var.password_auth_enabled ? var.administrator_login : null
administrator_password = var.password_auth_enabled ? coalesce(var.administrator_password, try(random_password.admin[0].result, null)) : null
# Placement / networking. delegated_subnet_id + private_dns_zone_id enable
# VNet integration (no public IP). These are immutable — changes force replace.
zone = var.zone
delegated_subnet_id = var.delegated_subnet_id
private_dns_zone_id = local.private_dns_zone_id
public_network_access_enabled = var.delegated_subnet_id == null ? var.public_network_access_enabled : false
backup_retention_days = var.backup_retention_days
geo_redundant_backup_enabled = var.geo_redundant_backup_enabled
dynamic "high_availability" {
for_each = var.high_availability_mode != null ? [1] : []
content {
mode = var.high_availability_mode
standby_availability_zone = var.standby_availability_zone
}
}
dynamic "maintenance_window" {
for_each = var.maintenance_window != null ? [var.maintenance_window] : []
content {
day_of_week = maintenance_window.value.day_of_week
start_hour = maintenance_window.value.start_hour
start_minute = maintenance_window.value.start_minute
}
}
dynamic "authentication" {
for_each = var.active_directory_auth_enabled ? [1] : []
content {
active_directory_auth_enabled = true
password_auth_enabled = var.password_auth_enabled
tenant_id = var.tenant_id
}
}
tags = var.tags
lifecycle {
# zone is set at create time; an automatic HA failover can swap the
# primary/standby zones, which Terraform would otherwise try to revert.
ignore_changes = [zone, high_availability[0].standby_availability_zone]
}
depends_on = [azurerm_private_dns_zone_virtual_network_link.this]
}
resource "azurerm_postgresql_flexible_server_database" "this" {
for_each = var.databases
name = each.key
server_id = azurerm_postgresql_flexible_server.this.id
charset = each.value.charset
collation = each.value.collation
lifecycle {
prevent_destroy = false
}
}
resource "azurerm_postgresql_flexible_server_configuration" "this" {
for_each = var.server_parameters
name = each.key
server_id = azurerm_postgresql_flexible_server.this.id
value = each.value
}
# Firewall rules only apply when public access is in use (no delegated subnet).
resource "azurerm_postgresql_flexible_server_firewall_rule" "this" {
for_each = var.delegated_subnet_id == null ? var.firewall_rules : {}
name = each.key
server_id = azurerm_postgresql_flexible_server.this.id
start_ip_address = each.value.start_ip_address
end_ip_address = each.value.end_ip_address
}
variables.tf
variable "name" {
description = "Name of the PostgreSQL Flexible Server. Must be globally unique, 3-63 chars, lowercase letters, numbers and hyphens."
type = string
validation {
condition = can(regex("^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$", var.name))
error_message = "name must be 3-63 chars, lowercase alphanumeric or hyphens, and start/end with alphanumeric."
}
}
variable "resource_group_name" {
description = "Resource group in which to create the server and (optional) private DNS zone."
type = string
}
variable "location" {
description = "Azure region (e.g. centralindia, eastus). Must support Flexible Server and the chosen HA mode."
type = string
}
variable "postgresql_version" {
description = "Major PostgreSQL engine version."
type = string
default = "16"
validation {
condition = contains(["13", "14", "15", "16"], var.postgresql_version)
error_message = "postgresql_version must be one of 13, 14, 15, 16."
}
}
variable "sku_name" {
description = "Compute SKU, formatted as <Tier>_<VM>. Tiers: B (Burstable), GP (GeneralPurpose), MO (MemoryOptimized). e.g. GP_Standard_D2ds_v5."
type = string
default = "GP_Standard_D2ds_v5"
validation {
condition = can(regex("^(B|GP|MO)_", var.sku_name))
error_message = "sku_name must start with B_, GP_ or MO_ (Burstable / GeneralPurpose / MemoryOptimized)."
}
}
variable "storage_mb" {
description = "Provisioned storage in MB. Allowed discrete values from 32768 (32 GiB) up to 33554432 (32 TiB)."
type = number
default = 131072
validation {
condition = contains([
32768, 65536, 131072, 262144, 524288, 1048576,
2097152, 4194304, 8388608, 16777216, 33554432
], var.storage_mb)
error_message = "storage_mb must be a supported Flexible Server size (e.g. 32768, 131072, 524288 ...)."
}
}
variable "storage_tier" {
description = "Storage performance tier (IOPS). e.g. P10, P15, P20, P30. Must be compatible with storage_mb. Null lets Azure pick the default for the size."
type = string
default = null
}
variable "auto_grow_enabled" {
description = "Automatically grow storage as it fills to avoid out-of-space outages."
type = bool
default = true
}
variable "administrator_login" {
description = "Local administrator username (used only when password_auth_enabled = true). Cannot be 'azure_superuser', 'admin', 'root', etc."
type = string
default = "pgadmin"
}
variable "administrator_password" {
description = "Local administrator password. If null and password auth is enabled, a strong password is generated. Mark as sensitive at the call site."
type = string
default = null
sensitive = true
}
variable "password_auth_enabled" {
description = "Enable PostgreSQL native password authentication."
type = bool
default = true
}
variable "active_directory_auth_enabled" {
description = "Enable Microsoft Entra ID authentication on the server."
type = bool
default = false
}
variable "tenant_id" {
description = "Entra ID tenant GUID. Required when active_directory_auth_enabled = true."
type = string
default = null
}
variable "zone" {
description = "Availability zone (1, 2 or 3) for the primary. Immutable after create."
type = string
default = "1"
validation {
condition = contains(["1", "2", "3"], var.zone)
error_message = "zone must be \"1\", \"2\" or \"3\"."
}
}
variable "delegated_subnet_id" {
description = "ID of a subnet delegated to Microsoft.DBforPostgreSQL/flexibleServers for VNet (private) integration. Null = public/private-endpoint-free deployment. Immutable after create."
type = string
default = null
}
variable "private_dns_zone_id" {
description = "ID of an existing privatelink.postgres private DNS zone. If null and delegated_subnet_id is set, the module creates and links one."
type = string
default = null
}
variable "private_dns_vnet_id" {
description = "VNet ID to link the module-created private DNS zone to. Required when the module creates the DNS zone."
type = string
default = null
}
variable "public_network_access_enabled" {
description = "Allow public network access. Forced to false when delegated_subnet_id is set."
type = bool
default = false
}
variable "backup_retention_days" {
description = "Point-in-time restore retention window, in days (7-35)."
type = number
default = 14
validation {
condition = var.backup_retention_days >= 7 && var.backup_retention_days <= 35
error_message = "backup_retention_days must be between 7 and 35."
}
}
variable "geo_redundant_backup_enabled" {
description = "Store backups geo-redundantly for cross-region restore. Cannot be changed after create."
type = bool
default = false
}
variable "high_availability_mode" {
description = "HA mode: 'ZoneRedundant' (standby in a different zone) or 'SameZone'. Null disables HA. Not supported on Burstable SKUs."
type = string
default = null
validation {
condition = var.high_availability_mode == null || contains(["ZoneRedundant", "SameZone"], var.high_availability_mode)
error_message = "high_availability_mode must be null, \"ZoneRedundant\" or \"SameZone\"."
}
}
variable "standby_availability_zone" {
description = "Zone for the HA standby (used with ZoneRedundant). Should differ from var.zone."
type = string
default = "2"
}
variable "maintenance_window" {
description = "Custom maintenance window. Null uses the system-managed window."
type = object({
day_of_week = number # 0 = Sunday ... 6 = Saturday
start_hour = number # 0-23 UTC
start_minute = number # 0-59
})
default = null
}
variable "databases" {
description = "Map of databases to create on the server, keyed by database name."
type = map(object({
charset = optional(string, "UTF8")
collation = optional(string, "en_US.utf8")
}))
default = {}
}
variable "server_parameters" {
description = "Map of server configuration parameters (postgresql.conf knobs), e.g. { max_connections = \"200\", \"azure.extensions\" = \"PG_TRGM,UUID-OSSP\" }."
type = map(string)
default = {}
}
variable "firewall_rules" {
description = "Map of firewall rules (only applied when not VNet-integrated), keyed by rule name."
type = map(object({
start_ip_address = string
end_ip_address = string
}))
default = {}
}
variable "tags" {
description = "Tags applied to all created resources."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "Resource ID of the PostgreSQL Flexible Server."
value = azurerm_postgresql_flexible_server.this.id
}
output "name" {
description = "Name of the PostgreSQL Flexible Server."
value = azurerm_postgresql_flexible_server.this.name
}
output "fqdn" {
description = "Fully qualified domain name used to connect to the server."
value = azurerm_postgresql_flexible_server.this.fqdn
}
output "administrator_login" {
description = "Local administrator username, or null when password auth is disabled."
value = var.password_auth_enabled ? var.administrator_login : null
}
output "administrator_password" {
description = "Local administrator password (supplied or generated). Sensitive."
value = var.password_auth_enabled ? coalesce(var.administrator_password, try(random_password.admin[0].result, null)) : null
sensitive = true
}
output "database_ids" {
description = "Map of database name to database resource ID."
value = { for k, db in azurerm_postgresql_flexible_server_database.this : k => db.id }
}
output "private_dns_zone_id" {
description = "ID of the private DNS zone in use (module-created or supplied)."
value = local.private_dns_zone_id
}
output "connection_string_psql" {
description = "psql connection string template (password redacted via $PGPASSWORD)."
value = "psql \"host=${azurerm_postgresql_flexible_server.this.fqdn} port=5432 dbname=postgres user=${var.administrator_login} sslmode=require\""
}
How to use it
module "postgresql_flexible_server" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-postgresql-flexible?ref=v1.0.0"
name = "kv-orders-prod-pg"
resource_group_name = azurerm_resource_group.data.name
location = "centralindia"
postgresql_version = "16"
sku_name = "GP_Standard_D4ds_v5"
storage_mb = 262144 # 256 GiB
storage_tier = "P15"
auto_grow_enabled = true
# Private, VNet-injected — no public IP. Module builds + links the DNS zone.
delegated_subnet_id = azurerm_subnet.postgres.id
private_dns_vnet_id = azurerm_virtual_network.app.id
# Zone-redundant HA with a Sunday 03:00 IST-ish (UTC) maintenance window.
zone = "1"
high_availability_mode = "ZoneRedundant"
standby_availability_zone = "2"
maintenance_window = {
day_of_week = 0
start_hour = 21 # 21:00 UTC ≈ 02:30 IST
start_minute = 30
}
backup_retention_days = 21
geo_redundant_backup_enabled = true
databases = {
orders = { collation = "en_US.utf8" }
payments = { collation = "en_US.utf8" }
}
server_parameters = {
max_connections = "300"
"azure.extensions" = "PG_TRGM,UUID-OSSP,PGCRYPTO"
log_min_duration_statement = "500" # log queries slower than 500ms
require_secure_transport = "ON"
}
tags = {
env = "prod"
owner = "data-platform"
service = "orders"
}
}
# Downstream: hand the FQDN + generated admin password to an app's Key Vault secret.
resource "azurerm_key_vault_secret" "orders_db_url" {
name = "orders-db-connection"
key_vault_id = azurerm_key_vault.app.id
value = format(
"postgresql://%s:%s@%s:5432/orders?sslmode=require",
module.postgresql_flexible_server.administrator_login,
module.postgresql_flexible_server.administrator_password,
module.postgresql_flexible_server.fqdn,
)
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "azurerm"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...azurerm state bucket/container + key per path...
}
}
2. Module config — live/prod/postgresql_flexible/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-postgresql-flexible?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/postgresql_flexible && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string | — | yes | Server name; 3-63 lowercase alphanumeric/hyphen chars, globally unique. |
resource_group_name |
string | — | yes | Resource group for the server and optional DNS zone. |
location |
string | — | yes | Azure region supporting Flexible Server and the chosen HA mode. |
postgresql_version |
string | "16" |
no | Engine major version (13–16). |
sku_name |
string | "GP_Standard_D2ds_v5" |
no | Compute SKU; B/GP/MO tier prefix enforced. |
storage_mb |
number | 131072 |
no | Provisioned storage in MB (discrete supported sizes). |
storage_tier |
string | null |
no | IOPS tier (P10–P80); must match storage_mb. |
auto_grow_enabled |
bool | true |
no | Auto-grow storage to prevent out-of-space outages. |
administrator_login |
string | "pgadmin" |
no | Local admin username (password auth only). |
administrator_password |
string | null |
no | Local admin password; generated if null. Sensitive. |
password_auth_enabled |
bool | true |
no | Enable native password authentication. |
active_directory_auth_enabled |
bool | false |
no | Enable Microsoft Entra ID authentication. |
tenant_id |
string | null |
no | Entra tenant GUID; required when AAD auth is on. |
zone |
string | "1" |
no | Primary availability zone (1/2/3); immutable. |
delegated_subnet_id |
string | null |
no | Delegated subnet for VNet integration; immutable. |
private_dns_zone_id |
string | null |
no | Existing private DNS zone; module creates one if null + VNet-integrated. |
private_dns_vnet_id |
string | null |
no | VNet to link the module-created DNS zone. |
public_network_access_enabled |
bool | false |
no | Allow public access; forced false when VNet-integrated. |
backup_retention_days |
number | 14 |
no | PITR retention, 7–35 days. |
geo_redundant_backup_enabled |
bool | false |
no | Geo-redundant backups; immutable. |
high_availability_mode |
string | null |
no | ZoneRedundant, SameZone, or null. |
standby_availability_zone |
string | "2" |
no | Zone for the HA standby. |
maintenance_window |
object | null |
no | Custom maintenance window (day/hour/minute, UTC). |
databases |
map(object) | {} |
no | Databases to create, keyed by name (charset/collation). |
server_parameters |
map(string) | {} |
no | Server configuration knobs (e.g. max_connections). |
firewall_rules |
map(object) | {} |
no | Firewall rules; applied only when not VNet-integrated. |
tags |
map(string) | {} |
no | Tags applied to all resources. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the PostgreSQL Flexible Server. |
name |
Name of the server. |
fqdn |
Fully qualified domain name used to connect. |
administrator_login |
Local admin username (null when password auth disabled). |
administrator_password |
Local admin password (supplied or generated). Sensitive. |
database_ids |
Map of database name to database resource ID. |
private_dns_zone_id |
Private DNS zone ID in use (module-created or supplied). |
connection_string_psql |
Ready-to-use psql connection string template. |
Enterprise scenario
A retail platform runs its orders and payments services inside a hub-and-spoke VNet and must keep all data plane traffic private for PCI scope reduction. The team consumes this module once per environment: delegated_subnet_id drops each server into a dedicated Microsoft.DBforPostgreSQL/flexibleServers subnet, the module builds and links the private.postgres.database.azure.com DNS zone, and high_availability_mode = "ZoneRedundant" provisions a hot standby in zone 2 so a single-AZ outage triggers automatic failover within seconds. The generated admin password flows straight into Key Vault via the administrator_password output, and a Sunday-night maintenance window keeps patching off business hours — all reviewed in a single Terraform PR.
Best practices
- Stay VNet-injected and private. Set
delegated_subnet_idand let the module manage the private DNS zone; never leavepublic_network_access_enabled = truefor production. Remember the subnet must be delegated toMicrosoft.DBforPostgreSQL/flexibleServersand cannot be shared with other services. - Prefer Entra ID auth, and treat the password as a fallback. Enable
active_directory_auth_enabledwithtenant_idso humans and workloads authenticate with managed identities; keeprequire_secure_transport = "ON"so TLS is mandatory. If you must keep password auth, push the generatedadministrator_passwordinto Key Vault and rotate it. - Right-size cost with Burstable + auto-grow. Use
B_Standard_*SKUs for dev/test and reserveGP/MOfor prod; keepauto_grow_enabled = trueso you provision a smallerstorage_mband grow on demand instead of over-allocating expensive premium storage up front. - Plan HA and zones deliberately.
ZoneRedundantHA roughly doubles compute cost (you pay for the standby) and is unsupported on Burstable SKUs — only enable it where the SLA justifies it, and keepstandby_availability_zonedifferent fromzone. Notezone,delegated_subnet_id,private_dns_zone_id, andgeo_redundant_backup_enabledare immutable, so changing them forces a destroy/recreate. - Tune through
server_parameters, not the portal. Capturemax_connections,azure.extensions, andlog_min_duration_statementas code so DBA changes are reviewable and reproducible; portal edits drift from state and get reverted on the next apply. - Name and tag for fleet management. Use a consistent
<org>-<service>-<env>-pgconvention and applyenv/owner/servicetags so backups, cost reports, and PITR targets are unambiguous across many servers.