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
- You need geocoding or reverse-geocoding in an application — address validation at checkout, mapping user-submitted locations, or “stores near me” search.
- You want address autocomplete / search-as-you-type powered by
SearchPlaceIndexForSuggestionswithout standing up your own gazetteer. - You are standardising location capabilities across multiple services and want a single, tagged, region-consistent place index per environment rather than ad-hoc indexes created from the console.
- You need to enforce a specific data provider for legal, regional (e.g. Grab for Southeast Asia), or data-residency reasons and want that locked in code review, not left to whoever clicks “Create”.
- You want the index, its IAM read policy, and optional CloudWatch logging provisioned and destroyed as one unit.
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 config — live/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 config — live/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
- Treat
data_sourceandintended_useas permanent. The data source isForceNew; flipping it recreates the index and breaks consumers. Setintended_use = "Storage"only if you genuinely cache results —SingleUseis cheaper to reason about legally and is the safer default. - Scope IAM to the index ARN, never
geo:*on*. The module’s read policy grants exactly the four search/geocode actions against this index’s ARN; attach that instead of hand-rolling broad permissions, and keep write/admin actions (geo:CreatePlaceIndex,geo:DeletePlaceIndex) out of application roles. - Control cost at the call site, not the resource. Place indexes are billed per request and carry no idle cost, so the lever is request volume — debounce address-autocomplete keystrokes, cache repeat lookups, and prefer
SearchPlaceIndexForSuggestionsfor type-ahead rather than firing fullSearchPlaceIndexForTexton every character. - Pick the provider for coverage and residency, not habit. Use
Grabfor Southeast Asia,Here/Esrifor broad global coverage, and confirm the provider’s data-residency terms match your region before standardising — encode the decision in the module call so it is reviewable. - Name and tag per environment. Use a
name_prefixlikeprod-checkoutso prod and non-prod indexes never collide, and rely on the module’s baked-inService,DataSource, andIntendedUsetags plus your ownEnvironment/CostCenterfor clean cost allocation. - Confirm regional availability before you deploy. Amazon Location Service and specific data providers are not in every AWS region; pin your provider/region and validate the combination so a
terraform applydoesn’t fail mid-rollout.