Terraform is a proven infrastructure as code tool with a large provider and module ecosystem. Many teams choose Pulumi when they want to keep that infrastructure as code model, but write and maintain infrastructure with general-purpose programming languages, familiar package managers, IDEs, testing, and software engineering patterns, while still understanding the refactoring tradeoffs in Terraform’s own module refactoring guidance.
Why choose Pulumi over Terraform? Pulumi’s language SDKs let teams define cloud infrastructure in TypeScript, Python, Go, C#, Java, or YAML while adding first-class workflows for refactoring with Pulumi aliases, secrets, protect, retainOnDelete, deleteBeforeReplace, replaceOnChanges, provider resources, Pulumi stacks, testing, and incremental migration with pulumi import. Pulumi does not remove every hard problem in cloud infrastructure, but it gives teams stronger tools for many day-to-day pain points.
Executive summary
Pulumi is often a better fit when infrastructure code needs to behave like application code: reviewed, tested, packaged, refactored, and shared across teams. The biggest advantages show up when teams need safer refactors, encrypted secret values, reusable components, clearer provider resources, and guardrails around destructive changes.
The tradeoff is important: Pulumi is still an infrastructure as code engine. Provider bugs, cloud API eventual consistency, drift, preview-time unknowns, and poorly designed abstractions still require engineering discipline. The advantage is not magic. The advantage is a stronger programming model and a more familiar developer workflow.
| Need | Terraform pattern | Pulumi advantage | What still needs care |
|---|---|---|---|
| Languages and tooling | HCL plus Terraform-specific functions and expressions | Pulumi supports general-purpose languages and normal IDE, test, and package workflows | Teams still need code review and shared conventions |
| Refactoring | Moved blocks or state commands for resource identity changes | Pulumi aliases can map old resource identities to new ones during refactors | Aliases must model the old identity correctly |
| Secrets | Sensitive values can still require careful state and plan handling | Pulumi tracks secrets and encrypts secret values in state | Secrets are still available to code at runtime |
| Lifecycle safety | Lifecycle meta-arguments and plan review | Pulumi resource options such as protect, retainOnDelete, deleteBeforeReplace, and replaceOnChanges make intent explicit | Provider behavior and replacement semantics still matter |
| Environments | Workspaces or separate configurations | Pulumi stacks model environments with per-stack config, secrets, history, and outputs | Stack boundaries still need thoughtful design |
| Code reuse | Modules and HCL composition patterns | Pulumi components and packages use normal language abstractions | Over-abstracted components can become hard to use |
| Imports and migration | Import blocks, generated config, and state operations | pulumi import and migration tooling support gradual adoption |
Imported code still needs review and cleanup |
| Provider wiring | Provider inheritance and aliases inside modules | Explicit provider resources make multi-region and multi-account wiring visible in code review | Provider versions and bugs can still affect deployments |
| Testing | Validation, plan review, and external test harnesses | Pulumi programs can use normal unit and integration test frameworks | Tests complement previews, they do not replace them |
| Caveats | Declarative planning still has unknowns and drift | Pulumi improves the workflow around many pain points | It does not eliminate drift, provider bugs, or eventual consistency |
Use programming languages and familiar tools
Terraform modules are powerful, but larger HCL codebases can require teams to maintain separate conventions for composition, validation, and reuse. Pulumi lets infrastructure teams use the features of whichever supported programming language they choose, such as classes, functions, types, loops, package managers, linters, and test frameworks.
For example, a platform team can wrap a standard storage pattern in a ComponentResource and share it like any other TypeScript abstraction:
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
interface WorkQueueArgs {
visibilityTimeoutSeconds: number;
}
class WorkQueue extends pulumi.ComponentResource {
public readonly queueUrl: pulumi.Output<string>;
constructor(name: string, args: WorkQueueArgs, opts?: pulumi.ComponentResourceOptions) {
super("platform:queue:WorkQueue", name, {}, opts);
const queue = new aws.sqs.Queue(`${name}-queue`, {
visibilityTimeoutSeconds: args.visibilityTimeoutSeconds,
}, { parent: this });
this.queueUrl = queue.url;
this.registerOutputs({ queueUrl: this.queueUrl });
}
}
const jobs = new WorkQueue("image-jobs", {
visibilityTimeoutSeconds: 60,
});
Refactor infrastructure without surprise replacement
Renaming a resource, moving it into a component, or reorganizing a project should not automatically mean replacing production infrastructure. Pulumi aliases let you tell Pulumi how a resource used to be addressed, so refactors can preserve resource identity when the old and new resources represent the same cloud object.
const queue = new aws.sqs.Queue("app-jobs", {
visibilityTimeoutSeconds: 60,
}, {
aliases: [{ name: "jobs" }],
});
Aliases are not a substitute for review. They work when the old identity is modeled correctly, including details such as name, parent, type, project, and stack when those changed.
Handle secrets with encrypted configuration and secret outputs
Secrets are one of the most common places where infrastructure workflows become risky. Terraform has sensitive values and backend guidance, but teams still need to understand how plans, state, outputs, and backend access interact. Pulumi treats secrets as first-class values, encrypts them in state, and preserves secrecy as values flow through outputs.
const config = new pulumi.Config();
const dbPassword = config.requireSecret("dbPassword");
const passwordParameter = new aws.ssm.Parameter("db-password", {
type: "SecureString",
value: dbPassword,
});
This improves the default experience, but it’s not runtime isolation. In the TypeScript SDK, config.requireSecret("dbPassword") retrieves secret configuration, and your program can still access the decrypted value while it runs, so reviews, least privilege, and secret-provider choices still matter. If a value starts as a plain input instead of coming from requireSecret, pulumi.secret(...) can mark it as secret.
Add safer lifecycle controls for destructive changes
Infrastructure mistakes are expensive when they replace or delete stateful resources. Pulumi gives teams explicit resource options for safety-sensitive intent, such as protect, retainOnDelete, deleteBeforeReplace, and replaceOnChanges.
const database = new aws.rds.Instance("orders-db", {
instanceClass: "db.t4g.micro",
allocatedStorage: 20,
engine: "postgres",
username: "app",
password: dbPassword,
}, {
protect: true,
retainOnDelete: true,
});
These controls are guardrails, not guarantees. Provider bugs, cloud API eventual consistency, and replacement behavior still require preview review and operational judgment. replaceOnChanges applies to custom resources only, not component resources.
Model environments with stacks
Terraform workspaces can represent environments, but many teams eventually need stronger boundaries for configuration, secrets, history, and cross-environment outputs. Pulumi stacks make environment boundaries explicit and pair them with per-stack config and outputs.
const networking = new pulumi.StackReference("acme/networking/prod");
const vpcId = networking.requireOutput("vpcId");
const appSecurityGroup = new aws.ec2.SecurityGroup("app-sg", {
vpcId,
ingress: [{
protocol: "tcp",
fromPort: 443,
toPort: 443,
cidrBlocks: ["10.0.0.0/8"],
}],
egress: [{
protocol: "-1",
fromPort: 0,
toPort: 0,
cidrBlocks: ["0.0.0.0/0"],
}],
});
Cross-stack references are cleaner than sharing an entire remote state file, but they are still dependencies. Keep stack outputs intentional and avoid turning one stack into a global variable bag.
Test infrastructure with normal language tooling
Because Pulumi programs are real programs, infrastructure testing can use familiar test runners and assertions. Teams can test component defaults, naming conventions, required tags, and policy assumptions before a deployment reaches production.
pulumi.runtime.setMocks({
newResource: (args) => ({
id: `${args.name}_id`,
state: args.inputs,
}),
call: (args) => args.inputs,
});
Testing does not replace previews. It catches a different class of problems: broken abstractions, missing required inputs, invalid defaults, and regressions in shared components.
Wire providers explicitly
Provider configuration is another place where explicit code can reduce ambiguity. In Terraform, provider inheritance and aliases are often managed across module boundaries. In Pulumi, provider resources are normal resources that can be passed through resource options, which makes multi-region or multi-account deployments easier to follow in code review.
const west = new aws.Provider("west", {
region: "us-west-2",
});
const east = new aws.Provider("east", {
region: "us-east-1",
});
const primary = new aws.sqs.Queue("primary-jobs", {}, {
provider: west,
});
const replica = new aws.sqs.Queue("replica-jobs", {}, {
provider: east,
});
The provider object does not remove provider versioning or schema-change risk, but it makes provider wiring visible in the same language and review flow as the rest of the program.
Import and migrate incrementally
Teams rarely get to rebuild infrastructure from scratch. Pulumi supports incremental adoption with pulumi import, generated code, and Terraform interoperability paths. That makes it possible to start with one resource, one component, or one stack instead of forcing a big-bang migration.
Pulumi also supports interoperability paths for teams that need to bring existing Terraform assets forward over time, including Terraform provider access and migration workflows. That makes migration an engineering sequence, not an all-or-nothing rewrite.
const importedRepository = new aws.ecr.Repository("app", {
name: "existing-app-repo",
}, {
import: "existing-app-repo",
});
The CLI flow can also generate declarations with pulumi import. Generated code is a starting point, not a finished architecture. Review names, options, providers, secrets, and component boundaries before treating imported resources as production-ready Pulumi code.
What Pulumi does not magically fix
Pulumi does not eliminate cloud API eventual consistency. A deployment can finish successfully while downstream reads, controllers, or other operators still see stale state for a short window. That is a property of the cloud control plane, not of the IaC tool.
Pulumi also does not eliminate provider bugs or drift. If a provider has a schema issue, a bad default, or a flaky update path, Pulumi still has to ride that provider behavior. Likewise, if something changes outside Pulumi, you still need to detect it and reconcile it with refresh or another drift-management process.
Pulumi does not eliminate preview-time unknowns either. Some values are not known until deployment, so the plan can still contain uncertainty. Bad project decomposition and side-effect-heavy deployment code remain risks too, which is why testing, clear stack boundaries, and disciplined component design still matter.
OpenTofu compatibility caveat
OpenTofu users face many of the same infrastructure-as-code concerns around state, providers, drift, secrets, lifecycle behavior, and migration planning. This post stays Terraform-focused because that is where most teams begin the Pulumi comparison, but the same Pulumi capabilities are relevant when evaluating OpenTofu alternatives or hybrid migration paths.
Pulumi can also work with Terraform provider ecosystems, including long-tail providers that may not have a native Pulumi package yet. Treat that as an interoperability path, not a promise that every Terraform or OpenTofu workflow maps one-for-one without design work.
Get started with Pulumi
If your team is evaluating infrastructure as code options, start with the workflow that creates the most leverage: write infrastructure in the language your team already uses, test shared components, protect critical resources, and migrate one stack at a time.
To go deeper, get started with Pulumi or read the Terraform migration guide.
