// Stay in touch?
Products
Encore CloudEncore Cloud
Encore.tsEncore.ts
Encore.goEncore.go
PricingPricing
Book a DemoBook a Demo
Use Cases
AI-Powered DevelopmentAI-Powered Development
Event-Driven SystemsEvent-Driven Systems
Distributed SystemsDistributed Systems
Case StudiesCase Studies
ShowcaseShowcase
Resources
DocsDocs
InstallInstall
Example AppsExample Apps
Demo videoDemo video
ArticlesArticles
GitHub ReleasesGitHub Releases
Systems Operational
Company
About UsAbout Us
Swag ShopSwag Shop
ContactContact
JobsJobs
PressPress
TermsTerms
Privacy PolicyPrivacy Policy
Data Processing AgreementData Processing Agreement
Enterprise SLAEnterprise SLA
Encore
© 2026 EncoreAll rights reserved
© 2026 Encore All Rights Reserved
GitHubDiscordYouTube

How to Migrate from Terraform to Pulumi

A step-by-step migration path that imports existing resources instead of recreating them

07/08/26
10 Min Read
Ivan Cernja
07/08/26

How to Migrate from Terraform to Pulumi

A step-by-step migration path that imports existing resources instead of recreating them

Ivan Cernja
10 Min Read

HCL isn't broken, and that's rarely why teams leave it. They leave because they want loops, conditionals, real functions, and their existing language's test tooling around infrastructure, without keeping a separate mental model for the IaC layer. That's a reasonable goal. The risk lives in the migration itself: get the sequencing wrong and you recreate a production database instead of adopting it.

This guide walks the safe path. Assess what you have, convert the HCL to a Pulumi program, import the live resources into Pulumi state so nothing is destroyed, cut over module by module, and verify each step with pulumi preview before anything applies. The commands are real and the gotchas are the ones that bite.

What Changes When You Migrate

Terraform and Pulumi solve the same problem with different materials. Both build a resource graph, diff desired state against a state file, and apply the delta. The differences that matter for a migration:

TerraformPulumi
LanguageHCL (declarative DSL)TypeScript, Python, Go, C#, Java
StateState file (local or remote backend)State managed by Pulumi Cloud or a self-hosted backend (S3, GCS, Azure Blob)
ProvidersNative + registryBridged from Terraform providers, plus native ones
SecretsMarked sensitive, stored plaintext in state unless encryptedEncrypted per-stack by default
Program structureModulesFunctions, classes, ComponentResources
Loops / conditionalscount, for_each, ternariesNative language for, if, map

The provider bridge is the important line. Pulumi's AWS, GCP, and Azure providers are generated from the same upstream Terraform providers, so resource coverage and behavior are close to identical. An aws_s3_bucket in Terraform maps to aws.s3.Bucket in Pulumi, with the same underlying API calls. That parity is what makes a non-destructive import possible.

Step 1: Assess Your Existing Terraform Setup

Inventory what you have before converting a line.

# List every resource Terraform currently manages. terraform state list # Count them by type to size the effort. terraform state list | sed 's/\..*//' | sort | uniq -c | sort -rn

Answer three questions with that output:

  • Which providers are in play? Check each one against Pulumi's registry. AWS, GCP, Azure, Cloudflare, Kubernetes, Datadog all have parity. An in-house or rarely-used provider might not, and that's a decision to make now, not halfway through.
  • How is state stored? Local terraform.tfstate, an S3 backend, or Terraform Cloud. This determines how you extract state during import and who owns state after the cutover.
  • Where are the module boundaries? These become your migration units. If your Terraform is one giant root module with no separation, draw the boundaries first. You want to move networking, then data stores, then compute, as independent steps.

Do not attempt a big-bang rewrite of a large estate. The reliable approach is incremental, one module or stack at a time, with Terraform still owning everything you haven't moved yet.

Step 2: Convert the HCL

Pulumi ships a converter that reads your Terraform and emits a Pulumi program in your chosen language.

# Install the converter plugin (one time). pulumi plugin install converter terraform # From inside your Terraform directory, convert to TypeScript. pulumi convert --from terraform --language typescript --out ../pulumi-app

Point it at a directory containing your .tf files. It resolves variables, locals, and module references, and produces a Pulumi project (Pulumi.yaml, an entrypoint, and a package.json for TypeScript).

Given this Terraform:

resource "aws_s3_bucket" "uploads" { bucket = "myapp-uploads-prod" } resource "aws_s3_bucket_versioning" "uploads" { bucket = aws_s3_bucket.uploads.id versioning_configuration { status = "Enabled" } }

The converter produces roughly this TypeScript:

import * as aws from "@pulumi/aws"; const uploads = new aws.s3.Bucket("uploads", { bucket: "myapp-uploads-prod", }); const uploadsVersioning = new aws.s3.BucketVersioningV2("uploads", { bucket: uploads.id, versioningConfiguration: { status: "Enabled", }, });

