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.

  • + create on a resource that already exists. A pulumi preview proposing 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.
  • - delete still appearing in terraform 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 export that 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.

Incremental Terraform-to-Pulumi migration: import, align, remove, one resource at a time A left-to-right flow. The left column is Terraform state holding three resources: S3 raster storage, PostGIS instance, and GeoServer service. A central pipeline shows three steps per resource — pulumi import, align inputs until preview shows no changes, then terraform state rm. The right column is the Pulumi checkpoint filling with the same three resources. A horizontal band labels the middle as the dual-running transition window where ownership never overlaps. Terraform state S3 raster storage PostGIS instance GeoServer service 1 · pulumi import adopt live resource by id 2 · align inputs preview shows no changes 3 · terraform state rm release TF ownership Pulumi checkpoint S3 raster storage PostGIS instance GeoServer service Dual-running window — both engines live, ownership never overlaps a resource belongs to exactly one engine at any moment Migrate lowest-risk resource first; a clean, no-change preview gates every state removal.

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.120 with 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 in package.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 feed pulumi 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.

  1. 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
  2. Import each resource into Pulumi. pulumi import adopts 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
  3. 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 S3 bucket name) that differs from reality triggers a destroy-recreate. Iterate pulumi preview until 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 cutover

    Note: if you extend this instance with an aws.rds.ParameterGroup, express memory parameters in AWS integer block units — shared_buffers is counted in 8 kB blocks, so 4 GB is 524288, never "25%" or "4GB". A percentage string is silently invalid and will surface as drift on the next preview.

  4. Release Terraform ownership only after a clean preview. With pulumi preview reporting no changes for the resource, remove it from Terraform state so the two engines never both claim it. terraform state rm deletes 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
  5. 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 plan to 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-changes must 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 GetMap and a WMTS tile through the load balancer and assert a 200 with image/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 plan reports “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: true on 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 apply re-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 preview proposes 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.