IaC Azure

Terraform Module: Azure Cosmos DB for PostgreSQL — single-node to Citus-distributed clusters, governed

Quick take — A reusable hashicorp/azurerm ~> 4.0 module for azurerm_cosmosdb_postgresql_cluster: single-node vs distributed Citus topologies, HA, private-by-default access, firewall rules, and optional coordinator/node server configuration. 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 "citus" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-cosmosdb-postgresql?ref=v1.0.0"

  name                = "..."   # Cluster name (lowercase, hyphenated).
  resource_group_name = "..."   # Resource group for the cluster.
  location            = "..."   # Azure region (e.g. centralindia).

  # Admin password. Pass via a Key Vault data source — never hardcode in prod.
  administrator_login_password = "..."

  # node_count = 0 => single-node cluster (coordinator only).
  # node_count >= 2 => distributed (sharded) Citus cluster with worker nodes.
  node_count = 0

  coordinator_vcore_count         = 2
  coordinator_storage_quota_in_mb = 131072  # 128 GiB.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure Cosmos DB for PostgreSQL is Microsoft’s managed, distributed PostgreSQL service built on the open-source Citus extension. It is, despite the “Cosmos DB” branding, real PostgreSQL — wire-protocol compatible, extension-rich — with the ability to shard tables horizontally across worker nodes so a single logical database scales out to terabytes and high write throughput. The azurerm_cosmosdb_postgresql_cluster resource provisions the whole server group: a coordinator node that holds metadata and routes queries, plus zero or more worker nodes that store and process the distributed shards.

The single most important decision is the topology, controlled by node_count:

Each tier is sized independently: coordinator_vcore_count / coordinator_storage_quota_in_mb for the coordinator, and node_vcores / node_storage_quota_in_mb for each worker. High availability (ha_enabled) provisions a hot standby for every server in the group with automatic failover.

The security defaults are the part teams get wrong. By default the coordinator’s public IP access is enabled; this module flips coordinator_public_ip_access_enabled = false and node_public_ip_access_enabled = false so the cluster is private by default, and exposes azurerm_cosmosdb_postgresql_firewall_rule only as an explicit, reviewed allow-list. Wrapping the cluster in a module encodes the right topology choice, the right sizing per tier, private-by-default networking, HA, and version pinning (sql_version, citus_version) once — and hands consumers a clean set of inputs plus the coordinator FQDN output.

When to use it

Reach for azurerm_postgresql_flexible_server instead when a single-node, vertically-scaled PostgreSQL meets your needs and you don’t want the distributed-systems surface area of Citus. Cosmos DB for PostgreSQL is the right tool specifically when you need (or will soon need) to shard.

Module structure

terraform-module-azure-cosmosdb-postgresql/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # cluster, firewall rules, optional server configurations
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # id, coordinator FQDN, and server details

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

locals {
  tags = merge(
    {
      managed_by = "terraform"
      module     = "azure-cosmosdb-postgresql"
    },
    var.tags,
  )

  # A node_count >= 2 means a distributed (sharded) Citus cluster; worker sizing
  # is only meaningful in that mode and is omitted for single-node clusters.
  is_distributed = var.node_count >= 2
}

resource "azurerm_cosmosdb_postgresql_cluster" "this" {
  name                = var.name
  resource_group_name = var.resource_group_name
  location            = var.location

  administrator_login_password = var.administrator_login_password

  # ---- Topology ----
  # 0 = single-node (coordinator only); >= 2 = distributed with worker nodes.
  node_count = var.node_count

  # ---- Coordinator sizing ----
  coordinator_vcore_count         = var.coordinator_vcore_count
  coordinator_storage_quota_in_mb = var.coordinator_storage_quota_in_mb

  # ---- Worker node sizing (only applied for distributed clusters) ----
  node_vcores              = local.is_distributed ? var.node_vcores : null
  node_storage_quota_in_mb = local.is_distributed ? var.node_storage_quota_in_mb : null

  # ---- Versions (pin both to avoid surprise upgrades) ----
  citus_version = var.citus_version
  sql_version   = var.sql_version

  # ---- High availability: hot standby + automatic failover for every server ----
  ha_enabled = var.ha_enabled

  # ---- Private by default: no public IP on coordinator or worker nodes ----
  coordinator_public_ip_access_enabled = var.coordinator_public_ip_access_enabled
  node_public_ip_access_enabled        = var.node_public_ip_access_enabled

  # ---- Optional maintenance window ----
  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
    }
  }

  tags = local.tags
}

