Migrating GIS Terraform State from Local to S3
A spatial platform almost always begins on a laptop. Someone provisions a PostGIS instance and a raster bucket to test an idea, it works, and eighteen months later that terraform.tfstate file — still on one machine, still unlocked, still the only record of which resources exist — is the authoritative description of production geometry storage. Migrating it to a locked remote backend is the single highest-value piece of infrastructure hygiene available to a GIS team, and it is also the operation with the most alarming failure mode, because a botched migration can leave the engine believing that a live PostGIS cluster does not exist. This guide is the operational companion to State Backend Selection within Spatial IaC Architecture and Fundamentals, and it walks the migration in an order designed so that every step before the point of no return is reversible.
Symptom identification and triage
You need this migration if any of the following is true, and the first two are the ones that turn into incidents.
- The state file exists in exactly one place. No versioning, no backup, and the machine holding it is a workstation. A disk failure here does not destroy the infrastructure, but it destroys the only map of it, and rebuilding that map means importing every resource by hand.
- Two people can apply at once. Without a lock, two concurrent applies against the same resources interleave writes and produce a state file that describes neither run. On a spatial estate this typically surfaces as a duplicate security group or an orphaned read replica that no configuration claims.
- State is committed to version control. This is the variant that looks safe and is not: the file contains resource attributes including, for a database, connection endpoints and sometimes generated passwords, and it is now in every clone of the repository and in its entire history.
terraform planproposes to create things that already exist. Someone ran an apply from a different copy of the state. The engine is not wrong; it genuinely does not know about those resources.
Before touching anything, establish what the state actually contains. terraform state list enumerates every managed resource, and for a spatial estate you should expect to recognise every entry — a cluster, its parameter group, its subnet group, the buckets, the security groups. An entry you cannot account for, or a resource you know exists that is missing from the list, is a discrepancy to resolve before migrating rather than during.
Prerequisites and environment assumptions
Terraform 1.6 or later with the hashicorp/aws provider pinned at ~> 5.60; provider versions matter here because the backend block’s supported arguments have changed across releases and a mismatched example is a confusing first failure. An IAM principal that can create a bucket, a DynamoDB table and a key, and separately a principal for day-to-day use that can read and write only the state object and the lock item. Exclusive access to the estate for the duration — announce the migration and ensure nobody applies from another copy while it is in progress, because the one thing this procedure cannot survive is a concurrent write from the state file you are moving.
The bucket and lock table must be created by a separate configuration whose own state is small and can stay local or be committed deliberately. Trying to have one configuration create the backend it also stores itself in is the chicken-and-egg problem that produces half-migrated estates, and the workaround of applying it twice is more fragile than simply keeping a small bootstrap configuration.
Step-by-step migration
-
Inventory and reconcile. Run
terraform state listandterraform plan. The plan must be clean before you begin. If it proposes changes, decide deliberately whether to apply them first or to fix the configuration — migrating a state that already disagrees with reality means you cannot use “plan reports no changes” as your success signal afterwards, and that signal is the whole verification strategy. -
Provision the backend resources. A bucket with versioning enabled and a customer-managed key, plus a DynamoDB lock table. Versioning is not optional: it is what makes a bad state write recoverable, and it costs nothing at state-file volumes. Encryption follows the domain-key approach in Encryption and Key Management for Spatial Data, because a state file for a spatial estate contains endpoints, ARNs and occasionally generated credentials.
-
Back up the local state. Copy
terraform.tfstateto a timestamped file outside the working directory, and verify the copy parses as JSON and lists the resources you expect. This backup is the entire recovery plan for step four. -
Add the backend block and migrate. Add the
backend "s3"block and runterraform init -migrate-state. The engine detects the local state, asks for confirmation, and copies it to the bucket. Answer yes only once you have completed step three. -
Verify, then remove the local file. Run
terraform planand requireNo changes. Only after that succeeds should the localterraform.tfstateandterraform.tfstate.backupbe deleted from the working directory, and only then should the state file be removed from version control history if it was ever committed.
# bootstrap/main.tf — a separate, deliberately small configuration.
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
resource "aws_s3_bucket" "state" {
bucket = "gis-terraform-state-prod"
# A state bucket must never be destroyed by a refactor of this configuration.
lifecycle { prevent_destroy = true }
}
resource "aws_s3_bucket_versioning" "state" {
bucket = aws_s3_bucket.state.id
# Versioning is what makes a bad state write recoverable. It is the reason
# this bucket is not just any bucket.
versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_public_access_block" "state" {
bucket = aws_s3_bucket.state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_dynamodb_table" "locks" {
name = "gis-terraform-state-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
lifecycle { prevent_destroy = true }
}
# The backend block added to the real configuration in step 4.
terraform {
backend "s3" {
bucket = "gis-terraform-state-prod"
key = "platform/postgis/terraform.tfstate"
region = "eu-west-1"
dynamodb_table = "gis-terraform-state-locks"
encrypt = true
}
}
Verification
terraform plan reporting No changes. Your infrastructure matches the configuration. is the primary signal and it is close to conclusive: it means every resource in the migrated state was found in the account with matching attributes. Confirm the state object exists in the bucket with a version identifier, and confirm the lock mechanism works by starting a plan in one terminal and a second in another — the second must block with a message naming the holder, not proceed.
Then verify the spatial estate itself is untouched, because a migration should change nothing about it: the PostGIS cluster still accepts a connection and SELECT postgis_full_version() still answers, the raster bucket still lists, and the tile endpoint still returns a tile. State migration does not touch running resources, so any change here indicates that something other than the migration happened during the window.
Preventing recurrence
- Delete the local state and remove it from history. A leftover
terraform.tfstatein a working directory is the seed of the next divergence, because someone will eventually run against it. If it was committed, purge it from repository history and rotate any credential it contained — it was readable by everyone with a clone. - Make the backend mandatory in the module template. New configurations should start from a template that already carries a backend block, so the local-state stage never happens again for the next component.
- Enforce it in the pipeline. A policy rule that fails any configuration without a
backendblock, run in the gate described in Policy as Code for Spatial Resources, turns this from a convention into a constraint. - Add the state bucket to the backup and drill schedule. A versioned bucket is recoverable in principle; a team that has never restored a prior state version has not verified that in practice.
Frequently Asked Questions
Will migrating state change or restart my PostGIS cluster?
No. State migration copies a file describing resources; it does not call any API against the resources themselves. If a plan after migration proposes changes to the cluster, the cause is a configuration drift that predates the migration, not the migration.
What do I do if the post-migration plan proposes to create resources that already exist?
Stop and do not apply. That plan means the remote state is empty or partial — the migration did not carry everything across. Restore the backup taken in step three, re-run terraform init -migrate-state, and confirm terraform state list against the remote backend returns the same inventory as before.
Do I still need DynamoDB for locking?
Recent Terraform releases support S3-native conditional-write locking, which removes the separate table. Whichever mechanism your pinned version supports, the requirement is unchanged: some lock must exist, because the failure it prevents is concurrent writes to spatial infrastructure state. Verify which mechanism your version implements rather than copying a configuration written for a different one.
Should each environment have its own bucket or its own key?
Separate keys in one bucket per account is the common arrangement and is sufficient when IAM scopes access per key prefix. Separate buckets per environment give a cleaner blast radius and simpler policies at the cost of more bootstrap. The trade-off is developed in State Backend Selection.
Related
- State Backend Selection — the parent topic on choosing and structuring a backend
- Managing Terraform State Locks for Spatial Data — what to do when a lock is orphaned after this migration
- Terraform Workspaces vs Separate State Backends for Multi-Env GIS — how to split state once it is remote
- Encryption and Key Management for Spatial Data — the key protecting the state object