Integrating Terraform and Pulumi in One GIS Pipeline
Plenty of geospatial platforms end up running both engines on purpose: a stable Terraform layer owns the network and data foundations, while a Pulumi layer expresses the algorithm-driven tile-serving logic that HCL makes awkward. The integration problem is making them cooperate in one pipeline — letting Pulumi consume Terraform’s outputs (VPC IDs, subnet lists, bucket names) without duplicating resources, ordering the CI so the foundation applies before the app layer, and keeping the two ledgers from drifting apart. This guide, part of Terraform vs Pulumi for GIS within the Spatial IaC Architecture & Fundamentals discipline, shows how to wire a tile-serving stack across the boundary using Pulumi’s Terraform state reference and a single ordered pipeline.
Symptom identification and triage
A two-engine pipeline that is wired wrong fails in recognizable ways. Recognize the signal before you reach for a fix.
- Hardcoded IDs in the Pulumi program. A VPC ID or bucket name pasted as a string literal that Terraform actually owns is the root symptom. When Terraform recreates that resource, Pulumi silently points at a resource that no longer exists and the tile stack breaks at runtime, not at plan time.
- Duplicate resources across engines. Both stacks declaring the same security group or subnet means each plans to create its own copy — a classic double-ownership drift, and the collision surfaces as a name conflict on apply.
- App layer applies before its foundation. A CI run where the Pulumi tile stack deploys before Terraform has published fresh VPC and bucket outputs consumes stale values, so a new subnet or renamed bucket never reaches the consumer.
- Divergence between the two state ledgers. Terraform’s output for a bucket name no longer matches what Pulumi read last, because a mid-cycle Terraform change was not propagated. The tile origin points at yesterday’s infrastructure.
The correct wiring is one-directional: Terraform produces a foundation and exports outputs; Pulumi reads those outputs read-only through a state reference and builds the tile-serving layer on top. The diagram below shows the boundary and the CI ordering that keeps it consistent.
Prerequisites and environment assumptions
The integration assumes a Terraform foundation stack that already provisions the VPC, subnets, and S3 tile bucket, and a Pulumi program that builds the tile-serving layer (CloudFront plus an ECS tile service) on top. Before wiring them:
-
Pulumi
>= 3.120with both the AWS and the Terraform-state-reference providers pinned, so the state read resolves against a fixed schema:{ "dependencies": { "@pulumi/pulumi": "3.142.0", "@pulumi/aws": "6.66.0", "@pulumi/terraform": "6.2.0" } } -
Terraform
>= 1.10writing to a remote backend that Pulumi can read — an S3 backend for the foundation is typical. Itsoutputsblock must export exactly the values the tile layer needs (vpc_id,private_subnet_ids,tile_bucket_name) and nothing sensitive that should not cross the boundary. -
A clear ownership boundary. Terraform owns the foundation; Pulumi owns the tile layer. No resource is declared in both. The backend for each is isolated per State Backend Selection, and the Pulumi principal has read-only access to the Terraform state object.
-
CI able to run stages in order with an artifact or gate between them, so
pulumi upnever starts untilterraform applyhas published fresh outputs. This ordering discipline is the same one the Pipeline Orchestration for Spatial Deployments practices formalize.
Step-by-step remediation
Wire the boundary once, then let CI enforce the ordering on every run.
-
Export a stable output contract from Terraform. Treat the foundation’s outputs as an API: name them precisely, and change them only through reviewed PRs, because the Pulumi layer depends on them by name. Exporting the subnet list rather than individual subnets lets the tile service span availability zones without a Pulumi change.
terraform { required_version = ">= 1.10.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.60" # pin so foundation plans are reproducible } } } output "vpc_id" { value = aws_vpc.gis.id } output "private_subnet_ids" { value = aws_subnet.private[*].id } output "tile_bucket_name" { value = aws_s3_bucket.tiles.bucket } -
Read the Terraform state into Pulumi read-only. Use the
terraform.state.RemoteStateReferenceto pull the foundation outputs. This is a read, not an import — Pulumi never claims ownership of the VPC or bucket, so it cannot plan to modify or destroy them. That read-only boundary is what prevents the double-ownership drift.import * as aws from "@pulumi/aws"; import { state } from "@pulumi/terraform"; // Read-only view of the Terraform foundation's outputs. const foundation = new state.RemoteStateReference("foundation", { backendType: "s3", bucket: "gis-tfstate-prod", key: "foundation/terraform.tfstate", region: "us-east-1", }); const vpcId = foundation.getOutput("vpc_id"); const subnetIds = foundation.getOutput("private_subnet_ids"); const tileBucket = foundation.getOutput("tile_bucket_name"); -
Build the tile layer on the shared outputs. Wire the CloudFront origin to the imported bucket name and place the ECS tile service in the imported subnets. Because the values come from the live state read, a Terraform change that renames the bucket flows through on the next
pulumi up— no literal to update by hand.const tileService = new aws.ecs.Service("tile-service", { cluster: tileCluster.arn, desiredCount: 3, networkConfiguration: { subnets: subnetIds as pulumi.Output<string[]>, // from Terraform securityGroups: [tileSg.id], }, taskDefinition: tileTask.arn, }); const cdn = new aws.cloudfront.Distribution("tile-cdn", { enabled: true, origins: [{ originId: "tile-bucket", domainName: pulumi.interpolate`${tileBucket}.s3.us-east-1.amazonaws.com`, }], // ...default cache behavior for tiles omitted for brevity }); -
Order the two stages in one pipeline. Run
terraform applyfor the foundation first, capture its success as a gate, then runpulumi up. The gate guarantees the tile layer reads outputs that reflect the just-applied foundation, never a stale snapshot.# ci-pipeline.yml (excerpt) — foundation before app layer jobs: foundation: steps: - run: terraform -chdir=foundation apply -auto-approve tile-layer: needs: foundation # hard ordering gate steps: - run: pulumi up --yes --stack prod
Verification
Confirm the two engines are cooperating and neither owns the other’s resources:
- Pulumi previews no foundation changes.
pulumi previewmust never propose create, update, or delete on the VPC, subnets, or bucket — those are read-only references. If it does, an import crept in where a state read belonged. - The resolved values match Terraform. Compare
pulumi stack output(or debug logging ofvpcId/tileBucket) againstterraform output. They must be identical; a mismatch means the state read is stale or pointed at the wrong key. - A foundation change propagates. Rename a non-critical foundation output value in a test environment, run the pipeline in order, and confirm the tile layer picks up the new value on
pulumi upwithout a manual edit — proving the reference is live, not copied. - The tile endpoint serves through the wired stack. Request a tile through the CloudFront distribution and assert a
200withimage/png, confirming the CDN origin resolved to the Terraform-owned bucket and the ECS service came up in the Terraform-owned subnets.
Preventing recurrence
Keep the boundary clean so the two engines do not drift into conflict:
- Ban cross-engine resource duplication in review. A code-review rule that rejects any Pulumi resource redeclaring a foundation-owned VPC, subnet, or bucket stops the double-ownership drift at merge. The boundary is: Terraform creates, Pulumi reads.
- Enforce pipeline ordering, do not rely on habit. The
needs:gate (or its equivalent) must be structural, so no one can run the tile layer before the foundation. Manual ordering fails the first time someone is in a hurry — formalize it per Pipeline Orchestration for Spatial Deployments. - Version the output contract. Treat the Terraform
outputsblock as a versioned interface; renaming or removing an output is a breaking change that must land with the matching Pulumi update in the same PR. - Scheduled cross-check. Run a nightly job that diffs
terraform outputagainst the values Pulumi last resolved and alerts on divergence, catching a foundation change that never triggered a tile-layer run.
Frequently asked questions
Does reading Terraform state into Pulumi import the resources?
No. A RemoteStateReference is a read-only view of the foundation’s outputs — Pulumi obtains values like the VPC ID and bucket name but never records the resources in its own checkpoint. It cannot plan to modify or destroy them, which is exactly what keeps the two engines from fighting over ownership. Importing, by contrast, would make Pulumi claim the resource.
Which engine should own which layer?
Let Terraform own the stable foundation — VPC, subnets, tile bucket — where declarative review and broad provider parity matter most, and let Pulumi own the algorithm-driven tile-serving layer where language loops and tests earn their keep. The rule is one-directional: the foundation exports outputs, the app layer consumes them, and no resource lives in both.
How do I stop the tile layer from consuming stale foundation outputs?
Enforce pipeline ordering structurally. Run terraform apply for the foundation first, gate the tile-layer job behind its success (a needs dependency or equivalent), and only then run pulumi up. Because Pulumi reads the state at plan time, a gated run always sees the outputs the just-completed apply wrote.
What happens if Terraform recreates a resource the Pulumi layer references?
The reference resolves to the new value on the next gated pulumi up, so the tile layer follows the change automatically — provided you exported the attribute as an output rather than hardcoding an ID in the Pulumi program. Hardcoded literals are the failure mode: they point at a resource that no longer exists and break at runtime, which is why every cross-boundary value must come through the state reference.
Related
- Terraform vs Pulumi for GIS: Choosing a Spatial IaC Engine — the trade-offs that lead teams to run both engines together.
- Pipeline Orchestration for Spatial Deployments — formalizing the stage ordering the two-engine pipeline depends on.
- State Backend Selection — isolating the two ledgers so the read-only boundary stays clean.
- Spatial IaC Architecture & Fundamentals — the architectural framework this integration sits within.