# Explicit, reviewed allow-list. With public access disabled this governs the
# narrow ranges (e.g. a bastion or build agent) permitted to reach the cluster.
resource "azurerm_cosmosdb_postgresql_firewall_rule" "this" {
  for_each = var.firewall_rules

  name             = each.key
  cluster_id       = azurerm_cosmosdb_postgresql_cluster.this.id
  start_ip_address = each.value.start_ip_address
  end_ip_address   = each.value.end_ip_address
}

# Optional coordinator-level PostgreSQL/Citus GUCs (e.g. citus.shard_count).
resource "azurerm_cosmosdb_postgresql_coordinator_configuration" "this" {
  for_each = var.coordinator_configurations

  name       = each.key
  cluster_id = azurerm_cosmosdb_postgresql_cluster.this.id
  value      = each.value
}

# Optional worker-node-level configuration; only valid on distributed clusters.
resource "azurerm_cosmosdb_postgresql_node_configuration" "this" {
  for_each = local.is_distributed ? var.node_configurations : {}

  name       = each.key
  cluster_id = azurerm_cosmosdb_postgresql_cluster.this.id
  value      = each.value
}

variables.tf

variable "name" {
  description = "Cluster name (lowercase, hyphenated)."
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{1,38}[a-z0-9]$", var.name))
    error_message = "name must be 3-40 chars, start with a letter, be lowercase alphanumeric/hyphen, and end alphanumeric."
  }
}

variable "resource_group_name" {
  description = "Resource group where the cluster is created."
  type        = string
}

variable "location" {
  description = "Azure region for the cluster (e.g. centralindia, eastus)."
  type        = string
}

variable "administrator_login_password" {
  description = "Admin (citus) login password. Source from Key Vault; never hardcode in production."
  type        = string
  sensitive   = true
}

variable "node_count" {
  description = "Worker node count. 0 = single-node (coordinator only); 2-20 = distributed Citus. 1 is invalid."
  type        = number
  default     = 0

  validation {
    condition     = var.node_count == 0 || (var.node_count >= 2 && var.node_count <= 20)
    error_message = "node_count must be 0 (single-node) or between 2 and 20 (distributed). 1 is not allowed."
  }
}

variable "coordinator_vcore_count" {
  description = "Coordinator vCore count. Valid values: 1, 2, 4, 8, 16, 32, 64, 96."
  type        = number
  default     = 2

  validation {
    condition     = contains([1, 2, 4, 8, 16, 32, 64, 96], var.coordinator_vcore_count)
    error_message = "coordinator_vcore_count must be one of: 1, 2, 4, 8, 16, 32, 64, 96."
  }
}

variable "coordinator_storage_quota_in_mb" {
  description = "Coordinator storage in MB. Valid values: 32768, 65536, 131072, 262144, 524288, 1048576, 2097152, 4194304, 8388608, 16777216, 33554432."
  type        = number
  default     = 131072

  validation {
    condition = contains(
      [32768, 65536, 131072, 262144, 524288, 1048576, 2097152, 4194304, 8388608, 16777216, 33554432],
      var.coordinator_storage_quota_in_mb
    )
    error_message = "coordinator_storage_quota_in_mb must be a supported value (e.g. 131072 for 128 GiB)."
  }
}

variable "node_vcores" {
  description = "vCores per worker node (distributed clusters only). Valid: 1, 2, 4, 8, 16, 32, 64, 96, 104."
  type        = number
  default     = 4

  validation {
    condition     = contains([1, 2, 4, 8, 16, 32, 64, 96, 104], var.node_vcores)
    error_message = "node_vcores must be one of: 1, 2, 4, 8, 16, 32, 64, 96, 104."
  }
}

variable "node_storage_quota_in_mb" {
  description = "Storage in MB per worker node (distributed clusters only). Valid: 32768..16777216 (powers of two steps)."
  type        = number
  default     = 524288

  validation {
    condition = contains(
      [32768, 65536, 131072, 262144, 524288, 1048576, 2097152, 4194304, 8388608, 16777216],
      var.node_storage_quota_in_mb
    )
    error_message = "node_storage_quota_in_mb must be a supported value (e.g. 524288 for 512 GiB)."
  }
}

