Terraform works until it doesn't, try Pulumi
Years of HCL taught me that real languages beat DSLs. Pulumi proves it.
I spent years writing Terraform. Maintaining it. Patching it. I watched HCL evolve, saw Terraform become the band-aid on a gaping wound, then witnessed the OpenTofu split.
In real projects, Terraform becomes a pile of workarounds. External scripts, custom modules, cascading locals, hacks to do what the language can’t express natively.
HCL works for two or three resources. Push it further and you drown in boilerplate.
The real problems
Try processing data before creating resources. You can’t call APIs, you can’t parse JSON from external sources, you can’t validate business logic. You’re limited to what HCL’s functions provide.
Need to create resources based on runtime calculations? Use terraform_data with provisioners and external scripts. You’re now mixing imperative scripts into your declarative config.
Want to share logic between modules? Create a module. But modules can’t return computed values easily, they don’t compose well, and you end up with output chains that break when anything changes.
Conditional resources? You’re stuck with count = var.enabled ? 1 : 0 everywhere. And when you switch between count and for_each, Terraform wants to destroy and recreate everything because the addressing changed.
The language forces you into patterns that HCL wasn’t designed for. You end up with locals blocks doing string manipulation, templatefile calls that should be functions, and data sources that hit external scripts because HCL can’t express the logic.
Testing is a joke
Want to test your logic? Terratest. Run a full Terraform apply in your test suite just to verify your count expression works.
No unit tests. No way to validate logic without deploying real infrastructure. You write code blind and hope it works when you apply.
I’ve debugged for_each expressions by running terraform plan repeatedly, adjusting variables, and guessing what went wrong. That’s not engineering, that’s trial and error.
Pulumi is actual code
With Pulumi, you write TypeScript, Python, or Go. Real languages with real tooling.
Same infrastructure, TypeScript:
const instances = config.environment === 'prod'
? [1, 2, 3]
: [1];
instances.forEach(i => {
new aws.ec2.Instance(`app-${i}`, {
ami: config.amiId,
instanceType: config.environment === 'prod' ? 't3.medium' : 't3.micro',
ebsBlockDevices: config.volumes.map(v => ({
deviceName: v.device,
volumeSize: v.size,
})),
});
}); No for_each. No dynamic blocks. Just loops and conditionals like you’d write anywhere else.
Want to extract logic? Write a function:
function createInstance(name: string, size: string, volumes: Volume[]) {
return new aws.ec2.Instance(name, {
ami: config.amiId,
instanceType: size,
ebsBlockDevices: volumes.map(v => ({
deviceName: v.device,
volumeSize: v.size,
})),
});
}
const prod = ['app-1', 'app-2', 'app-3'];
const instances = prod.map(name =>
createInstance(name, 't3.medium', config.volumes)
); Try doing that in HCL. You can’t. Modules come close but they’re heavyweight and clunky.
Real testing
Pulumi code is testable like any other code:
import * as pulumi from '@pulumi/pulumi';
import { createInstance } from './infra';
pulumi.runtime.setMocks({
newResource: (args) => ({ id: 'mock-id', state: args.inputs }),
call: (args) => args.result,
});
describe('createInstance', () => {
it('creates correct instance type for prod', async () => {
const instance = createInstance('test', 't3.medium', []);
const type = await instance.instanceType;
expect(type).toBe('t3.medium');
});
}); Unit tests. No infrastructure deployed. Fast feedback. You validate logic before applying anything.
State and backends work the same
Pulumi has state. It stores it in backends just like Terraform. S3, Azure Blob, the Pulumi service, or self-hosted.
The difference is you don’t need Terragrunt to manage it. Pulumi stacks handle environments natively:
pulumi stack init dev
pulumi stack init prod
pulumi stack select dev
pulumi up Each stack has its own state. No separate state files per workspace, no complex backend configs, no wrapper tools.
‘But I have to learn a programming language’
No you don’t. Pulumi supports YAML if that’s what you want. Same declarative syntax, better tooling.
resources:
bucket:
type: aws:s3:Bucket
properties:
website:
indexDocument: index.html Works exactly like you’d expect. You get the Pulumi benefits without writing code.
But here’s the thing: DevOps engineers already know how to code. SysAdmins aren’t idiots who only understand YAML. If you’re managing infrastructure at scale, you can handle TypeScript or Python.
The ‘I don’t want to learn programming’ argument is lazy. You’re already writing logic in HCL, you’re already debugging complex systems, you’re already reading code. Using a real language makes that easier, not harder.
When Terraform still wins
If your team only knows HCL and won’t learn anything else, stick with Terraform. Forcing a tool on unwilling people fails.
If you have years of Terraform modules and no budget to rewrite, stay with Terraform. Migration has a cost.
For new projects or teams willing to learn, Pulumi is better. Real languages, real testing, less boilerplate.
Getting started
Install Pulumi:
curl -fsSL https://get.pulumi.com | sh Create a project:
pulumi new aws-typescript Write infrastructure:
import * as aws from '@pulumi/aws';
const bucket = new aws.s3.Bucket('my-bucket');
export const bucketName = bucket.id; Deploy:
pulumi up That’s it. No providers to configure manually, no init dance, just code and deploy.
The real difference
Terraform makes you fight the language. You spend time working around limitations instead of solving problems.
Pulumi lets you write infrastructure like you write application code. Functions, loops, conditionals, tests. Tools you already know.
HCL was a compromise. We needed infrastructure as code before better options existed. Now they do.
Reality is often more nuanced. But me? Nuance bores me. I'd rather be clear.