IaC Azure

Terraform Module: Azure Azure Maps — one wired-up mapping account with managed identity and CORS

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

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 configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/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

TerraformAzureAzure MapsModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading