Migrating a GeoServer Stack from Terraform to Pulumi
Moving a running GeoServer deployment from Terraform to Pulumi is not a rewrite — it is a state-transfer operation where the single unacceptable outcome is a resource replacement that tears down a live tile origin, a PostGIS instance, or the S3 raster store the map layers depend on. Done carelessly, the first pulumi up plans to create infrastructure that already exists and destroy what Terraform still tracks, cutting service for every consumer at once. This guide, part of Terraform vs Pulumi for GIS inside the Spatial IaC Architecture & Fundamentals discipline, walks the incremental import path: bringing existing resources under Pulumi via pulumi import, converting state without replacement, running both engines during the transition, and verifying that not a single live resource is recreated.
Symptom identification and triage
The failure this migration exists to avoid is stark and easy to spot in a preview. Before importing anything, learn to read the signals that separate a safe transfer from a destructive one.
+ createon a resource that already exists. Apulumi previewproposing to create the RDS PostGIS instance, the GeoServer service, or the raster bucket means Pulumi has no record of the live resource. Applying this provisions a duplicate or fails on a name collision — never apply it.- deletestill appearing interraform plan. If Terraform still owns a resource after you believe it was migrated, both engines think they own it. The next apply on either side fights the other.replace(delete then create) in the Pulumi preview. The most dangerous signal: an imported resource whose declared inputs do not match the live attributes, so Pulumi wants to destroy and recreate it. For a GeoServer origin or a PostGIS instance holding spatial data, replacement is an outage.- Empty state file after export. A
pulumi stack exportthat shows no resources means imports have not landed in the checkpoint yet — proceed no further until they have.
The correct migration order flows resource by resource, lowest-risk first, with a clean preview gating every promotion. The path below shows the sequence.
Prerequisites and environment assumptions
The migration assumes a working Terraform-managed stack — an S3 raster bucket, an RDS PostGIS instance, and a containerized GeoServer service (ECS or EKS) fronted by a load balancer — and a target Pulumi program that will adopt them. Before the first import:
-
Pulumi
>= 3.120with the AWS provider pinned so imports resolve against a fixed schema. Unpinned providers shift resource-attribute defaults and manufacture phantom diffs during alignment. Pin both packages inpackage.json:{ "dependencies": { "@pulumi/pulumi": "3.142.0", "@pulumi/aws": "6.66.0" } } -
A separate, locking-capable Pulumi backend. Do not point Pulumi at Terraform’s state object. Use an isolated Pulumi Cloud stack or a self-managed S3 + DynamoDB backend, chosen per State Backend Selection, so the two engines’ ledgers never touch during the dual-running window.
-
Read access to the live Terraform state. You need the resource IDs Terraform recorded (
terraform state show) to feedpulumi import. Keep a snapshot of the Terraform state before you begin, as your rollback anchor. -
IAM permissions for the operator: describe/read on every resource being imported (
rds:DescribeDBInstances,s3:GetBucketPolicy,ecs:DescribeServices), plus write access to both state backends. A read-only import fails at the alignment step when it cannot re-read attributes.
Step-by-step remediation
Migrate one resource at a time, lowest blast-radius first, and never remove a resource from Terraform state until Pulumi previews it with zero changes.
-
Capture the live resource IDs from Terraform. For each resource, read the ID Terraform holds — this is the handle Pulumi imports against. Start with the S3 raster bucket because it is stateless from a compute view and the safest first move.
terraform state show aws_s3_bucket.raster_store # note the bucket id terraform state show aws_db_instance.postgis # note the db instance id terraform state show aws_ecs_service.geoserver # note cluster/service arn -
Import each resource into Pulumi.
pulumi importadopts the existing resource into the checkpoint and prints generated code you reconcile into your program. Import the raster store first, verify, then the PostGIS instance, then GeoServer — mirroring the dependency order the Pulumi Programmatic Resource Mapping for GeoServer guide uses to structure the target program.pulumi import aws:s3/bucketV2:BucketV2 raster-store prod-gis-raster-store pulumi import aws:rds/instance:Instance postgis prod-gis-postgis pulumi import aws:ecs/service:Service geoserver arn:aws:ecs:us-east-1:123456789012:service/gis/geoserver -
Align program inputs until the preview is clean. Paste the imported resource definitions into your Pulumi program and adjust every input to match the live attributes exactly. This is where replacement risk lives: an input Pulumi treats as replace-forcing (an RDS
engine_version, an S3bucketname) that differs from reality triggers a destroy-recreate. Iteratepulumi previewuntil it reports no changes.import * as aws from "@pulumi/aws"; // Imported and aligned so preview reports zero changes — values must mirror // the live resource exactly, or Pulumi will propose a destructive replace. const postgis = new aws.rds.Instance("postgis", { identifier: "prod-gis-postgis", engine: "postgres", engineVersion: "17.4", // must equal the live engine version instanceClass: "db.r6g.large", allocatedStorage: 100, storageEncrypted: true, publiclyAccessible: false, deletionProtection: true, // keep on through the migration dbSubnetGroupName: "prod-gis-subnet-group", vpcSecurityGroupIds: ["sg-0abc123def456"], }, { protect: true }); // block accidental deletion during cutoverNote: if you extend this instance with an
aws.rds.ParameterGroup, express memory parameters in AWS integer block units —shared_buffersis counted in 8 kB blocks, so 4 GB is524288, never"25%"or"4GB". A percentage string is silently invalid and will surface as drift on the next preview. -
Release Terraform ownership only after a clean preview. With
pulumi previewreporting no changes for the resource, remove it from Terraform state so the two engines never both claim it.terraform state rmdeletes the record without touching the live resource — the resource keeps running, now owned solely by Pulumi.pulumi preview --diff # must show: no changes for the imported resource terraform state rm aws_s3_bucket.raster_store -
Repeat resource by resource, then decommission the Terraform config. Once all three resources preview clean in Pulumi and are removed from Terraform state, delete the corresponding blocks from the Terraform configuration and run a final
terraform planto confirm it proposes nothing. Only then retire the Terraform state object.
Verification
Prove no live resource was replaced and the migrated stack is fully under Pulumi:
- Zero-change preview across the whole stack.
pulumi preview --expect-no-changesmust exit clean after all imports. Any proposed create, delete, or replace means an input is still misaligned — do not proceed to cutover. - Resource identity is unchanged. Compare the RDS instance identifier, the S3 bucket name, and the ECS service ARN before and after; they must be identical. A changed ID is the fingerprint of a replacement that already happened.
- GeoServer still serves live layers. Request a known WMS
GetMapand a WMTS tile through the load balancer and assert a200withimage/png. A round-trip through the actual origin confirms the container, its PostGIS connection, and the raster store all survived intact. - PostGIS spatial query round-trips. Run a
SELECT ST_SRID(geom) FROM ...against a known layer to confirm the database instance is the same one, with its spatial data and indexes untouched. - Terraform owns nothing. A final
terraform planreports “No changes” against an empty configuration, confirming ownership fully transferred.
Preventing recurrence
Lock in the migrated state so a future change cannot silently reintroduce the double-ownership hazard:
- Keep
protect: trueon stateful resources. The PostGIS instance and raster bucket should stay protected in Pulumi so an accidental config change cannot delete them. Remove protection only through a deliberate, reviewed change. - Delete the old Terraform config, do not archive it live. A dormant Terraform configuration that still references migrated resources is a loaded gun — a stray
terraform applyre-adopts and fights Pulumi. Remove the blocks entirely once migration completes. - Gate imports behind a no-change preview in CI. Add a pipeline check that fails any merge whose
pulumi previewproposes a replace on a protected resource, so the destructive pattern can never reach production unreviewed. - Document the cutover in the state backend record. Note which resources moved and when, so the next engineer understands why the Terraform state is empty and does not attempt to “restore” it.
Frequently asked questions
Will pulumi import modify or restart my live GeoServer resources?
No. pulumi import only reads the live resource and records it in the Pulumi checkpoint — it does not mutate infrastructure. The risk comes later, at the first pulumi up, if your program inputs do not match the live attributes and Pulumi proposes a replacement. That is why you align inputs until preview reports zero changes before applying anything.
Can Terraform and Pulumi manage the same resource at once during the transition?
Never intentionally. Each resource must belong to exactly one engine at any moment. The safe sequence is import into Pulumi, confirm a clean preview, then terraform state rm to release Terraform’s claim. Between the import and the state removal both records exist, so do not run terraform apply or pulumi up in that gap.
The preview wants to replace my PostGIS instance after import — what do I do?
Stop and diff. A proposed replace means a replace-forcing input — usually engineVersion, identifier, or a subnet group — differs from the live resource. Read the live attributes with terraform state show or the AWS console and edit your Pulumi inputs to match exactly. Keep deletionProtection and protect: true on until the preview is clean, so an accidental apply cannot destroy the database.
Do I need a fresh Pulumi backend, or can I reuse the Terraform state bucket?
Use a fresh, isolated Pulumi backend. Pulumi’s checkpoint format is not Terraform’s state format, and pointing Pulumi at the Terraform state object risks corrupting the ledger that still governs un-migrated resources. Keep the two backends separate through the entire dual-running window, then retire the Terraform state only after migration completes.
Related
- Terraform vs Pulumi for GIS: Choosing a Spatial IaC Engine — the trade-offs that justify or caution against this migration.
- Pulumi Programmatic Resource Mapping for GeoServer — how to structure the Pulumi program the imported resources land in.
- State Backend Selection — choosing the isolated, locking backend the migration requires.
- Spatial IaC Architecture & Fundamentals — the architectural framework this migration sits within.