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:
node_count = 0— single-node cluster. Just the coordinator. This is ordinary, vertically-scaled PostgreSQL: cheaper, simpler, and the right choice until your data or throughput outgrows one machine. You can convert to multi-node later.node_count >= 2— distributed (multi-node) cluster. The coordinator plus N workers. Distributed tables are sharded across the workers by a distribution column; the coordinator parallelizes queries across shards. This is where Citus earns its keep: multi-tenant SaaS, real-time analytics, and time-series workloads that need to scale beyond a single box. (Anode_countof1is not valid — go from 0 straight to 2+.)
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
- You are building a multi-tenant SaaS backend where each tenant’s data shards by
tenant_id, and you need PostgreSQL that scales horizontally as tenants grow. - You run real-time analytics or time-series workloads (IoT, events, metrics) that need parallel query execution across many nodes rather than a single large instance.
- You want standard PostgreSQL semantics, extensions, and tooling — not a bespoke NoSQL API — but with a path to scale-out you don’t get from single-instance Flexible Server.
- You need consistent governance — private-by-default access, HA on for production, pinned PostgreSQL and Citus versions, reviewed firewall rules — across every cluster and environment.
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. Changingnode_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 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/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
- Choose topology deliberately, and start small. Use
node_count = 0(single-node) until your data or throughput genuinely outgrows one machine; move tonode_count >= 2for distributed Citus when you need to shard. Remember1is invalid — go from 0 to 2+. - Keep the cluster private. Leave
coordinator_public_ip_access_enabledandnode_public_ip_access_enabledatfalseand grant access only through narrow, reviewedfirewall_rules(or a Private Endpoint). Never open the cluster to broad public ranges. - Enable HA for anything production.
ha_enabled = trueprovisions a hot standby for every server with automatic failover; the extra cost is small relative to an unplanned outage on a sharded database. - Source the admin password from Key Vault. Pass
administrator_login_passwordfrom aazurerm_key_vault_secretdata source so the credential is never hardcoded; the variable is markedsensitiveto keep it out of plan output. - Pin both
sql_versionandcitus_version. Floating versions cause surprise behaviour changes on a distributed engine; treat version bumps as deliberate, tested upgrades aligned with your application’s compatibility matrix. - Size each tier for its job and tune sharding. The coordinator handles routing and metadata while workers do the heavy lifting — size them independently, set
citus.shard_countto a multiple of your worker count for even distribution, and plan that scalingnode_countor vCores is a long-running operation best done in a maintenance window.