Terraform's HCL is a fine configuration language for describing a fixed set of resources. It becomes an awkward programming language the moment you need a loop with real logic, a helper function shared across modules, or a unit test. Pulumi's pitch is simple: write infrastructure in TypeScript, Python, Go, or C#, and get the language's actual features — not a DSL that approximates them — for the parts of IaC that are genuinely code.
What a real language buys you that HCL doesn't
HCL has for_each and count, conditional expressions, and locals — it's gained a lot of programming-language shape over the years. But it still doesn't have real functions you can unit test, a type checker that runs before you touch a cloud API, or a package manager for sharing logic across teams beyond copy-pasting modules. In Pulumi, provisioning fifteen near-identical S3 buckets with slightly different lifecycle rules is a for loop over an array of config objects, and you can write a Jest test that asserts your bucket-naming function produces valid names before you ever run pulumi up. That's a genuine capability gap, not a stylistic preference — it's the difference between a config file and a program.
State and backend model
Pulumi's state model looks a lot like Terraform's at a glance — a JSON state file tracking real resource IDs against your declared resources — but the default backend is Pulumi's own SaaS (pulumi.com), which stores state, encrypts secrets, and locks concurrent updates for you out of the box. If you don't want a third-party service holding your state, you can self-host: point Pulumi at an S3 bucket, Azure Blob Storage, or a local file backend instead. Locking and encryption still work, you just own the infrastructure for it. Functionally this is close to Terraform + a remote backend (S3 + DynamoDB for locking), just with a hosted option that's less assembly required.
import * as aws from "@pulumi/aws";
const bucket = new aws.s3.BucketV2("reports-bucket", {
bucket: "acme-reports-prod",
});
new aws.s3.BucketVersioningV2("reports-versioning", {
bucket: bucket.id,
versioningConfiguration: { status: "Enabled" },
});
const publicAccessBlock = new aws.s3.BucketPublicAccessBlock("reports-pab", {
bucket: bucket.id,
blockPublicAcls: true,
blockPublicPolicy: true,
ignorePublicAcls: true,
restrictPublicBuckets: true,
});
export const bucketName = bucket.bucket;
That's the whole program: no HCL provider block, no separate variables file — just imports, a couple of resource constructors, and an export. `pulumi up` diffs it against state and shows you the plan before applying, the same workflow as `terraform plan` / `apply`.
Testing infrastructure code like actual code
Because Pulumi programs are just TypeScript, ordinary tooling applies: ESLint catches mistakes before you run anything, your editor's type checker flags a typo'd property name on a resource, and Pulumi's own unit-testing mocks let you assert "this program creates exactly one public-access-blocked bucket" without touching AWS. None of that is exotic — it's just what you already get writing application code, applied to infrastructure for the first time.
Pulumi CrossGuard (or plain Open Policy Agent) lets you write policies like "no S3 bucket may be public" that run against every pulumi up, in the same language as your infra. It's the guardrail for the mistakes a type checker can't catch — a correctly-typed bucket that's still misconfigured.
The honest tradeoffs
Terraform's ecosystem is bigger: more providers with mature coverage, more Stack Overflow answers, more engineers who already know HCL, and a registry of modules that dwarfs Pulumi's. Pulumi's providers are largely generated from the same Terraform provider schemas, so coverage is close but you'll occasionally hit a resource or property that Terraform supports and Pulumi's provider hasn't caught up on yet. And the language-power argument cuts both ways: a for-loop generating dynamic resource counts is powerful and also exactly the kind of "clever" abstraction that makes infrastructure hard to reason about at 2am during an incident. HCL's restrictions are partly a feature — they keep most Terraform configs boring and readable by design; Pulumi trusts you not to write a metaprogramming factory for your VPCs just because you can.
The temptation with a real language is to build a generic createMicroservice() function on day one. Write the plain resource declarations for the first one or two services, and only extract a shared function once you can see what actually varies between them. A premature abstraction in infra code is as expensive to unwind as one in application code — arguably more, because it's provisioning real, billable resources.
Wrapping up
Pulumi's real advantage isn't syntax preference, it's that infrastructure code gets access to loops, functions, types, and tests the same way application code does — closing a capability gap HCL only partially fills. The cost is a smaller ecosystem and a bit less battle-testing on the edges, plus the discipline to not over-abstract just because the language lets you. If your team already thinks in TypeScript or Python and your provider needs are mainstream (AWS, Azure, GCP, Kubernetes), that trade is usually worth it. If you're deep in Terraform already with modules that work, this isn't a rewrite-it-now case.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.