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.
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:
| Terraform | Pulumi | |
|---|---|---|
| Language | HCL (declarative DSL) | TypeScript, Python, Go, C#, Java |
| State | State file (local or remote backend) | State managed by Pulumi Cloud or a self-hosted backend (S3, GCS, Azure Blob) |
| Providers | Native + registry | Bridged from Terraform providers, plus native ones |
| Secrets | Marked sensitive, stored plaintext in state unless encrypted | Encrypted per-stack by default |
| Program structure | Modules | Functions, classes, ComponentResources |
| Loops / conditionals | count, for_each, ternaries | Native 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.
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:
terraform.tfstate, an S3 backend, or Terraform Cloud. This determines how you extract state during import and who owns state after the cutover.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.
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.
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: true on it or run pulumi state protect after import, so a mistaken up can't delete it.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:
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.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.
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.
Repeat steps 2 through 5 per module. A workable order for most estates:
protect set, carefully.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.
Migrate to Pulumi when:
count and for_each express awkwardly.Stay on Terraform when:
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.
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.
Skip the IaC layer for your backend. Deploy a TypeScript service with Postgres and object storage to your own AWS or GCP account.
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.
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`.
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.
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.
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.
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.