Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Cosmos DB SQL Dedicated Gateway Terraform Module

Provisions a dedicated gateway cluster on a Cosmos DB NoSQL account, which carries the integrated cache. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • ⚡ Creates one azurerm_cosmosdb_sql_dedicated_gateway — a singleton service on the account, not a named child record.
  • 🧠 An integrated cache is configured automatically inside it; cache hits consume no request units.
  • 💰 It is billed hourly whether or not anything uses it, and a client using the SDK default connection mode bypasses it entirely.
  • 📏 Emits the published SKU capacity numbers and the documented three-node SLA threshold as a flag, rather than enforcing it.
  • 🔗 Derives the dedicated endpoint host a client must connect to, which is otherwise assembled by hand.

💡 Why it matters: this is the one Cosmos DB resource whose value depends entirely on something outside Terraform. Provisioning it correctly and completely is not enough — an application must switch to gateway connectivity mode and the dedicated endpoint, or you pay hourly for a cache that serves nothing. Three of this module's outputs exist to make that failure visible.


❤️ Support this project

If this module saves you time:


🗺️ Where this fits in the family

flowchart TB
  RG["terraform-azurerm-resource-group"]
  KV["terraform-azurerm-key-vault: the customer-managed key, if the account uses one"]
  VNET["terraform-azurerm-virtual-network: a delegated subnet, for the MANAGED CASSANDRA service only"]

  ACCT["terraform-azurerm-cosmosdb-account: the account, PLUS its sql_database and sql_container children"]
  KEYSPACE["terraform-azurerm-cosmosdb-cassandra-keyspace"]
  CASSTABLE["terraform-azurerm-cosmosdb-cassandra-table"]
  MONGODB["terraform-azurerm-cosmosdb-mongo-database"]
  MONGOCOLL["terraform-azurerm-cosmosdb-mongo-collection"]
  GREMDB["terraform-azurerm-cosmosdb-gremlin-database"]
  GREMGRAPH["terraform-azurerm-cosmosdb-gremlin-graph"]
  PGCLUSTER["terraform-azurerm-cosmosdb-postgresql-cluster"]
  MICLUSTER["terraform-azurerm-cosmosdb-cassandra-cluster: MANAGED INSTANCE for Apache Cassandra, a DIFFERENT service from the Cassandra API"]
  MICHILD["terraform-azurerm-cosmosdb-cassandra-datacenter: the NODES of a managed cluster, with its own region and its own delegated subnet"]
  SQLTRIGGER["terraform-azurerm-cosmosdb-sql-trigger: JavaScript that the client must ASK FOR on each request, so registering it does not put it into effect"]
  SQLFUNC["terraform-azurerm-cosmosdb-sql-function: a SIDE-EFFECT-FREE query extension, invoked only from query text as udf.name, with no context object at all"]
  SQLSPROC["terraform-azurerm-cosmosdb-sql-stored-procedure: TRANSACTIONAL JavaScript on the primary replica, scoped to ONE logical partition, and the only container child taking FOUR NAMES instead of a container id"]
  SQLROLEDEF["terraform-azurerm-cosmosdb-sql-role-definition: ENTRA ID data-plane RBAC. Defines what may be granted, and grants nothing by itself"]
  SQLROLEASSIGN["terraform-azurerm-cosmosdb-sql-role-assignment: the half that HANDS THE ROLE TO AN ENTRA PRINCIPAL. Creating one is the moment data access begins"]
  MONGOROLEDEF["terraform-azurerm-cosmosdb-mongo-role-definition: MONGO-NATIVE RBAC, a wholly different mechanism from the Entra ID RBAC above. Roles live INSIDE a database"]
  MONGOUSERDEF["terraform-azurerm-cosmosdb-mongo-user-definition: the MONGO USER that holds a role, authenticating with SCRAM rather than Entra ID. The ONLY module in this family carrying a PASSWORD, and Azure never returns it"]
  SQLGATEWAY["terraform-azurerm-cosmosdb-sql-dedicated-gateway: a SINGLETON SERVICE on the account, not a child record. Provisions the integrated cache, is BILLED HOURLY whether used, and does nothing until a client switches to gateway mode"]
  TABLE["terraform-azurerm-cosmosdb-table: the Table API, and the ONLY child with NO intermediate layer -- a table sits directly on the account, so two names are the whole path"]

  API["ONE ACCOUNT SERVES ONE API, chosen by capabilities: EnableCassandra, EnableMongo, EnableGremlin, EnableTable, or none for the SQL API. This is why each API's children are separate modules and not more children of the account composite"]

  PGKIDS["not yet authored, and DELIBERATELY SO: azurerm_cosmosdb_postgresql_role, azurerm_cosmosdb_postgresql_firewall_rule, azurerm_cosmosdb_postgresql_node_configuration and azurerm_cosmosdb_postgresql_coordinator_configuration. Microsoft documents Cosmos DB for PostgreSQL as on a retirement path, so these await a maintainer decision rather than effort"]

  RG -->|"resource_group_name and location"| ACCT
  RG -->|"resource_group_name"| MICLUSTER
  RG -->|"resource_group_name"| PGCLUSTER
  KV -->|"key id, for account encryption"| ACCT
  VNET -->|"a DELEGATED subnet id, required by the managed service and by nothing else here"| MICLUSTER
  VNET -->|"a SECOND delegated subnet, which must sit in the datacenter's own region and must be able to ROUTE to the cluster's management subnet"| MICHILD
  KV -->|"two VERSIONED key uris, for backup storage and for the nodes' managed disks"| MICHILD

  ACCT -->|"capabilities decide which of the children below are even legal"| API

  ACCT -->|"NAME plus resource group, so the subscription comes from the provider and Terraform builds no edge from a literal"| KEYSPACE
  ACCT -->|"name plus resource group, the same convention"| MONGODB
  ACCT -->|"name plus resource group, the same convention again"| GREMDB
  ACCT -->|"THREE names and NO intermediate layer: the Table API has no database, so account plus table name is the entire hierarchy. Every other API here interposes a database, keyspace or graph"| TABLE

  KEYSPACE -->|"id: the child takes an ID where the parent takes names, and the provider rebuilds that ID using the PROVIDER's subscription, so a cross-subscription id is silently relocated"| CASSTABLE
  MONGODB -->|"THREE names at once: this parent emits name, account_name AND resource_group_name, so the four-name child wires entirely from one module"| MONGOCOLL
  GREMDB -->|"three names, and this parent emits all three for exactly that reason. The child cannot express a HIERARCHICAL partition key at all"| GREMGRAPH
  MICLUSTER -->|"cluster id: the provider takes the subscription, resource group AND cluster name from this id and nothing from its own configuration, so a cross-subscription id is honored rather than relocated. The exact opposite of the keyspace edge above"| MICHILD
  MONGODB -->|"the DATABASE id: the provider reads the subscription, resource group, account AND database name out of it and takes nothing from its own configuration, so a cross-subscription id is HONORED rather than relocated. The same convention as the cluster to datacenter edge, and the opposite of the keyspace to table edge"| MONGOROLEDEF
  MONGODB -->|"the same DATABASE id, on the same convention. This database is also the authSource a client must authenticate against"| MONGOUSERDEF
  MONGOROLEDEF -->|"the ROLE NAME, from its role_name output. A role confers nothing until a user holds it, so this edge is where Mongo-native access actually begins"| MONGOUSERDEF
  ACCT -->|"container id: the provider parses the resource group, account, database and container out of it and then takes the SUBSCRIPTION FROM ITS OWN CONFIGURATION, so a cross-subscription container id is silently relocated. The same trap as the keyspace-to-table edge, and the opposite of the cluster-to-datacenter edge"| SQLTRIGGER
  ACCT -->|"container id: the SAME subscription substitution as the trigger edge. Its child is a QUERY EXTENSION rather than an operation hook, so it shares the id trap and none of the invocation story"| SQLFUNC
  ACCT -->|"FOUR NAMES: resource group, account, database and container. So the caller never supplies a subscription and the provider has nothing to override -- the relocation hazard its two siblings carry is IMPOSSIBLE here rather than silent"| SQLSPROC
  ACCT -->|"THREE names, and an allow-list of data actions. Nothing here is a container child: a role definition belongs to the ACCOUNT and names the databases and containers it may be assigned over, using the DATA-PLANE scope grammar with dbs and colls rather than sqlDatabases and containers"| SQLROLEDEF
  ACCT -->|"TWO names only, resource group and account, and BOTH are force-new. The assignment's own name is an optional GUID rather than a label, so it is not a third name from the parent"| SQLROLEASSIGN
  SQLROLEDEF -->|"the definition's RESOURCE ID, not its bare GUID: Terraform validates this argument as a role-definition id, so the id output is the one to wire and the role_definition_id output is for things outside Terraform"| SQLROLEASSIGN
  ACCT -->|"the ACCOUNT id, and the only child here that takes one. The gateway appends services/SqlDedicatedGateway to it, so there is no name to supply and exactly ONE gateway per account"| SQLGATEWAY
  SQLGATEWAY -.->|"routes through, and caches for, whichever containers a client reads via the dedicated endpoint"| ACCT
  PGCLUSTER --> PGKIDS

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff
  classDef keystone fill:#004578,stroke:#002438,color:#ffffff
  classDef sibling fill:#F3F6F9,stroke:#8A9BA8,color:#1B1F23
  class KEYSPACE,CASSTABLE,MONGOCOLL,GREMDB,GREMGRAPH,ACCT,MONGODB,PGCLUSTER,MICLUSTER,MICHILD,SQLTRIGGER,SQLFUNC,SQLSPROC,SQLROLEDEF,SQLROLEASSIGN,MONGOROLEDEF,MONGOUSERDEF,SQLGATEWAY,TABLE me
  class API keystone
  class RG,KV,VNET,PGKIDS sibling
Loading

🧬 What this module builds

flowchart TB
  IN_ACCTID["cosmosdb_account_id: the ACCOUNT id, FORCE-NEW. The gateway appends services/SqlDedicatedGateway to it, so there is no name argument and exactly ONE gateway per account"]
  IN_SIZE["instance_size: a closed set of Cosmos.D4s, Cosmos.D8s and Cosmos.D16s. FORCE-NEW -- a resize is a deprovision and reprovision, which Microsoft says causes downtime UNLESS clients move to the standard gateway endpoint first. Note Microsoft's own SKU table omits the Cosmos prefix, which is the mistake this module rejects"]
  IN_COUNT["instance_count: 1 to 5, and the ONE argument that changes in place. THREE is the documented threshold for availability SLA coverage, and fewer is legal -- Microsoft recommends one for development -- so the shortfall is REPORTED, not refused"]

  BILL["billed HOURLY, like provisioned throughput, whether or not any request reaches it"]
  CLIENT["and nothing is cached until an application connects to the DEDICATED endpoint in GATEWAY mode. Direct mode is the default in the .NET and Java SDKs, and direct mode bypasses the gateway entirely"]
  CONSIST["the cache serves SESSION and EVENTUAL consistency only, so a stronger account or client consistency level pays for routing and gets no caching"]

  THIS["azurerm_cosmosdb_sql_dedicated_gateway.this"]

  CACHE["an INTEGRATED CACHE is configured automatically -- no second resource, no enable flag. Cache hits consume NO request units, and each node holds its OWN cache rather than a shared pool"]
  RBACNOTE["access is unchanged: the gateway uses the account's existing data-plane role definitions and assignments, so this module grants nobody anything"]

  OUT_ID["id, cosmosdb_account_id, instance_size and instance_count as applied"]
  OUT_HOST["dedicated_gateway_host: the account name plus sqlx.cosmos.azure.com, built by Microsoft's documented endpoint substitution. A client cannot reach the cache without it"]
  OUT_PARTS["account_name, resource_group_name, subscription_id and account_path_within_the_subscription, all parsed from the supplied id"]
  OUT_CAPACITY["vcpus_per_node, memory_gb_per_node and approximate_cache_memory_gb_per_node from the published SKU table, plus the three cluster totals"]
  OUT_SLA["meets_the_availability_sla_node_minimum, is_a_single_node_deployment, node_count_headroom_to_the_documented_maximum"]
  OUT_CONST["fifteen constants, including that it is billed whether used and that nothing here reports whether it is used"]

  IN_ACCTID --> THIS
  IN_SIZE --> THIS
  IN_COUNT --> THIS
  BILL -.->|"regardless of traffic"| THIS
  CLIENT -.->|"required, and outside Terraform"| THIS
  CONSIST -.->|"precondition on the account"| THIS

  THIS --> CACHE
  THIS --> RBACNOTE
  THIS --> OUT_ID
  THIS --> OUT_HOST
  THIS --> OUT_PARTS
  THIS --> OUT_CAPACITY
  THIS --> OUT_SLA
  THIS --> OUT_CONST

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff
  classDef keystone fill:#004578,stroke:#002438,color:#ffffff
  classDef sibling fill:#F3F6F9,stroke:#8A9BA8,color:#1B1F23
  class THIS keystone
  class OUT_ID,OUT_HOST,OUT_PARTS,OUT_CAPACITY,OUT_SLA,OUT_CONST me
  class IN_ACCTID,IN_SIZE,IN_COUNT,BILL,CLIENT,CONSIST,CACHE,RBACNOTE sibling
Loading

Resource inventory

Resource Count Notes
azurerm_cosmosdb_sql_dedicated_gateway 1 (this) A singleton per account. Two force-new arguments, one editable

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None here. The caller configures the provider, its authentication, and its mandatory features {} block
ARM resource type Microsoft.DocumentDB/databaseAccounts/services (name SqlDedicatedGateway)

Schema notes that bite

  • 🔴 It is a singleton. The Resource ID is the account's path plus /services/SqlDedicatedGateway with no name segment, so an account has exactly one dedicated gateway and for_each over gateways is not expressible.
  • 🔴 instance_size is force-new, and the replacement is an OUTAGE. Microsoft: "you can't modify the size of the nodes… you can deprovision the cluster and provision it again in a different size. This will result in a short period of downtime unless you change the endpoint in your application to use the standard gateway during reprovisioning."
  • 🔴 instance_count is the only in-place argument. Nodes can be added or removed at any time.
  • 🔴 Billed hourly regardless of use. Microsoft: "Like provisioned throughput, the dedicated gateway is billed hourly."
  • 🔴 The SDK default bypasses it. Direct mode is the default in the .NET and Java SDKs, and "if you choose to connect with direct mode, your requests won't use the dedicated gateway or the integrated cache."
  • ⚠️ Microsoft's SKU table omits the Cosmos. prefix — it lists D4s, D8s, D16s while the argument takes Cosmos.D4s. That mismatch has its own validation here.
  • ⚠️ Three nodes is the SLA threshold, and one or two is legal. Reported, not enforced.
  • ⚠️ The cache serves session and eventual consistency only. A stronger level pays for routing and gets no caching — and that level is set on the account, not here.
  • ⚠️ NoSQL API only. Nothing here checks which API the account serves, so a Mongo or Cassandra account fails at apply.
  • ✅ No account-wide lock. Unlike this family's list-valued children, this resource takes none, so it does not serialize behind them.
  • ✅ All four timeouts keys are honored — a timeout is declared per operation.

🔑 Required Azure RBAC Roles / Permissions

Permission Scope Why
Microsoft.DocumentDB/databaseAccounts/services/write the Cosmos DB account Provision the gateway, change the node count
Microsoft.DocumentDB/databaseAccounts/services/read the Cosmos DB account Refresh, and the pre-create existence check
Microsoft.DocumentDB/databaseAccounts/services/delete the Cosmos DB account Deprovision
Microsoft.DocumentDB/databaseAccounts/read the Cosmos DB account Not required by Terraform, but needed to read the account's endpoint and consistency level

ℹ️ This module grants nobody any data access. Microsoft: "The dedicated gateway uses the same permissions, role definitions and role assignments as Azure Cosmos DB." Whoever could read data before can read it through the gateway, and whoever could not still cannot. Provisioning a gateway is a performance and cost decision, not an access one.

💰 It is a spend decision, though. services/write on an account is the ability to commit hourly charges — up to five of the largest nodes — so it belongs in the same review as throughput changes rather than being treated as a routine performance setting.


Azure Prerequisites

  • The Microsoft.DocumentDB resource provider registered in the subscription.
  • An existing Cosmos DB account serving the NoSQL API. Microsoft: a dedicated gateway "can be provisioned in API for NoSQL accounts", and the integrated cache is "only available for API for NoSQL right now".
  • An account (or client) consistency level of session or eventual, for the cache to serve anything. The account module's consistency_policy sets it, and its default is Session.
  • An application you can change: it must use the dedicated endpoint and gateway connectivity mode.
  • Budget approval — the cluster bills hourly from provisioning, not from first use.

📁 Module Structure

terraform-azurerm-cosmosdb-sql-dedicated-gateway/
├── providers.tf     # required_version + the pinned azurerm. No provider block.
├── variables.tf     # 4 variables, 7 validations
├── main.tf          # 17 locals + the single keystone resource
├── outputs.tf       # 33 outputs
├── README.md        # this file
├── SCOPE.md         # the cross-module contract
├── LICENSE          # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "gateway" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-dedicated-gateway.git?ref=v1.0.0"

  cosmosdb_account_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data-eastus/providers/Microsoft.DocumentDB/databaseAccounts/cosmos-orders-eastus"
  instance_size       = "Cosmos.D4s"
  instance_count      = 1
}

⚠️ One node is Microsoft's recommendation for development and is not covered by the availability SLA. See example 2.


🔌 Cross-Module Contract

Consumes

Input Type Source
cosmosdb_account_id string terraform-azurerm-cosmosdb-account → id
instance_size string the caller; one of three documented SKUs
instance_count number the caller; 1–5

Emits

Output Description
id The gateway's Resource ID (first)
dedicated_gateway_host The host a client must connect to
meets_the_availability_sla_node_minimum instance_count >= 3
total_approximate_cache_memory_gb Sum of the independent per-node caches
billed_hourly_whether_or_not_it_is_used Constant true

📚 Example Library

1 · Minimal — one node for development
module "gateway" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-dedicated-gateway.git?ref=v1.0.0"

  cosmosdb_account_id = module.cosmos.id
  instance_size       = "Cosmos.D4s"
  instance_count      = 1
}

output "dev_posture" {
  value = {
    single_node = module.gateway.is_a_single_node_deployment       # true
    sla_covered = module.gateway.meets_the_availability_sla_node_minimum # false
    cache_gb    = module.gateway.total_approximate_cache_memory_gb # 8
  }
}

ℹ️ Microsoft: "For development, we recommend starting with one node." The two flags above make the development posture explicit rather than implied — which matters because the same configuration in production is a availability gap.

2 · Production — three nodes for SLA coverage
module "gateway" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-dedicated-gateway.git?ref=v1.0.0"

  cosmosdb_account_id = module.cosmos.id
  instance_size       = "Cosmos.D8s"
  instance_count      = 3
}

output "prod_posture" {
  value = {
    sla_covered = module.gateway.meets_the_availability_sla_node_minimum # true
    headroom    = module.gateway.node_count_headroom_to_the_documented_maximum # 2
    cache_gb    = module.gateway.total_approximate_cache_memory_gb # 48
  }
}

🔒 Three is the documented threshold. Microsoft: "For scenarios that require high availability and in order to be covered by the Azure Cosmos DB availability SLA, you should provision at least 3 dedicated gateway nodes… If only one dedicated gateway node is provisioned, you will temporarily lose availability" during downtime, outages and upgrades.

3 · Asserting the SLA minimum in CI
check "gateway_is_sla_covered" {
  assert {
    condition     = module.gateway.meets_the_availability_sla_node_minimum
    error_message = "The dedicated gateway has fewer than 3 nodes and is not covered by the availability SLA. Raise instance_count, or approve the exception explicitly."
  }
}

💡 The module reports; the policy decides. One and two nodes are legal, documented choices, so refusing them here would reject input Azure accepts and Microsoft recommends for development. A check block is where a house rule about production belongs.

4 · Scaling out — in place, no replacement
module "gateway" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-dedicated-gateway.git?ref=v1.0.0"

  cosmosdb_account_id = module.cosmos.id
  instance_size       = "Cosmos.D8s"

  # Was 3. Raising this is an in-place update.
  instance_count = 5
}

output "scaling" {
  value = {
    can_still_add = module.gateway.node_count_headroom_to_the_documented_maximum # 0
    in_place      = module.gateway.the_node_count_changes_without_replacement
  }
}

✅ Microsoft: nodes can be added or removed "at any time". So pick the SKU for the workload and treat the count as the adjustable dimension — the reverse choice is expensive, as the next example shows.

⚠️ A new node starts with an empty cache. Each node caches independently, so scaling out adds capacity without replicating what is already cached; the hit rate dips until the new node warms.

5 · Resizing — a destroy, a create, and downtime unless you plan for it
module "gateway" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-dedicated-gateway.git?ref=v1.0.0"

  cosmosdb_account_id = module.cosmos.id

  # Changing this from Cosmos.D4s DESTROYS and RECREATES the cluster.
  instance_size  = "Cosmos.D8s"
  instance_count = 3
}

output "resize_warning" {
  value = module.gateway.changing_the_instance_size_reprovisions_the_cluster
}

🔴 Microsoft names both the problem and the mitigation in one sentence: "you can't modify the size of the nodes. To change the size of your dedicated gateway nodes you can deprovision the cluster and provision it again in a different size. This will result in a short period of downtime unless you change the endpoint in your application to use the standard gateway during reprovisioning."

✅ And one fact makes this much less painful than it looks: "A dedicated gateway will always have the same endpoint, even if you remove and reprovision it." So the application's dedicated endpoint configuration does not change — only the availability gap has to be planned for. Point clients at the standard gateway, resize, point them back.

⚠️ lifecycle blocks are not valid inside a module block, so a caller cannot request create_before_destroy here — and a singleton could not honor it anyway.

6 · The `Cosmos.` prefix
module "gateway" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-dedicated-gateway.git?ref=v1.0.0"

  cosmosdb_account_id = module.cosmos.id

  instance_size = "Cosmos.D4s" # correct
  # instance_size = "D4s"      # REJECTED here, with an explanation

  instance_count = 1
}

⚠️ This is a documentation mismatch, not a typo trap. Microsoft's SKU table lists the sizes as D4s, D8s and D16s — the form a reader will copy — while the Terraform argument requires the Cosmos. prefix. The module rejects the bare form with a message that says exactly that, because the provider's own error would only list the three legal values without explaining why the documented name is not one of them.

7 · Capacity planning from the published numbers
output "capacity" {
  value = {
    per_node_vcpu     = module.gateway.vcpus_per_node                       # 8
    per_node_memory   = module.gateway.memory_gb_per_node                   # 32
    per_node_cache    = module.gateway.approximate_cache_memory_gb_per_node # 16
    cluster_vcpu      = module.gateway.total_vcpus                          # 24
    cluster_memory    = module.gateway.total_memory_gb                      # 96
    cluster_cache     = module.gateway.total_approximate_cache_memory_gb    # 48
    caches_are_shared = !module.gateway.each_node_caches_independently      # false
  }
}

ℹ️ The per-node cache is half the memory. Microsoft: the integrated cache "uses approximately 50% of the memory and the rest is reserved for metadata and routing requests to backend partitions". The word approximate is in the output name because it is Microsoft's word — treat these as planning figures.

⚠️ Read total_approximate_cache_memory_gb carefully. It is the sum of independent caches, not one pool of that size: "If an item or query is cached on one node, it isn't necessarily cached on the others." A 48 GB total across three nodes will not hold a 40 GB working set the way one 48 GB cache would.

8 · The dedicated endpoint, and handing it to an application
output "app_settings" {
  value = {
    # Microsoft's documented substitution, applied to the account's real endpoint.
    COSMOS_ENDPOINT        = replace(module.cosmos.endpoint, "documents.azure.com", "sqlx.cosmos.azure.com")
    COSMOS_CONNECTION_MODE = "Gateway"
  }
}

output "host_check" {
  # The module derives the same host independently, from the account name in the ID.
  value = module.gateway.dedicated_gateway_host # "cosmos-orders-eastus.sqlx.cosmos.azure.com"
}

ℹ️ Microsoft's rule, verbatim: "All dedicated gateway endpoints follow the same pattern. Remove documents.azure.com from your original endpoint and replace it with sqlx.cosmos.azure.com." The module derives the host only, because that is the whole of what the rule specifies — take the scheme and port from the account's own endpoint, which is what the replace() above does.

🔒 Both values matter. An application given the endpoint but left in direct mode caches nothing, and one given gateway mode but the standard endpoint caches nothing either.

9 · The expensive mistake: direct mode
output "the_trap" {
  value = {
    billed_anyway  = module.gateway.billed_hourly_whether_or_not_it_is_used
    client_must_do = module.gateway.the_client_must_use_gateway_mode_and_the_dedicated_endpoint
    visible_here   = !module.gateway.nothing_here_reports_whether_the_cache_is_being_used # false
  }
}

🔴 Direct mode is the SDK default, and it bypasses everything provisioned here. Microsoft: "Direct connectivity mode is the default in the .NET SDK. You must explicitly configure gateway mode to use the dedicated gateway" — the same warning appears for Java — and "if you choose to connect with direct mode, your requests won't use the dedicated gateway or the integrated cache."

💰 Combined with hourly billing, that is the failure this module works hardest to make visible: a perfectly applied configuration, a correctly sized cluster, a full invoice, and a zero hit rate. Nothing in a plan will tell you — see example 13 for what will.

10 · Consistency decides whether anything is cached
module "cosmos" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-account.git?ref=v1.0.0"

  name                = "cosmos-orders-eastus"
  resource_group_name = module.rg.name
  location            = module.rg.location

  # Session (the module default) and Eventual are the two the cache serves.
  consistency_policy = {
    consistency_level = "Session"
  }
}

output "cache_eligible" {
  value = module.gateway.the_cache_serves_only_session_and_eventual_consistency
}

⚠️ A precondition that lives on another resource. Microsoft: "The integrated cache supports both session and eventual consistency." An account or client configured for Strong or BoundedStaleness gets routing from the gateway and no caching — the same full hourly bill for a fraction of the benefit. This module cannot read the account's consistency level, so it states the requirement instead.

11 · One per account — no `for_each`
# There is no name argument, and the Resource ID carries no name segment:
#   .../databaseAccounts/cosmos-orders-eastus/services/SqlDedicatedGateway
#
# So this is NOT expressible:
#   module "gateways" { for_each = toset(["a", "b"]) ... }   # both target the same resource

output "singleton" {
  value = module.gateway.it_is_a_singleton_per_account
}

ℹ️ A second instance of this module pointed at the same account is a conflict, not a second gateway — and the provider's pre-create check will refuse it as an existing resource. Scale with instance_count, not with more module instances.

✅ This is also why no account-wide lock is taken. The family's list-valued children lock the account so a for_each over them serializes; a singleton service has no list to contend for.

12 · The economics: cache hits cost no RUs
output "economics" {
  value = {
    hits_are_free   = module.gateway.cache_hits_consume_no_request_units
    hourly_cost     = module.gateway.billed_hourly_whether_or_not_it_is_used
    cache_capacity  = module.gateway.total_approximate_cache_memory_gb
  }
}

💡 The trade this resource exists to make. Microsoft: "Point reads and queries that hit the integrated cache don't use any of your RUs. Provisioning a dedicated gateway with an integrated cache can help reduce costs for read-heavy workloads." So gateway nodes are bought with money that would otherwise go to provisioned throughput.

⚠️ Whether the trade pays depends on the hit rate, which is a runtime measurement. Microsoft is blunt about the limit: if a larger gateway does not improve the hit rate, "it's possible that reads simply don't repeat themselves enough for the integrated cache to be impactful."

13 · Monitoring what this module cannot see
output "metrics_to_watch" {
  value = {
    # Zero means no traffic is reaching the gateway at all.
    is_it_used  = "DedicatedGatewayRequests"
    # Zero means traffic arrives but nothing is served from cache.
    item_hits   = "IntegratedCacheItemHitRate"
    query_hits  = "IntegratedCacheQueryHitRate"
    # High, with a low hit rate, means the cluster is too small.
    evictions   = "IntegratedCacheEvictedEntriesSize"
  }
}

💡 Microsoft's own diagnostic sequence. DedicatedGatewayRequests is "zero" when applications still use the standard gateway or direct mode — that is the check for example 9's failure. If requests arrive but both hit rates are zero, check the connection mode and the consistency level. If hit rates are low and IntegratedCacheEvictedEntriesSize is high, a larger size may help; if a larger size does not move the hit rate, the workload simply does not repeat reads.

ℹ️ Wire these with terraform-azurerm-monitor-diagnostic-setting against the account. This module emits nothing_here_reports_whether_the_cache_is_being_used precisely so the gap is not discovered from an invoice.

14 · Timeouts
module "gateway" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-dedicated-gateway.git?ref=v1.0.0"

  cosmosdb_account_id = module.cosmos.id
  instance_size       = "Cosmos.D16s"
  instance_count      = 5

  timeouts = {
    create = "45m"
    update = "30m"
    delete = "45m"
  }
}

✅ All four keys are honored — a timeout is declared per operation and each carries its own value. The default create timeout is 30 minutes, and Microsoft's portal walkthrough says to "wait about 5-10 minutes for the dedicated gateway provisioning to complete" — so the default has room, but a five-node Cosmos.D16s cluster is the case for raising it. An undeclared fifth key would be silently discarded by Terraform's object-type conversion, with no error.

15 · 🏗️ End-to-end composition
provider "azurerm" {
  features {}
}

module "rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-data-eastus"
  location = "eastus"
}

module "cosmos" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-account.git?ref=v1.0.0"

  name                = "cosmos-orders-eastus"
  resource_group_name = module.rg.name
  location            = module.rg.location

  # Session is one of the two levels the integrated cache serves.
  consistency_policy = {
    consistency_level = "Session"
  }
}

module "gateway" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-cosmosdb-sql-dedicated-gateway.git?ref=v1.0.0"

  cosmosdb_account_id = module.cosmos.id
  instance_size       = "Cosmos.D8s"
  instance_count      = 3
}

check "gateway_is_sla_covered" {
  assert {
    condition     = module.gateway.meets_the_availability_sla_node_minimum
    error_message = "The dedicated gateway has fewer than 3 nodes and is not covered by the availability SLA."
  }
}

output "composition" {
  value = {
    gateway_id   = module.gateway.id
    accounts_agree = module.gateway.account_path_within_the_subscription

    # What the application must be configured with. Both, or the cache serves nothing.
    endpoint        = replace(module.cosmos.endpoint, "documents.azure.com", "sqlx.cosmos.azure.com")
    connection_mode = "Gateway"
    host_check      = module.gateway.dedicated_gateway_host

    # What it costs and what it buys.
    cache_gb     = module.gateway.total_approximate_cache_memory_gb
    hits_free    = module.gateway.cache_hits_consume_no_request_units
    billed_idle  = module.gateway.billed_hourly_whether_or_not_it_is_used
  }
}

🔒 Three steps, and the fourth is not Terraform's. The account sets a cache-eligible consistency level, the gateway provisions the cache, the check block holds the SLA line — and then an application must adopt the endpoint and connection mode above. billed_idle is in the output on purpose: it is true and cannot be made false, so the composition states plainly that the meter runs from provisioning rather than from first use.

ℹ️ Note the gateway consumes module.cosmos.id — the account's Resource ID — and nothing else. It creates no database, no container and no role, and it changes nobody's permissions.


📥 Inputs

Group Variables
Placement cosmosdb_account_id (force-new; the gateway is a service on this account)
Sizing instance_size (force-new — a resize reprovisions the cluster), instance_count (editable)
Tail timeouts

ℹ️ No tags variable. azurerm_cosmosdb_sql_dedicated_gateway exposes no tags attribute — confirmed against the provider schema. Tag the account instead.

Full schemas
Variable Type Default Notes
cosmosdb_account_id string — Force-new. Anchored at databaseAccounts/<account>; a child ID or an existing /services/ path is rejected
instance_size string — Force-new. Exactly Cosmos.D4s, Cosmos.D8s or Cosmos.D16s
instance_count number — Whole number 1–5. Editable in place
timeouts object({create, read, update, delete}) {} All four honored

7 validations, listed rather than totaled. Three on cosmosdb_account_id: non-blank, the anchored databaseAccounts shape, and a rejection of the gateway's own /services/ path. Two on instance_size: the closed three-value set, and a separate rejection of the bare D4s form Microsoft's SKU table uses. One on instance_count: a whole number between 1 and 5. One on timeouts.

Three nodes is deliberately NOT validated. Microsoft names three as the availability-SLA threshold and one as its development recommendation, so both are legal — the shortfall is reported through meets_the_availability_sla_node_minimum for a check block to act on.


🧾 Outputs

33 outputs: 4 passthrough, 14 derived, 15 constant.

Output Description
id The gateway's Resource ID
cosmosdb_account_id, instance_size, instance_count The arguments as applied
account_name, resource_group_name, subscription_id Parsed from the account ID
account_path_within_the_subscription The account in this family's comparable form
dedicated_gateway_host The host a client must connect to
vcpus_per_node, memory_gb_per_node, approximate_cache_memory_gb_per_node The published SKU figures
total_vcpus, total_memory_gb, total_approximate_cache_memory_gb Cluster totals
meets_the_availability_sla_node_minimum instance_count >= 3
is_a_single_node_deployment Exactly one node
node_count_headroom_to_the_documented_maximum Room below five
billed_hourly_whether_or_not_it_is_used Constant true
the_client_must_use_gateway_mode_and_the_dedicated_endpoint Constant true
changing_the_instance_size_reprovisions_the_cluster Constant true
the_node_count_changes_without_replacement Constant true
the_integrated_cache_is_configured_automatically Constant true
each_node_caches_independently Constant true
the_cache_serves_only_session_and_eventual_consistency Constant true
cache_hits_consume_no_request_units Constant true
currently_available_on_the_nosql_api_only Constant true
the_endpoint_survives_deprovisioning Constant true
it_is_a_singleton_per_account Constant true
it_uses_the_accounts_existing_data_plane_rbac Constant true
no_account_wide_lock_is_taken Constant true
the_existence_check_can_be_disabled_by_a_provider_feature Constant true
nothing_here_reports_whether_the_cache_is_being_used Constant true

ℹ️ Nothing here is sensitive. This module handles no credential and emits no endpoint that carries one — dedicated_gateway_host is a hostname, and access through it is governed by the account's existing RBAC.


🧠 Architecture Notes

A service, not a child record. Every other module in this family creates something with a name inside the account. This one creates a service on the account: its Resource ID is the account path plus /services/SqlDedicatedGateway, there is no name argument, and there is exactly one per account. That shape explains three otherwise-odd facts — why for_each is not expressible, why the module takes an account ID rather than names, and why it takes no account-wide lock (there is no list to contend for, unlike the family's keyspaces, databases and role definitions).

The only Cosmos DB resource whose value is decided outside Terraform. Provisioning the cluster configures an integrated cache automatically — no second resource, no enable flag. But the cache serves nothing unless an application connects to the dedicated endpoint and uses gateway connectivity mode, and direct mode is the default in the .NET and Java SDKs. The bill starts at provisioning. So a completely correct, fully applied configuration can produce a zero hit rate at full cost, and no plan will show it. Three outputs exist for that single failure: the billing constant, the client-requirement constant, and the honest admission that this module cannot see usage.

Two force-new arguments, and one of them is an outage. instance_size cannot be modified: Microsoft's guidance is to deprovision and reprovision, which "will result in a short period of downtime unless you change the endpoint in your application to use the standard gateway during reprovisioning." That makes the SKU a decision to get right up front, with instance_count as the dimension to adjust later — nodes can be added or removed at any time. One fact takes the sting out: the dedicated endpoint is stable across a destroy and recreate, so application configuration does not change, only availability.

Capacity is published, so the module emits it rather than making you look it up. The three SKUs are 4/16, 8/32 and 16/64 vCPU-to-GB, and the cache gets roughly half the memory — the rest goes to metadata and request routing. The cluster totals follow, with one caveat stated in the output's own description: nodes cache independently, so a three-node total is the sum of three separate caches and will not hold a working set that one cache of that size would. Adding a node also starts it empty, so a scale-out dips the hit rate before improving it.

Where the module reports instead of enforcing, and why. Three nodes is the documented threshold for availability-SLA coverage, and one node is Microsoft's own development recommendation — so both are legal and a validation would reject input Azure accepts. The shortfall becomes a flag that a check block can assert on, which puts the production rule where it belongs. The same reasoning applies to the account's consistency level and to which API the account serves: both are preconditions this module cannot read, so both are stated rather than guessed at.

One validation exists because the documentation disagrees with the argument. Microsoft's SKU table lists the sizes as D4s, D8s and D16s; the Terraform argument requires Cosmos.D4s. A caller copying from the documentation gets a bare name, and the provider's own error would list three legal values without explaining why the documented one is absent. The module rejects the bare form with that explanation, which is the whole value the check adds.


🧱 Design Principles

Concern This module's default The caller's opt-out
Node count No default — required by the provider. Reported against the documented SLA threshold choose 1 or 2 deliberately
SLA minimum of three Reported, not enforced, because 1 and 2 are documented choices —
SKU No default. A closed set of three, with the bare D4s form rejected —
Spend Not defaulted. Secure-by-default covers exposure, not cost — and every node bills hourly —
Access Unchanged. The gateway reuses the account's existing data-plane RBAC —
Client configuration Out of scope and stated. Terraform cannot set an SDK's connection mode —
Usage visibility Absent and admitted. Wire the documented metrics —

🔒 Secure-by-default has little to bite on here, and that is worth saying plainly. This resource grants no access, stores no data and holds no credential — it is a performance and cost decision. So the module's compensating work is elsewhere: naming the spend that starts at provisioning, naming the client change without which the spend buys nothing, and naming the resize that causes an outage. Those are the ways this resource actually hurts.


🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module with ?ref=v1.0.0 — never a branch. This module is plan-only in this library; a human applies from CI.


🧪 Testing

Covered by What it proves
terraform validate The resource, its arguments and every expression parse and type-check against the pinned provider schema
terraform fmt -check Canonical formatting
terraform console with a root-module fixture Variable validations actually fire. All 7 rules were driven by deliberately bad inputs, and every local was driven to more than one value by good ones
terraform plan (needs credentials) Whether the account exists and serves the NoSQL API
Nothing offline Whether the account's consistency level permits caching
Nothing in Terraform at all Whether any application uses the gateway, and whether requests hit the cache. Both are runtime metrics

⚠️ Terraform skips a validation whose referenced variable has already failed, so a short error list is not proof a rule is missing. Fix the first failure and re-run.


💬 Example Output

id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data-eastus/providers/Microsoft.DocumentDB/databaseAccounts/cosmos-orders-eastus/services/SqlDedicatedGateway"
instance_size = "Cosmos.D8s"
instance_count = 3
account_name = "cosmos-orders-eastus"
account_path_within_the_subscription = "/resourceGroups/rg-data-eastus/providers/Microsoft.DocumentDB/databaseAccounts/cosmos-orders-eastus"
dedicated_gateway_host = "cosmos-orders-eastus.sqlx.cosmos.azure.com"
vcpus_per_node = 8
memory_gb_per_node = 32
approximate_cache_memory_gb_per_node = 16
total_vcpus = 24
total_memory_gb = 96
total_approximate_cache_memory_gb = 48
meets_the_availability_sla_node_minimum = true
is_a_single_node_deployment = false
node_count_headroom_to_the_documented_maximum = 2
billed_hourly_whether_or_not_it_is_used = true
the_client_must_use_gateway_mode_and_the_dedicated_endpoint = true
changing_the_instance_size_reprovisions_the_cluster = true
nothing_here_reports_whether_the_cache_is_being_used = true

🔍 Troubleshooting

Symptom Cause Fix
cosmosdb_account_id must be a Cosmos DB ACCOUNT Resource ID... Passed a database or container ID The ID must end at /databaseAccounts/<account>
cosmosdb_account_id looks like the dedicated gateway's OWN Resource ID Passed the /services/SqlDedicatedGateway path Pass the account id; the module appends the service path
instance_size is missing its "Cosmos." prefix Copied D4s from Microsoft's SKU table Use Cosmos.D4s
instance_size must be exactly one of... A size that does not exist, such as Cosmos.D32s Only D4s, D8s and D16s exist
instance_count must be a whole number between 1 and 5 Asked for six or more Five is the documented default maximum; more is a support request
Apply fails on a Mongo or Cassandra account Dedicated gateways are NoSQL-API only Nothing here checks the API; use a NoSQL account
A plan shows the whole gateway being replaced instance_size changed, and it is force-new Expected. Move clients to the standard gateway endpoint first
The node count changed with no replacement instance_count is editable Expected, and the cheap dimension to adjust
A second module instance conflicts The gateway is a singleton per account Scale with instance_count, not more instances
DedicatedGatewayRequests is zero No application is using the dedicated endpoint or gateway mode Set both; direct mode is the SDK default
Requests arrive but both hit rates are zero Direct mode against the dedicated endpoint, or a too-strong consistency level Use gateway mode; the cache serves session and eventual only
Low hit rate and high IntegratedCacheEvictedEntriesSize The cluster is too small for the working set A larger SKU may help — but a resize is an outage
A larger SKU did not improve the hit rate The workload does not repeat reads enough The cache may simply not fit this workload
The invoice arrived and nothing is faster Billing starts at provisioning, not at first use Check DedicatedGatewayRequests before assuming the cache is working
An existing gateway was silently overwritten The skip-import-check provider feature is enabled Turn it off in the root module's features block

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."