Migrate a Terraform Stack to Pulumi TypeScript

Dark code editor on a wide monitor showing TypeScript infrastructure code

Photo by Artur Shamsutdinov on Unsplash

To migrate a Terraform stack to Pulumi TypeScript, translate the HCL with pulumi convert --from terraform --language typescript, then adopt the live resources into a Pulumi stack, either with the import resource option or with pulumi import --from terraform on the state file. Run pulumi preview until it shows imports only, with no creates or replacements, then release the resources from Terraform with removed blocks.

This guide is for platform teams moving AWS infrastructure from Terraform to Pulumi so it can live in TypeScript next to the application code. Translating HCL is only half the job. The other half is state: Pulumi has to take ownership of resources that already exist, and Terraform has to let go without destroying them.

By the end you’ll have a Pulumi TypeScript program, a stack whose state lists every resource, a preview with no changes, and a Terraform state without them. The example is a small data layer: one S3 bucket (three Terraform resources) and one DynamoDB table.

Before you migrate a Terraform stack to Pulumi TypeScript

  • The Pulumi CLI and Node.js, plus a backend for state: Pulumi Cloud or a self-managed backend chosen with pulumi login.
  • AWS credentials that can read every resource you’re moving. Import reads each one.
  • A clean Terraform plan. terraform plan -detailed-exitcode should exit with 0, meaning nothing is pending.
  • An apply freeze on the Terraform configuration until the handover is complete.
  • A resource inventory from terraform state list, with the physical ID of each resource.

Pulumi’s own guide to migrating from Terraform to Pulumi covers other options too, including leaving some resources in Terraform and reading their outputs from Pulumi through the Terraform state functions. That’s a valid middle ground if you only want new work in Pulumi.

Two ways to move state: which one fits?

Convert, then import option pulumi import --from terraform
Code comes from Your HCL, via pulumi convert Generated from the state file
Keeps variables, references, structure Yes No: literal values
Resources in modules Converted with the rest Skipped with a warning; import them separately
Protected from deletion Only if you add protect: true Yes, by default
Best for Configurations you want to keep maintaining Flat stacks, or a quick first pass to compare against

The rest of this guide uses the first route, because the converted code is the code you’ll live with. The state-file route is covered after it.

The migration, step by step

  1. Freeze Terraform and back up stateConfirm the plan is clean and keep a local copy of the state.
  2. Convert the HCLRun pulumi convert in the Terraform directory and review the TypeScript it writes.
  3. Create the stackInitialise a stack and set the Region to match the Terraform provider.
  4. Add import IDsGive each resource the import option with its physical ID, and protect the stateful ones.
  5. Preview until it shows only importsFix arguments until nothing would be created, updated or replaced.
  6. Run pulumi up, then remove the import optionsA second preview must report no changes.
  7. Release the resources from TerraformApply removed blocks with destroy = false.

Steps 1 to 3: freeze, convert, create the stack

Terminal

# In the Terraform directory
terraform plan -detailed-exitcode
terraform state list
terraform state pull > pre-migration.tfstate

# Translate HCL to a Pulumi TypeScript project
pulumi convert --from terraform --language typescript --out ../orders-pulumi

cd ../orders-pulumi
npm install
pulumi login
pulumi stack init prod
pulumi config set aws:region us-east-1

pulumi convert translates source code only; it doesn’t read or move Terraform state. Read the output the way you’d read a colleague’s pull request. The free Terraform to Pulumi TypeScript converter is a second opinion for files that convert awkwardly, and it’s handy for single modules when you don’t want to set up a project first.

Step 4: add import IDs to the converted program

The import resource option tells Pulumi to adopt an existing resource with that ID instead of creating one. The ID is the same one terraform import would use: the bucket name for S3 resources, the table name for DynamoDB. After cleanup, the program looks like this:

index.ts

import * as aws from "@pulumi/aws";

const bucketName = "acme-orders-archive-123456789012";

const ordersArchive = new aws.s3.Bucket("orders-archive", {
  bucket: bucketName,
}, { import: bucketName, protect: true });

new aws.s3.BucketVersioning("orders-archive-versioning", {
  bucket: ordersArchive.id,
  versioningConfiguration: { status: "Enabled" },
}, { import: bucketName });

new aws.s3.BucketPublicAccessBlock("orders-archive-public-access", {
  bucket: ordersArchive.id,
  blockPublicAcls: true,
  blockPublicPolicy: true,
  ignorePublicAcls: true,
  restrictPublicBuckets: true,
}, { import: bucketName });

const orders = new aws.dynamodb.Table("orders", {
  name: "orders",
  billingMode: "PAY_PER_REQUEST",
  hashKey: "pk",
  rangeKey: "sk",
  attributes: [
    { name: "pk", type: "S" },
    { name: "sk", type: "S" },
  ],
  pointInTimeRecovery: { enabled: true },
}, { import: "orders", protect: true });

export const archiveBucket = ordersArchive.bucket;
export const ordersTableArn = orders.arn;

Warning: Always set the physical name (bucket, name) explicitly. If you leave it out, Pulumi auto-names the resource with a random suffix, the name no longer matches the live resource, and the preview plans a replacement instead of an import.

Steps 5 and 6: a preview that shows only imports

Terminal

pulumi preview --diff   # expect: 4 to import, nothing to create, update, replace or delete
pulumi up

# Delete every { import: ... } option from index.ts, keep protect, then:
pulumi preview          # expect: no changes

If the preview reports a difference on an import, your arguments don’t match the live resource. Change the code to the live value; don’t let Pulumi “fix” production during a migration. Differences usually come from defaults Terraform set silently, or from tags applied through the Terraform provider’s default_tags, which have to be declared in Pulumi too. Once the import options are gone, the program behaves like any other Pulumi program; leaving them in is harmless but confusing.

The state-file route: pulumi import –from terraform

For a flat configuration, Pulumi can read the state file directly, import every managed resource into the current stack, and write matching code:

Terminal

pulumi import --from terraform ./pre-migration.tfstate --out imported.ts

Imported resources are protected by default (--protect defaults to true), and resources inside Terraform modules are skipped with a warning. For resources you’d rather list by hand, the same command takes a JSON file with --file; the pulumi import command reference documents the format:

import.json

{
  "resources": [
    { "type": "aws:s3/bucket:Bucket", "name": "orders-archive", "id": "acme-orders-archive-123456789012" },
    { "type": "aws:s3/bucketVersioning:BucketVersioning", "name": "orders-archive-versioning", "id": "acme-orders-archive-123456789012" },
    { "type": "aws:s3/bucketPublicAccessBlock:BucketPublicAccessBlock", "name": "orders-archive-public-access", "id": "acme-orders-archive-123456789012" },
    { "type": "aws:dynamodb/table:Table", "name": "orders", "id": "orders" }
  ]
}

Generated code uses literal values everywhere. Either way, replace literals with references (ordersArchive.id) before the program becomes the long-term source of truth, and run pulumi preview after each edit.

Step 7: hand the resources over from Terraform

Only when Pulumi’s state holds every resource and the preview is clean does Terraform let go. Replace each moved resource block with a removed block:

release.tf

removed {
  from = aws_s3_bucket.orders_archive
  lifecycle {
    destroy = false
  }
}

removed {
  from = aws_s3_bucket_versioning.orders_archive
  lifecycle {
    destroy = false
  }
}

removed {
  from = aws_s3_bucket_public_access_block.orders_archive
  lifecycle {
    destroy = false
  }
}

removed {
  from = aws_dynamodb_table.orders
  lifecycle {
    destroy = false
  }
}

Run terraform plan and confirm nothing will be destroyed, then apply. Terraform versions before 1.7 don’t have removed blocks; there, delete the resource blocks and run terraform state rm for each address. From this point, only Pulumi changes these resources.

Permissions needed

Phase What the credentials need
Import and preview Read access to each resource type, such as s3:GetBucketVersioning, s3:GetBucketPublicAccessBlock, s3:ListBucket, dynamodb:DescribeTable, dynamodb:DescribeContinuousBackups, dynamodb:ListTagsOfResource
pulumi up with imports only The same reads; nothing is created or modified
Releasing from Terraform Access to the Terraform state backend; no AWS changes

The AWS provider reads more attributes than you’d expect, so a tight policy often fails on one Get* call. Our steps to troubleshoot an AWS IAM access denied error find the missing action, and the checklist to review a generated IAM policy for least privilege helps when you build the long-term deployment role for Pulumi.

Common mistakes and how to fix them

Symptom Cause and fix
Preview plans a replacement A physical name is missing or different, so auto-naming kicked in. Set bucket or name to the live value.
Preview plans a create The resource has no import option, or the stack’s Region differs from the resource’s Region. Check pulumi config get aws:region.
Import fails: resource not found Wrong ID format for the type. Use the ID terraform import uses for the same resource.
Module resources missing after --from terraform The state-file importer skips modules. Import them with a JSON file or the import option.
pulumi destroy refuses to delete The resource is protected, as intended. Remove protect deliberately if you really mean it.
Terraform plan wants to destroy A resource block was deleted without a removed block. Restore it and add the block.

If something behaves differently after the move, compare with what’s actually deployed. You can list AWS resources with natural language from your terminal, and troubleshooting AWS infrastructure with an AI CLI walks through narrowing down what changed.

Where ChatWithCloud fits, and its limits

The Terraform to Pulumi TypeScript converter is one of 19 free AI code converters for AWS SDK and IaC code. It translates pasted HCL and adds comments where there’s no direct equivalent. It doesn’t read your state, add import IDs or run Pulumi, so the steps above are still yours, and output must be reviewed and tested. Check the free converter limits for large Terraform files (60,000 characters per conversion) and what happens to code pasted into an AI converter before converting a whole repository.

Still weighing Pulumi against CDK or staying on Terraform? The trade-offs in AWS CDK vs Terraform for existing AWS stacks apply to Pulumi too, since it also keeps its own state. If part of your estate is still CloudFormation, the guide to import CloudFormation resources into Terraform shows the retain-and-release pattern that works for any tool change. If you end up choosing CloudFormation’s engine instead, see how to migrate a Terraform stack to AWS CDK TypeScript or migrate a Terraform stack to CloudFormation without downtime.

Frequently asked questions

Does pulumi convert migrate Terraform state?

No. It converts HCL source to a Pulumi program. State moves separately, through the import resource option, pulumi import --file, or pulumi import --from terraform with the state file.

Can I migrate a Terraform stack to Pulumi TypeScript without downtime?

Yes. Importing only records existing resources in Pulumi state, and a removed block with destroy = false only edits Terraform state. The resources keep running throughout.

Can Terraform and Pulumi manage resources in the same AWS account?

Yes, as long as each resource has one owner. Pulumi can read outputs from Terraform state, so new Pulumi code can depend on resources Terraform still manages.

Should imported resources stay protected?

For stateful resources such as buckets and tables, yes. Protection makes pulumi destroy and accidental deletions fail until you remove it on purpose.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud