IaC AWS

Terraform Module: AWS Location Service — a reusable place index for geocoding and search

Quick take — Provision Amazon Location Service place indexes with a hardened, var-driven Terraform module: pick your data provider, control intended use, lock down storage, and tag for cost. 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 "aws" {
  region = "us-east-1"
}

module "location_service" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-location-service?ref=v1.0.0"

  # (no required inputs — all have sensible defaults)
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Amazon Location Service is AWS’s managed location stack — maps, place search (geocoding), routing, geofencing and asset tracking — billed per request with no servers to run. The piece this module wraps is the place index (aws_location_place_index): the resource you call to turn an address into coordinates (geocoding), turn coordinates back into an address (reverse geocoding), or search for points of interest by text. It is the workhorse behind “find the nearest store”, “validate this shipping address”, or “autocomplete this address field”.

A place index looks deceptively simple — one resource, three meaningful settings — but the settings carry real consequences. You choose a data source (Esri, Here, Grab, or the AWS-curated Amazon provider), and that choice is effectively permanent: the data source is ForceNew, so changing it destroys and recreates the index and breaks every consumer holding the index name. You also declare an intended use (SingleUse vs Storage), which is a licensing constraint baked into the data provider’s terms — get it wrong and you are either paying for capability you can’t legally use or violating the provider agreement. Wrapping this in a module pins those decisions behind validated variables, enforces consistent naming and tagging across every team that needs geocoding, and gives you one place to attach the IAM policy that actually lets applications call SearchPlaceIndexForText.

When to use it

Reach for the routing or tracker resources instead if you need ETA/route calculation or live asset tracking — this module is specifically the search/geocode building block.

Module structure

terraform-module-aws-location-service/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}
# main.tf

locals {
  # A place index name must be 1-100 chars: alphanumerics, hyphens, periods, underscores.
  index_name = var.index_name != null ? var.index_name : "${var.name_prefix}-place-index"

  common_tags = merge(
    {
      "Module"      = "terraform-module-aws-location-service"
      "ManagedBy"   = "Terraform"
      "Service"     = "AmazonLocationService"
      "DataSource"  = var.data_source
      "IntendedUse" = var.intended_use
    },
    var.tags
  )
}

resource "aws_location_place_index" "this" {
  index_name  = local.index_name
  data_source = var.data_source
  description = var.description

  data_source_configuration {
    # Only honored by the data provider when intended_use = "Storage".
    # "SingleUse" results may not be stored; "Storage" allows caching results.
    intended_use = var.intended_use
  }

  tags = local.common_tags
}

# Read-only IAM policy that applications/roles can attach to call the index.
# Created only when var.create_read_policy = true.
data "aws_iam_policy_document" "read" {
  count = var.create_read_policy ? 1 : 0

  statement {
    sid    = "AllowPlaceIndexSearch"
    effect = "Allow"

    actions = [
      "geo:SearchPlaceIndexForText",
      "geo:SearchPlaceIndexForPosition",
      "geo:SearchPlaceIndexForSuggestions",
      "geo:GetPlace",
    ]

    resources = [aws_location_place_index.this.index_arn]
  }
}

resource "aws_iam_policy" "read" {
  count = var.create_read_policy ? 1 : 0

  name_prefix = "${local.index_name}-read-"
  description = "Read/search access to the ${local.index_name} place index"
  policy      = data.aws_iam_policy_document.read[0].json

  tags = local.common_tags
}
# variables.tf

variable "name_prefix" {
  description = "Prefix used to build the index name when index_name is not supplied (e.g. \"prod-checkout\")."
  type        = string
  default     = "kloudvin"

  validation {
    condition     = can(regex("^[A-Za-z0-9._-]+$", var.name_prefix))
    error_message = "name_prefix may contain only alphanumerics, periods, underscores, and hyphens."
  }
}

variable "index_name" {
  description = "Explicit place index name. If null, it is derived from name_prefix. Must be 1-100 chars of [A-Za-z0-9._-]."
  type        = string
  default     = null

  validation {
    condition     = var.index_name == null || can(regex("^[A-Za-z0-9._-]{1,100}$", var.index_name))
    error_message = "index_name must be 1-100 characters: alphanumerics, periods, underscores, or hyphens."
  }
}

variable "data_source" {
  description = "Geospatial data provider for the index. Changing this forces replacement of the index."
  type        = string
  default     = "Esri"

  validation {
    condition     = contains(["Esri", "Here", "Grab", "Amazon"], var.data_source)
    error_message = "data_source must be one of: Esri, Here, Grab, Amazon."
  }
}

variable "intended_use" {
  description = "How results may be used per the provider's terms. SingleUse = results not stored; Storage = results may be cached/stored."
  type        = string
  default     = "SingleUse"

  validation {
    condition     = contains(["SingleUse", "Storage"], var.intended_use)
    error_message = "intended_use must be either SingleUse or Storage."
  }
}

variable "description" {
  description = "Optional human-readable description for the place index."
  type        = string
  default     = "Managed by Terraform — Amazon Location Service place index."

  validation {
    condition     = length(var.description) <= 1000
    error_message = "description must be 1000 characters or fewer."
  }
}

variable "create_read_policy" {
  description = "Whether to create a customer-managed IAM policy granting read/search access to this index."
  type        = bool
  default     = true
}

variable "tags" {
  description = "Additional tags merged onto every resource created by the module."
  type        = map(string)
  default     = {}
}
# outputs.tf

output "index_name" {
  description = "Name of the place index — pass this to client SDK calls (e.g. SearchPlaceIndexForText)."
  value       = aws_location_place_index.this.index_name
}

output "index_arn" {
  description = "ARN of the place index, suitable for scoping IAM policies."
  value       = aws_location_place_index.this.index_arn
}

output "data_source" {
  description = "The geospatial data provider backing the index."
  value       = aws_location_place_index.this.data_source
}

output "create_time" {
  description = "Timestamp (RFC 3339) at which the place index was created."
  value       = aws_location_place_index.this.create_time
}

output "read_policy_arn" {
  description = "ARN of the customer-managed read/search IAM policy, or null when create_read_policy = false."
  value       = try(aws_iam_policy.read[0].arn, null)
}

How to use it

module "location_service" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-location-service?ref=v1.0.0"

  name_prefix  = "prod-checkout"
  data_source  = "Here"        # HERE for richer global address coverage at checkout
  intended_use = "Storage"     # we cache validated addresses against the order record
  description  = "Checkout address validation and geocoding"

  create_read_policy = true

  tags = {
    Environment = "prod"
    Team        = "fulfillment"
    CostCenter  = "1042"
  }
}

# Downstream: attach the module's read policy to the app's task role so the
# checkout service can call SearchPlaceIndexForText against this index.
resource "aws_iam_role_policy_attachment" "checkout_geocode" {
  role       = aws_iam_role.checkout_task.name
  policy_arn = module.location_service.read_policy_arn
}

# Downstream: surface the index name to the running container as an env var.
resource "aws_ssm_parameter" "place_index_name" {
  name  = "/prod/checkout/place-index-name"
  type  = "String"
  value = module.location_service.index_name
}

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 = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/location_service/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-location-service?ref=v1.0.0"
}

inputs = {
  # (no required inputs)
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/location_service && 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_prefix string "kloudvin" No Prefix used to build the index name when index_name is not supplied.
index_name string null No Explicit index name (1-100 chars of [A-Za-z0-9._-]); derived from name_prefix when null.
data_source string "Esri" No Data provider: Esri, Here, Grab, or Amazon. Changing it forces replacement.
intended_use string "SingleUse" No SingleUse (results not stored) or Storage (results may be cached).
description string "Managed by Terraform …" No Human-readable description, max 1000 chars.
create_read_policy bool true No Create a customer-managed IAM policy granting read/search access to the index.
tags map(string) {} No Additional tags merged onto all module resources.

Outputs

Name Description
index_name Name of the place index; pass to client SDK search/geocode calls.
index_arn ARN of the place index, for scoping IAM policies.
data_source The geospatial data provider backing the index.
create_time RFC 3339 timestamp at which the index was created.
read_policy_arn ARN of the read/search IAM policy, or null when create_read_policy = false.

Enterprise scenario

A retail platform validates and geocodes every shipping address at checkout to cut failed deliveries. The fulfillment team consumes this module with data_source = "Here" and intended_use = "Storage" so validated coordinates can be persisted on the order, attaches the emitted read_policy_arn to the checkout ECS task role, and publishes index_name to SSM Parameter Store for the container to read at boot. Because the data provider is pinned in code, a later attempt to “save money” by switching to Esri is caught in review — the reviewer sees the ForceNew data source change in the plan and knows it would orphan the cached addresses and break the live index.

Best practices

TerraformAWSLocation ServiceModuleIaC
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