Quick take — A reusable hashicorp/azurerm ~> 4.0 module for azurerm_maps_account: pick your SKU, attach a system- or user-assigned identity, lock down CORS origins, and emit keys for downstream apps. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "azurerm" {
features {}
}
module "maps" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-maps?ref=v1.0.0"
name = "..." # Name of the Azure Maps account (1-98 chars; letters, nu…
resource_group_name = "..." # Resource group the Maps account is created in.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Azure Maps is Microsoft’s geospatial platform: a set of REST and SDK services for rendering map tiles, geocoding and reverse-geocoding addresses, routing, traffic, time-zones, weather and spatial geofencing. You provision it as a single control-plane resource — an azurerm_maps_account — and your front-end and back-end code then call the data-plane endpoints (atlas.microsoft.com) authenticated either with one of the account’s shared keys or, far better, with Entra ID tokens scoped to the account.
The account itself looks deceptively simple, which is exactly why teams copy-paste it and get the details wrong. The interesting decisions live in the surrounding wiring: which SKU (G2 is the only generally-available tier on azurerm 4.x — S0/S1 are retired), whether to enable a managed identity so the account can read styles or data from a Storage account without a secret, whether to allow shared-key auth at all (you usually want it off for a hardened deployment), and which CORS origins the browser SDK is allowed to call from. This module turns those into a handful of validated variables so every Maps account in your estate is consistent, taggable, and auditable — and so the rotation-sensitive keys are surfaced as explicit, sensitive outputs rather than fished out of the portal by hand.
When to use it
- You are embedding the Azure Maps Web SDK in a web app and need a Maps account whose CORS allow-list is pinned to your own domains, not
*. - A back-end service (Functions, Container Apps, AKS) needs to call Geocoding, Route, or Search and you want it to authenticate with a managed identity plus a token instead of a long-lived key.
- You run multiple environments (dev/test/prod) or multiple product teams and want every Maps account named, tagged, and configured identically from code.
- You need Maps to read custom map styles, indoor maps (Creator datasets) or geofence data from a Storage account, which requires the account’s identity to hold a data-plane role on that storage.
- You want shared-key authentication disabled for compliance, falling back to Entra-only access, and need that enforced as policy-as-code.
If you only ever click one account into a sandbox and never touch it again, a module is overkill — but the moment a second environment or a CORS change appears, the module pays for itself.
Module structure
terraform-module-azure-maps/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
locals {
# Maps account names are 1-98 chars; we keep it tidy and lower-cased.
maps_account_name = lower(var.name)
# Only build an identity block when the caller actually wants one.
identity_enabled = var.identity_type != null
default_tags = {
managed_by = "terraform"
module = "terraform-module-azure-maps"
}
}
resource "azurerm_maps_account" "this" {
name = local.maps_account_name
resource_group_name = var.resource_group_name
sku_name = var.sku_name
# When false, only Entra ID (Azure AD) tokens are accepted on the
# data plane; the two shared keys still exist but cannot be used.
local_authentication_enabled = var.local_authentication_enabled
dynamic "identity" {
for_each = local.identity_enabled ? [1] : []
content {
type = var.identity_type
identity_ids = (
var.identity_type == "UserAssigned" || var.identity_type == "SystemAssigned, UserAssigned"
? var.identity_ids
: null
)
}
}
dynamic "cors" {
for_each = length(var.cors_allowed_origins) > 0 ? [1] : []
content {
allowed_origins = var.cors_allowed_origins
}
}
tags = merge(local.default_tags, var.tags)
}
variables.tf
variable "name" {
description = "Name of the Azure Maps account (1-98 chars; letters, numbers and hyphens)."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9][a-zA-Z0-9-]{0,97}$", var.name))
error_message = "name must be 1-98 chars and contain only letters, numbers and hyphens, starting with an alphanumeric character."
}
}
variable "resource_group_name" {
description = "Name of the resource group the Maps account is created in."
type = string
}
variable "sku_name" {
description = "Pricing SKU for the Maps account. G2 is the only generally-available tier on azurerm 4.x (S0/S1 are retired)."
type = string
default = "G2"
validation {
condition = contains(["G2"], var.sku_name)
error_message = "sku_name must be \"G2\". The legacy S0/S1 SKUs are no longer accepted for new accounts."
}
}
variable "local_authentication_enabled" {
description = "If false, shared-key auth is rejected and only Entra ID tokens are accepted on the data plane. Recommended: false for hardened workloads."
type = bool
default = true
}
variable "identity_type" {
description = "Managed identity type for the account. One of SystemAssigned, UserAssigned, \"SystemAssigned, UserAssigned\", or null for no identity."
type = string
default = null
validation {
condition = var.identity_type == null || contains(
["SystemAssigned", "UserAssigned", "SystemAssigned, UserAssigned"],
coalesce(var.identity_type, "null")
)
error_message = "identity_type must be one of: SystemAssigned, UserAssigned, \"SystemAssigned, UserAssigned\", or null."
}
}
variable "identity_ids" {
description = "List of user-assigned managed identity resource IDs. Required when identity_type includes UserAssigned."
type = list(string)
default = []
validation {
condition = alltrue([for id in var.identity_ids : can(regex("^/subscriptions/.+/userAssignedIdentities/.+$", id))])
error_message = "Each entry in identity_ids must be a full user-assigned managed identity resource ID."
}
}
variable "cors_allowed_origins" {
description = "List of origins (e.g. https://app.kloudvin.com) allowed to call the Maps data plane from a browser. Empty list disables the CORS block. Avoid using \"*\" in production."
type = list(string)
default = []
validation {
condition = length(var.cors_allowed_origins) <= 10
error_message = "Azure Maps accepts at most 10 CORS allowed_origins."
}
}
variable "tags" {
description = "Additional tags merged onto the account (module/managed_by tags are always applied)."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "Resource ID of the Azure Maps account."
value = azurerm_maps_account.this.id
}
output "name" {
description = "Name of the Azure Maps account."
value = azurerm_maps_account.this.name
}
output "client_id" {
description = "Unique client ID (account GUID) used as the x-ms-client-id header on data-plane calls authenticated with Entra ID."
value = azurerm_maps_account.this.x_ms_client_id
}
output "identity_principal_id" {
description = "Principal ID of the system-assigned identity, if one was created (else null)."
value = try(azurerm_maps_account.this.identity[0].principal_id, null)
}
output "primary_access_key" {
description = "Primary shared key for the account (only usable when local_authentication_enabled = true)."
value = azurerm_maps_account.this.primary_access_key
sensitive = true
}
output "secondary_access_key" {
description = "Secondary shared key, used for zero-downtime key rotation."
value = azurerm_maps_account.this.secondary_access_key
sensitive = true
}
How to use it
Here a system-assigned identity is enabled, shared keys are switched off, and the browser SDK is pinned to two of our own origins. The Maps account’s identity is then granted a data-plane role so it can read custom styles from a Storage account, and its client_id is handed to a Static Web App as configuration.
module "azure_maps" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-maps?ref=v1.0.0"
name = "kv-maps-prod"
resource_group_name = azurerm_resource_group.geo.name
sku_name = "G2"
# Entra-only: no shared keys accepted on the data plane.
local_authentication_enabled = false
identity_type = "SystemAssigned"
cors_allowed_origins = [
"https://app.kloudvin.com",
"https://maps.kloudvin.com",
]
tags = {
environment = "prod"
cost_center = "geo-platform"
}
}
# Let the Maps account read custom map styles / Creator data from Storage.
resource "azurerm_role_assignment" "maps_reads_styles" {
scope = azurerm_storage_account.map_styles.id
role_definition_name = "Storage Blob Data Reader"
principal_id = module.azure_maps.identity_principal_id
}
# Downstream: front-end needs the client ID to set x-ms-client-id when
# acquiring Entra tokens for atlas.microsoft.com.
resource "azurerm_static_web_app_custom_domain_setting" "noop" {
# illustrative downstream consumer of a module output
count = 0
}
resource "azurerm_static_web_app" "frontend" {
name = "kv-geo-frontend"
resource_group_name = azurerm_resource_group.geo.name
location = "centralus"
sku_tier = "Standard"
sku_size = "Standard"
app_settings = {
AZURE_MAPS_CLIENT_ID = module.azure_maps.client_id
}
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "azurerm"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...azurerm state bucket/container + key per path...
}
}
2. Module config — live/prod/maps/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-maps?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/maps && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Name of the Azure Maps account (1-98 chars; letters, numbers, hyphens). |
resource_group_name |
string |
— | Yes | Resource group the Maps account is created in. |
sku_name |
string |
"G2" |
No | Pricing SKU; only G2 is accepted on azurerm 4.x (S0/S1 retired). |
local_authentication_enabled |
bool |
true |
No | When false, shared-key auth is rejected and only Entra ID tokens are accepted. |
identity_type |
string |
null |
No | SystemAssigned, UserAssigned, "SystemAssigned, UserAssigned", or null. |
identity_ids |
list(string) |
[] |
No | User-assigned identity resource IDs; required when identity_type includes UserAssigned. |
cors_allowed_origins |
list(string) |
[] |
No | Origins allowed to call the data plane from a browser (max 10). Empty disables CORS. |
tags |
map(string) |
{} |
No | Extra tags merged onto the account. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the Azure Maps account. |
name |
Name of the Azure Maps account. |
client_id |
Account GUID used as the x-ms-client-id header on Entra-authenticated data-plane calls. |
identity_principal_id |
Principal ID of the system-assigned identity, or null if none. |
primary_access_key |
Primary shared key (sensitive); usable only when local auth is enabled. |
secondary_access_key |
Secondary shared key (sensitive) for zero-downtime rotation. |
Enterprise scenario
A logistics company runs a fleet-tracking portal where dispatchers see live vehicle positions over a custom-branded basemap. They deploy one azure_maps module instance per region, each with local_authentication_enabled = false so every call from the React front-end and the .NET routing service uses Entra tokens with the account’s client_id — no key ever lands in a browser. The module’s system-assigned identity is granted Storage Blob Data Reader on the styles container, letting Azure Maps Creator serve their indoor warehouse maps, while CORS is pinned to the three corporate portal domains so the Web SDK cannot be abused from a phishing site.
Best practices
- Disable shared-key auth (
local_authentication_enabled = false) for production and authenticate with Entra ID tokens plus the accountclient_id; reserve the shared keys for break-glass or legacy clients only. - Never set
cors_allowed_originsto["*"]for a public web app — pin it to your exact origins. The browser SDK callsatlas.microsoft.comdirectly, so a wildcard lets any site borrow your account’s quota. - Use a managed identity instead of storage keys when Maps needs to read custom styles or Creator datasets, and grant it the least-privilege
Storage Blob Data Readerrole rather than a broader one. - Rotate keys with the secondary slot: roll consumers onto
secondary_access_key, regenerate the primary, then swap back — the two outputs make this a zero-downtime operation. Even better, avoid keys entirely. - Right-size on the
G2SKU and watch transactions: Maps bills per geocoding/render/route transaction, so cache tiles and geocode results, and set a budget alert — a chatty front-end can run up costs fast. - Name and tag every account consistently (
kv-maps-<env>plusenvironment/cost_centertags) so you can attribute the per-transaction spend back to the team that owns the workload.