IaC Azure

Terraform Module: Azure PostgreSQL Flexible Server — private, HA-ready Postgres in one block

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 immutablezone, 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

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 configlive/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 configlive/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

TerraformAzurePostgreSQL Flexible ServerModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading