Provisions a dedicated gateway cluster on a Cosmos DB NoSQL account, which carries the integrated cache. Targets
hashicorp/azurerm ~> 4.0.
- ⚡ 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.
If this module saves you time:
- ⭐ Star the repository — it helps others find it.
- 💼 Connect on LinkedIn — linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee — buymeacoffee.com/microsoftexpert
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
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
Resource inventory
| Resource | Count | Notes |
|---|---|---|
azurerm_cosmosdb_sql_dedicated_gateway |
1 (this) |
A singleton per account. Two force-new arguments, one editable |
| 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/SqlDedicatedGatewaywith no name segment, so an account has exactly one dedicated gateway andfor_eachover gateways is not expressible. - 🔴
instance_sizeis 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_countis 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 theCosmos.prefix — it listsD4s,D8s,D16swhile the argument takesCosmos.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
timeoutskeys are honored — a timeout is declared per operation.
| 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/writeon 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.
- The
Microsoft.DocumentDBresource 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_policysets it, and its default isSession. - 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.
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
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.
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 |
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
checkblock 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.
⚠️ lifecycleblocks are not valid inside amoduleblock, so a caller cannot requestcreate_before_destroyhere — 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 asD4s,D8sandD16s— the form a reader will copy — while the Terraform argument requires theCosmos.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
approximateis in the output name because it is Microsoft's word — treat these as planning figures.
⚠️ Readtotal_approximate_cache_memory_gbcarefully. 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.comfrom your original endpoint and replace it withsqlx.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 thereplace()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 forStrongorBoundedStalenessgets 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_eachover 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.
DedicatedGatewayRequestsis "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 andIntegratedCacheEvictedEntriesSizeis 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-settingagainst the account. This module emitsnothing_here_reports_whether_the_cache_is_being_usedprecisely 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.D16scluster 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
checkblock holds the SLA line — and then an application must adopt the endpoint and connection mode above.billed_idleis in the output on purpose: it istrueand cannot be madefalse, 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.
| 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
tagsvariable.azurerm_cosmosdb_sql_dedicated_gatewayexposes notagsattribute — 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.
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_hostis a hostname, and access through it is governed by the account's existing RBAC.
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.
| 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.
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module with ?ref=v1.0.0 — never a branch. This module is plan-only in this library; a human applies from CI.
| 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.
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
| 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 |
azurerm_cosmosdb_sql_dedicated_gateway— the provider resource- Azure Cosmos DB dedicated gateway - Overview — the SKU table, hourly billing, the node maximum, and the resize-causes-downtime note
- Azure Cosmos DB integrated cache - Overview — the item and query caches, LRU eviction, per-node independence, and the troubleshooting metrics
- Azure Cosmos DB integrated cache frequently asked questions — the three-node SLA threshold, supported consistency levels, and NoSQL-API-only scope
- How to configure the Azure Cosmos DB integrated cache — the endpoint substitution, gateway-mode requirement, and RBAC reuse
- Azure Cosmos DB SDK connection modes — direct versus gateway connectivity
terraform-azurerm-cosmosdb-account— the account, itsid, itsendpoint, and itsconsistency_policyterraform-azurerm-monitor-diagnostic-setting— for the metrics this module cannot report- This module's
SCOPE.md
💙 "Infrastructure as Code should be standardized, consistent, and secure."