variable "citus_version" {
  description = "Citus extension version, e.g. '12.1'."
  type        = string
  default     = "12.1"

  validation {
    condition = contains(
      ["8.3", "9.0", "9.1", "9.2", "9.3", "9.4", "9.5", "10.0", "10.1", "10.2", "11.0", "11.1", "11.2", "11.3", "12.1"],
      var.citus_version
    )
    error_message = "citus_version must be a supported Citus version (e.g. 12.1)."
  }
}

variable "sql_version" {
  description = "Major PostgreSQL version. Valid values: 11, 12, 13, 14, 15, 16."
  type        = string
  default     = "16"

  validation {
    condition     = contains(["11", "12", "13", "14", "15", "16"], var.sql_version)
    error_message = "sql_version must be one of: 11, 12, 13, 14, 15, 16."
  }
}

variable "ha_enabled" {
  description = "Enable high availability (hot standby + automatic failover for every server)."
  type        = bool
  default     = true
}

variable "coordinator_public_ip_access_enabled" {
  description = "Allow public IP access to the coordinator. Kept false (private by default)."
  type        = bool
  default     = false
}

variable "node_public_ip_access_enabled" {
  description = "Allow public IP access to worker nodes. Kept false (private by default)."
  type        = bool
  default     = false
}

variable "maintenance_window" {
  description = <<-EOT
    Optional maintenance window:
      day_of_week  - 0 (Sunday) .. 6 (Saturday)
      start_hour   - 0 .. 23
      start_minute - 0 .. 59
  EOT
  type = object({
    day_of_week  = number
    start_hour   = number
    start_minute = number
  })
  default = null
}

variable "firewall_rules" {
  description = <<-EOT
    Map of firewall rules keyed by rule name. Each value:
      start_ip_address - first IP of the allowed range
      end_ip_address   - last IP of the allowed range
  EOT
  type = map(object({
    start_ip_address = string
    end_ip_address   = string
  }))
  default = {}
}

variable "coordinator_configurations" {
  description = "Map of coordinator PostgreSQL/Citus configuration name => value (e.g. { \"citus.shard_count\" = \"64\" })."
  type        = map(string)
  default     = {}
}

variable "node_configurations" {
  description = "Map of worker-node configuration name => value. Applied only on distributed clusters."
  type        = map(string)
  default     = {}
}

variable "tags" {
  description = "Tags assigned to the cluster."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "Resource ID of the Cosmos DB for PostgreSQL cluster."
  value       = azurerm_cosmosdb_postgresql_cluster.this.id
}

output "name" {
  description = "Cluster name."
  value       = azurerm_cosmosdb_postgresql_cluster.this.name
}

output "earliest_restore_time" {
  description = "Earliest point-in-time-restore timestamp (ISO8601)."
  value       = azurerm_cosmosdb_postgresql_cluster.this.earliest_restore_time
}

output "servers" {
  description = "List of servers in the group, each with its name and FQDN."
  value       = azurerm_cosmosdb_postgresql_cluster.this.servers
}

output "coordinator_fqdn" {
  description = "FQDN of the coordinator server (the connection endpoint for clients)."
  value       = try(azurerm_cosmosdb_postgresql_cluster.this.servers[0].fqdn, null)
}

output "node_count" {
  description = "Configured worker node count (0 = single-node)."
  value       = azurerm_cosmosdb_postgresql_cluster.this.node_count
}

output "firewall_rule_ids" {
  description = "Map of firewall rule name => rule resource ID."
  value       = { for k, r in azurerm_cosmosdb_postgresql_firewall_rule.this : k => r.id }
}

How to use it

# Distributed, production-grade SaaS cluster: 4 workers, HA on, private only.
module "citus" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-cosmosdb-postgresql?ref=v1.0.0"

  name                = "saas-tenants-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  # Pull the admin password from Key Vault, never from a literal.
  administrator_login_password = data.azurerm_key_vault_secret.citus_admin.value

  # Distributed topology: coordinator + 4 worker nodes.
  node_count = 4

  coordinator_vcore_count         = 8
  coordinator_storage_quota_in_mb = 524288 # 512 GiB

  node_vcores              = 8
  node_storage_quota_in_mb = 2097152 # 2 TiB per worker

  citus_version = "12.1"
  sql_version   = "16"

  ha_enabled = true

  # Private by default (these are the module defaults, shown for clarity).
  coordinator_public_ip_access_enabled = false
  node_public_ip_access_enabled        = false

  maintenance_window = {
    day_of_week  = 0 # Sunday
    start_hour   = 3
    start_minute = 0
  }

  # Narrow allow-list: only the bastion subnet's NAT egress IP.
  firewall_rules = {
    bastion = {
      start_ip_address = "20.40.10.20"
      end_ip_address   = "20.40.10.20"
    }
  }

  # Tune sharding for a multi-tenant workload.
  coordinator_configurations = {
    "citus.shard_count" = "64"
  }

  tags = {
    env         = "prod"
    workload    = "saas-platform"
    cost_center = "ENG-301"
  }
}

# Downstream: publish the coordinator endpoint for the application to consume.
resource "azurerm_key_vault_secret" "citus_endpoint" {
  name         = "citus-coordinator-fqdn"
  value        = module.citus.coordinator_fqdn
  key_vault_id = azurerm_key_vault.platform.id
}

Pin the module with ?ref=<tag> so a stack never silently picks up a breaking change. Changing node_count, vCore counts, or storage triggers a long-running scale operation — treat topology changes as reviewed, planned events.

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/citus/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-cosmosdb-postgresql?ref=v1.0.0"
}

inputs = {
  name = "..."
  resource_group_name = "..."
  location = "..."
  administrator_login_password = "..."
  node_count = 0
  coordinator_vcore_count = 2
  coordinator_storage_quota_in_mb = 131072
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/citus && 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 Cluster name (validated, 3–40 chars).
resource_group_name string Yes Resource group for the cluster.
location string Yes Azure region.
administrator_login_password string (sensitive) Yes Admin login password; source from Key Vault.
node_count number 0 No 0 = single-node; 2–20 = distributed Citus (1 invalid).
coordinator_vcore_count number 2 No Coordinator vCores (1–96 in valid steps).
coordinator_storage_quota_in_mb number 131072 No Coordinator storage in MB (e.g. 131072 = 128 GiB).
node_vcores number 4 No vCores per worker node (distributed only).
node_storage_quota_in_mb number 524288 No Storage per worker node in MB (distributed only).
citus_version string 12.1 No Citus extension version.
sql_version string 16 No Major PostgreSQL version (11–16).
ha_enabled bool true No Enable HA (hot standby + auto failover).
coordinator_public_ip_access_enabled bool false No Public access to coordinator (kept private).
node_public_ip_access_enabled bool false No Public access to worker nodes (kept private).
maintenance_window object null No Optional maintenance window (day/hour/minute).
firewall_rules map(object) {} No Allow-listed IP ranges keyed by rule name.
coordinator_configurations map(string) {} No Coordinator GUC name → value.
node_configurations map(string) {} No Worker-node GUC name → value (distributed only).
tags map(string) {} No Tags assigned to the cluster.

Outputs

Name Description
id Cluster resource ID.
name Cluster name.
earliest_restore_time Earliest point-in-time-restore timestamp.
servers List of servers in the group (name + FQDN).
coordinator_fqdn FQDN of the coordinator (the client connection endpoint).
node_count Configured worker node count (0 = single-node).
firewall_rule_ids Map of firewall rule name → rule resource ID.

Enterprise scenario

A SaaS platform team runs a multi-tenant application where every tenant’s rows shard by tenant_id. In dev and staging they deploy this module with node_count = 0 — a single-node cluster keeps cost low while the schema and queries are validated. For prod they flip to node_count = 4 with 8-vCore workers and 2 TiB of storage each, ha_enabled = true, and citus.shard_count = 64 set through coordinator_configurations, so tenant data is distributed across the workers and queries run in parallel. Because the module hard-codes coordinator_public_ip_access_enabled = false and node_public_ip_access_enabled = false, no cluster is ever reachable from the internet; the only ingress is a single firewall rule for the bastion’s NAT egress IP, reviewed in PR. The application reads the coordinator FQDN from the module’s output (published into Key Vault), and a quarterly audit confirms every cluster across environments is private, HA-enabled, and version-pinned.

Best practices

TerraformAzureCosmos DB for PostgreSQLModuleIaC
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