DevOps

Pulumi vs Terraform: A Practitioner's Comparison for AWS Infrastructure

2024-10-08 Gabor Balogh

PulumiTerraformIaCAWSTypeScript

Pulumi vs Terraform: A Practitioner's Comparison for AWS Infrastructure

This comparison draws on three years of managing AWS infrastructure with Terraform followed by a year migrating that same infrastructure to Pulumi TypeScript. This isn't a marketing comparison โ€” it's an honest assessment of what changed, what improved, and what's still painful.

The short answer: neither is universally better. The right tool depends on your team, your tooling ecosystem, and how much you care about certain specific tradeoffs.

The State Backend Question

This is often the first practical blocker. Both tools need somewhere to store state.

Terraform

For teams, the de facto choice is Terraform Cloud (now HCP Terraform). It handles state locking, remote execution, and the Sentinel policy framework. But it's not free โ€” the free tier is limited, and meaningful team features start at the paid tier.

Alternatives: S3 + DynamoDB for state and locking. This is free, fully under your control, and works perfectly well. The tradeoff: you manage it yourself, and you don't get the Terraform Cloud UI or remote execution.

terraform {
  backend "s3" {
    bucket         = "my-tf-state"
    key            = "prod/terraform.tfstate"
    region         = "eu-west-2"
    dynamodb_table = "my-tf-locks"
    encrypt        = true
  }
}

Pulumi

Pulumi has a managed backend (Pulumi Cloud) that's free for individuals and small teams. But you can also self-host: S3 as the state backend with DynamoDB for locking is fully supported.

export PULUMI_BACKEND_URL=s3://my-pulumi-state-bucket
pulumi stack init prod

This is a solid choice. No SaaS dependency, no cost, the state bucket lives in your own AWS account. It works identically to Pulumi Cloud for all practical purposes.

Verdict: Both support S3 backends. Pulumi's self-hosted story is actually slightly simpler to configure.

Type Safety: The Real Difference

This is where Pulumi genuinely wins, and the difference compounds over time.

HCL's Type System

HCL is a DSL. It has types, but they're looser than most engineers expect:

variable "instance_type" {
  type    = string
  default = "t3.micro"
}

Your editor will autocomplete known resource types if you have the right plugins, but there's no compile-time guarantee. A typo in an attribute name (iamce_profile instead of iam_instance_profile) will pass validation and fail at apply time โ€” often after creating resources you'll need to clean up.

Terraform's terraform validate helps, but it validates HCL syntax, not semantic correctness against the provider schema.

TypeScript in Pulumi

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

const instance = new aws.ec2.Instance('web', {
  instanceType: aws.ec2.InstanceType.T3_Micro,  // enum โ€” typos caught at compile time
  ami: latestAmi.id,
  iamInstanceProfile: profile.name,  // wrong attribute name โ†’ TS error immediately
});

Your IDE gives you autocomplete on resource properties. TypeScript catches attribute name typos before you run pulumi preview. Mismatched types (e.g., passing a string where a pulumi.Output<string> is required) are compile-time errors.

This matters more than it sounds. When your infrastructure has 50+ resources across multiple stacks, catching errors in the editor versus at apply time is a significant productivity difference.

Verdict: Pulumi TypeScript wins on type safety, decisively. This becomes more valuable as your codebase grows.

Abstraction and Reuse

Terraform Modules

Modules in Terraform are directories of .tf files. They're widely used and the Terraform Registry has thousands of community modules. However:

  • Module interfaces are defined via variable blocks โ€” no type-safe calling convention
  • Looping in modules (for_each, count) is expressive but has edge cases that require workarounds
  • Dynamic blocks for optional nested configuration are verbose
module "ecs_service" {
  source      = "./modules/ecs-fargate"
  name        = "api"
  image       = var.ecr_image
  cpu         = 256
  memory      = 512
  port        = 3000
}

Pulumi Component Resources

In Pulumi, reusable components are TypeScript classes:

export class FargateService extends pulumi.ComponentResource {
  public readonly serviceArn: pulumi.Output<string>;

  constructor(name: string, args: FargateServiceArgs, opts?: pulumi.ComponentResourceOptions) {
    super('gabaltech:platform:FargateService', name, {}, opts);
    // ... create ALB, ECS service, task definition
    this.serviceArn = ecsService.id;
  }
}

// Usage:
const api = new FargateService('api', {
  image: ecrImage,
  cpu: 256,
  memory: 512,
  port: 3000,
});

This is a genuine class with a TypeScript interface. Arguments are typed, autocomplete works, and the abstraction composes naturally with the rest of the language.

Verdict: Both support reuse, but Pulumi's component model is more powerful for complex abstractions.

The Ecosystem Gap

Terraform's ecosystem advantage is real and shouldn't be dismissed:

  • The Terraform Registry has a mature, community-maintained module library
  • Most "how do I do X in AWS" Stack Overflow answers are Terraform examples
  • Most third-party tooling (Atlantis, Terragrunt, Checkov, tfsec) targets Terraform
  • Many compliance frameworks publish Terraform Guard rules

Pulumi is growing fast, but if you're looking for a pre-built community module for, say, a specific networking pattern, you're more likely to find a polished Terraform version than a Pulumi equivalent.

Verdict: Terraform wins on ecosystem maturity. This gap is narrowing but is still real.

Testing Infrastructure Code

Terraform

Terratest is the standard. It's Go-based, so your infrastructure tests are Go programs that run terraform apply, make assertions against real AWS resources, and run terraform destroy. Effective but slow (real AWS resources are created) and requires Go knowledge.

Pulumi

Because Pulumi programs are regular TypeScript, you can use @pulumi/pulumi/testing with Jest or Vitest to write unit tests that mock the provider layer:

it('creates ECS service with correct CPU', async () => {
  const resources = await testStack.testService();
  expect(resources.cpu).toEqual(256);
});

These run in milliseconds, with no AWS API calls. You can also run integration tests against real resources using Pulumi's automation API.

Verdict: Pulumi has a clearer path to fast unit tests. Terraform testing exists but requires more infrastructure.

When to Choose Each

Choose Terraform if:

  • Your team already knows HCL
  • You need specific community modules that don't have Pulumi equivalents
  • You're working in a regulated environment with existing Terraform-based compliance tooling
  • You need Atlantis or Terragrunt workflows

Choose Pulumi if:

  • Your team is comfortable with TypeScript/Python/Go
  • You want compile-time safety for infrastructure code
  • You're building complex abstractions or reusable platform components
  • You want to unit-test infrastructure logic without provisioning real resources

What Actually Changed

Migrating from Terraform to Pulumi TypeScript made sense for one concrete reason: the stack was growing beyond what HCL handles comfortably. Conditional resource creation, complex loop patterns, and reusable components all became cleaner in TypeScript. The type safety paid off immediately โ€” in the first week, two attribute typos were caught during tsc compilation that would have been runtime failures in Terraform.

The migration wasn't free โ€” about two weeks of effort to rewrite three stacks, plus time updating CI/CD. But for a growing platform with many shared components, it was worth it.

The S3 state backend made the migration operationally clean: stand up the new Pulumi stacks in parallel, verify outputs match, then decommission the Terraform state. No downtime, no resource recreation.

Back to Blog

About the Author

Gabor Balogh is a senior cloud platform engineer and DevOps consultant building infrastructure-as-code and developer platforms.