Gotcha: the output is a literal, line-by-line translation. It works, but it won't use loops where you had for_each, and it won't factor shared config into functions. Resist the urge to refactor immediately. Get the converted program importing and previewing clean first, then refactor into ComponentResource classes and loops once the state is stable. Refactoring and importing at the same time makes a broken preview impossible to diagnose.

Step 3: Import live resources into Pulumi state

This is the step that decides whether the migration is safe. The converted program describes your infrastructure, but Pulumi's state is empty, so a naive pulumi up would try to create everything you already have. pulumi import tells Pulumi that a resource in the program already exists in the cloud, and writes it into state instead.

For a single resource:

# pulumi import <type> <name> <cloud-id> pulumi import aws:s3/bucket:Bucket uploads myapp-uploads-prod

Pulumi reads the live resource, writes it into your stack's state, and prints the code it expects. Match your program to that output so the next preview is clean.

For a real estate, import in bulk from a JSON file instead of one command per resource:

{ "resources": [ { "type": "aws:s3/bucket:Bucket", "name": "uploads", "id": "myapp-uploads-prod" }, { "type": "aws:ec2/vpc:Vpc", "name": "main", "id": "vpc-0abc123" }, { "type": "aws:rds/instance:Instance", "name": "primary", "id": "myapp-prod-db" } ] }
pulumi import --file resources.json

You can generate most of that file from terraform state list plus the resource IDs already in your Terraform state (terraform show -json gives you both the addresses and the cloud IDs to script the mapping).

Gotchas that bite here:

  • Protect stateful resources first. Before importing anything with data (RDS, DynamoDB, S3 with objects), set protect: true on it or run pulumi state protect after import, so a mistaken up can't delete it.
  • Import IDs are provider-specific. An S3 bucket imports by name, an RDS instance by identifier, an IAM role by name, a security group rule by a composite string. When in doubt, the Pulumi registry page for each resource type documents its import format.
  • Some resources have no drift-free representation. A few Terraform resources map to sub-resources or default settings that Pulumi models differently (default VPC routes, inline vs. standalone rules). These show up as diffs in the next step and need the program adjusted by hand.

Step 4: Preview until it reports zero changes

After importing, run a preview. This is the safety gate. A clean migration means Pulumi sees no difference between your program and the real infrastructure.

pulumi preview --diff

You want output that says no creates, no replaces, no deletes. If you see:

  • Updates on imported resources: your program's properties don't exactly match the live resource. Adjust the code to match reality (the imported state is the source of truth), not the other way around.
  • Replaces: dangerous. A property Pulumi treats as forcing replacement doesn't match. Do not run up. Fix the program so the property matches, or add an ignoreChanges in the resource options if it's a field you don't manage.
  • Creates: you missed an import. The resource exists in the cloud but not in Pulumi state. Import it.
  • Deletes: your program is missing a resource that Terraform created. Add it and import it.

Only when pulumi preview reports zero changes is it safe to consider that module migrated. Run pulumi up once to make Pulumi the acting owner of state, even though it changes nothing, so subsequent applies work from Pulumi's state.

Step 5: Hand off ownership and remove from Terraform

Now two tools believe they own the same resources. That's the most dangerous state in the whole process, because a terraform apply and a pulumi up will fight. Resolve it immediately by removing the migrated resources from Terraform's management.

# Remove from Terraform state WITHOUT destroying the real resource. terraform state rm aws_s3_bucket.uploads terraform state rm aws_s3_bucket_versioning.uploads

terraform state rm deletes the entry from Terraform's state file only. The cloud resource is untouched, and it's now owned solely by Pulumi. Do this at a clean boundary. Move a whole module out of Terraform, not half of one, so you never have a module where some resources are Terraform's and some are Pulumi's.

If any Terraform code still references those resources (a data source, an output, a depends_on), rewire it before the next Terraform run, or that run will error on missing state.

Step 6: Cut over incrementally

Repeat steps 2 through 5 per module. A workable order for most estates:

  1. Leaf resources with no dependents (an isolated S3 bucket, a standalone SQS queue). Low risk, builds confidence in the import workflow.
  2. Networking (VPC, subnets, security groups) once you trust the process, since everything depends on it.
  3. Data stores (RDS, DynamoDB, ElastiCache) with protect set, carefully.
  4. Compute and orchestration (ECS, Lambda, EKS) last, since they reference everything above.

During cutover, cross-tool references are the hard part. If a Pulumi-managed VPC needs to be referenced by a still-Terraform-managed subnet, you can read the value with a Terraform data source or a Pulumi StackReference in the other direction. Minimize these by moving dependency clusters together rather than splitting them across the boundary.

When to migrate, and when not to

Migrate to Pulumi when:

  • Your team already writes TypeScript, Python, or Go, and context-switching to HCL is a genuine cost.
  • You have real abstraction needs (dynamic resource counts, complex conditionals, shared component libraries) that count and for_each express awkwardly.
  • You want to unit-test infrastructure logic with your existing test framework.
  • Secrets handling in plaintext state is a compliance problem, and Pulumi's per-stack encryption solves it cleanly.

Stay on Terraform when:

  • Your HCL is stable, understood by the team, and not the thing slowing you down. A migration has real cost and no payoff if the current setup isn't hurting.
  • You depend on a provider with no Pulumi equivalent, or on HCP Terraform-specific workflows (Terraform Stacks) with no direct analog. Note that Sentinel-style policy-as-code does have a Pulumi analog in CrossGuard.
  • Your organization has standardized tooling, policy, and hiring around Terraform, and the switching cost outweighs the ergonomic gain.
  • You're mid-incident or in a freeze. Migrate when the estate is calm.

Pulumi's ergonomics are better if you live in a general-purpose language, and the import path is solid enough that a careful migration is low-risk. But Terraform's declarative simplicity is a feature for teams who want infrastructure to be boring and diff-able, and HCL's constraints prevent a class of clever-but-unmaintainable code that a real language invites. Neither choice is wrong.

When the Goal Is Less Infrastructure Code

Worth naming a third option, because a fair number of Terraform-to-Pulumi migrations are chasing the wrong target. If the actual frustration is how much infrastructure code you maintain alongside a backend, switching HCL for TypeScript gives you a nicer language for the same volume of config. It doesn't shrink the config.

Encore takes a different route. You declare the infrastructure your application needs (Postgres, Pub/Sub, cron jobs, object storage) directly in your TypeScript or Go backend code, and Encore Cloud provisions the real resources in your own AWS or GCP account. There's no separate IaC program and no separate state file to reconcile, because the application code is the source of truth for both behavior and infrastructure.

import { SQLDatabase } from "encore.dev/storage/sqldb"; import { Bucket } from "encore.dev/storage/objects"; // Provisions managed Postgres (RDS on AWS, Cloud SQL on GCP). const db = new SQLDatabase("users", { migrations: "./migrations" }); // Provisions an S3 / GCS bucket. export const uploads = new Bucket("uploads", { versioned: true });

This is not a replacement for Terraform or Pulumi in every case. Teams managing large VM fleets, deep networking, or IAM at scale will keep a general-purpose IaC tool. What it removes is the config sprawl that accumulates around an application backend, which is often the thing a Pulumi migration is trying to fix. Encore is open source (around 12k GitHub stars) and used in production by teams including Groupon.

Deploy with Encore

Skip the IaC layer for your backend. Deploy a TypeScript service with Postgres and object storage to your own AWS or GCP account.

Deploy

Related resources

  • Terraform vs Pulumi
  • Pulumi alternatives
  • Infrastructure as Code with TypeScript
  • Encore vs Terraform, CDK, Pulumi, and SST

Frequently asked questions

Can Pulumi import my existing Terraform state?

Yes. `pulumi convert --from terraform` translates your HCL to a Pulumi program, and `pulumi import` (or bulk import from a JSON file) adopts the live resources into Pulumi state without recreating them. You migrate the code and the state adoption separately.

Will migrating from Terraform to Pulumi recreate my infrastructure?

It should not, if you import correctly. The safe order is: convert the code, import each resource so Pulumi's state points at the real cloud resource, then run `pulumi preview` and confirm it reports zero changes before you ever run `pulumi up`.

Can Terraform and Pulumi manage the same resources at the same time?

Not safely. Once a resource is imported into Pulumi, remove it from Terraform's management (`terraform state rm`) so two tools don't both try to own it. Split ownership at the module or stack boundary, never per-resource inside one module.

Does Pulumi support the same providers as Terraform?

Mostly. Pulumi bridges the Terraform provider ecosystem, so AWS, GCP, Azure, and most common providers have parity. Check any niche or in-house provider before you commit, since a few Terraform-only providers have no Pulumi equivalent.

How long does a Terraform to Pulumi migration take?

For a single small stack, an afternoon. For a large multi-module estate, plan for weeks of incremental cutover, one module at a time, with both tools coexisting until each module is fully moved.

Should I rewrite my Terraform in Pulumi by hand or convert it?

Convert first with `pulumi convert`, then clean up. The converter gets you a working program quickly, but the output is a literal translation. Refactor into functions, components, and loops afterwards, once the imported state is verified stable.

Contents
What Changes When You Migrate
Step 1: Assess Your Existing Terraform Setup
Step 2: Convert the HCL
Step 3: Import live resources into Pulumi state
Step 4: Preview until it reports zero changes
Step 5: Hand off ownership and remove from Terraform
Step 6: Cut over incrementally
When to migrate, and when not to
When the Goal Is Less Infrastructure Code
Related resources
Encore

The backend platform for humans and agents

Build backends in Go or TypeScript and run them on your own AWS or GCP, with the guardrails your team and AI agents need to ship safely.

Ready to build your next backend?

Encore is the Open Source framework for building robust type-safe distributed systems with declarative infrastructure.