Skip to main content

Infrastructure as Code: Terraform vs Pulumi

9 min read
Write my infrastructure as code

We choose the tool that suits your team and set up state, testing and migration.

Compare Terraform and Pulumi for infrastructure as code with real-world examples, state management, testing strategies, and migration considerations.

The IaC Decision

Infrastructure as Code transforms manual server provisioning into repeatable, version-controlled automation. Two tools dominate the IaC landscape: Terraform with its declarative HCL language and Pulumi with general-purpose programming languages. Both solve the same problem, but they approach it differently.

This comparison draws on our experience managing cloud infrastructure with both tools across dozens of production environments.

Language and Developer Experience

Terraform (HCL)

Terraform uses HashiCorp Configuration Language, a declarative DSL designed specifically for infrastructure:

resource "aws_ecs_cluster" "main" {
  name = "production-cluster"

  setting {
    name  = "containerInsights"
    value = "enabled"
  }

  tags = {
    Environment = "production"
    ManagedBy   = "terraform"
  }
}

resource "aws_ecs_service" "api" {
  name            = "api-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.api.arn
  desired_count   = 3
  launch_type     = "FARGATE"

  network_configuration {
    subnets         = var.private_subnets
    security_groups = [aws_security_group.api.id]
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.api.arn
    container_name   = "api"
    container_port   = 8080
  }
}

HCL is approachable for operators who are not software engineers. The declarative style makes it easy to read and understand what infrastructure exists. However, complex logic requires workarounds - for_each, dynamic blocks, and locals can become convoluted.

Pulumi (TypeScript/Python/Go)

Pulumi uses real programming languages, giving full access to loops, conditionals, functions, and type systems:

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

const cluster = new aws.ecs.Cluster("production-cluster", {
  settings: [{
    name: "containerInsights",
    value: "enabled",
  }],
  tags: {
    Environment: "production",
    ManagedBy: "pulumi",
  },
});

const service = new aws.ecs.Service("api-service", {
  cluster: cluster.id,
  taskDefinition: taskDef.arn,
  desiredCount: 3,
  launchType: "FARGATE",
  networkConfiguration: {
    subnets: privateSubnets,
    securityGroups: [apiSg.id],
  },
  loadBalancers: [{
    targetGroupArn: apiTg.arn,
    containerName: "api",
    containerPort: 8080,
  }],
});

The TypeScript version is slightly more verbose but offers IDE autocompletion, compile-time type checking, and the ability to create reusable abstractions with classes and functions.

State Management

Terraform State

Terraform stores state in a JSON file that maps configuration to real-world resources. For team collaboration, use remote backends:

terraform {
  backend "s3" {
    bucket         = "company-terraform-state"
    key            = "production/ecs/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

DynamoDB provides state locking to prevent concurrent modifications. State files contain sensitive data (passwords, keys) and must be encrypted at rest.

Pulumi State

Pulumi offers managed state through Pulumi Cloud or self-managed backends:

# Using Pulumi Cloud (default)
pulumi login

# Using S3 backend
pulumi login s3://company-pulumi-state

# Using local filesystem
pulumi login --local

Pulumi Cloud provides state management, secrets encryption, RBAC, and a web UI for viewing deployments. Self-managed backends work identically to Terraform's approach.

Reusable Components

Terraform Modules

module "vpc" {
  source = "./modules/vpc"

  cidr_block      = "10.0.0.0/16"
  az_count        = 3
  environment     = "production"
  enable_nat      = true
}

module "ecs_service" {
  source = "./modules/ecs-service"

  cluster_id    = module.ecs_cluster.id
  service_name  = "api"
  image         = "registry.example.com/api:v1.2.3"
  cpu           = 512
  memory        = 1024
  desired_count = 3
  vpc_id        = module.vpc.vpc_id
  subnets       = module.vpc.private_subnets
}

Pulumi Component Resources

class EcsService extends pulumi.ComponentResource {
  public readonly url: pulumi.Output<string>;

  constructor(name: string, args: EcsServiceArgs, opts?: pulumi.ComponentResourceOptions) {
    super("custom:EcsService", name, {}, opts);

    const taskDef = new aws.ecs.TaskDefinition(`${name}-task`, {
      family: name,
      cpu: String(args.cpu),
      memory: String(args.memory),
      networkMode: "awsvpc",
      requiresCompatibilities: ["FARGATE"],
      containerDefinitions: pulumi.output(args.image).apply(image =>
        JSON.stringify([{
          name,
          image,
          portMappings: [{ containerPort: args.port }],
          logConfiguration: {
            logDriver: "awslogs",
            options: {
              "awslogs-group": `/ecs/${name}`,
              "awslogs-region": aws.config.region!,
              "awslogs-stream-prefix": "ecs",
            },
          },
        }])
      ),
    }, { parent: this });

    // ... service, ALB, target group creation
  }
}

// Usage
const api = new EcsService("api", {
  clusterId: cluster.id,
  image: "registry.example.com/api:v1.2.3",
  cpu: 512,
  memory: 1024,
  port: 8080,
});

Pulumi components feel natural to developers - they are just classes with constructors. Terraform modules require understanding input variables, output values, and module composition patterns.

Testing

Terraform Testing

# tests/vpc_test.tftest.hcl
run "creates_vpc_with_correct_cidr" {
  command = plan

  variables {
    cidr_block  = "10.0.0.0/16"
    az_count    = 3
    environment = "test"
  }

  assert {
    condition     = aws_vpc.main.cidr_block == "10.0.0.0/16"
    error_message = "VPC CIDR block does not match"
  }
}

Pulumi Testing

import * as pulumi from "@pulumi/pulumi";
import { describe, it, expect } from "vitest";

pulumi.runtime.setMocks({
  newResource: (args) => ({ id: "mock-id", state: args.inputs }),
  call: (args) => args.inputs,
});

describe("ECS Service", () => {
  it("sets Fargate launch type", async () => {
    const { service } = await import("./index");
    const launchType = await new Promise<string>((resolve) =>
      service.launchType.apply(resolve)
    );
    expect(launchType).toBe("FARGATE");
  });
});

Pulumi's testing uses standard testing frameworks (Jest, pytest, Go testing), making it more accessible to development teams already familiar with these tools.

When to Choose Each

Choose Terraform when:

  • Your team consists primarily of operations engineers
  • You need the broadest provider ecosystem
  • You want a large community with extensive module registry
  • Declarative configuration is preferred over imperative code

Choose Pulumi when:

  • Your team consists primarily of software engineers
  • You need complex logic (conditional resources, dynamic configuration)
  • You want to share libraries and patterns with application code
  • Type safety and IDE support are important

Migration Considerations

Both tools support importing existing resources. Terraform uses terraform import, Pulumi uses pulumi import. Neither migration is trivial - plan for a phased approach, starting with new infrastructure and gradually importing existing resources.

For guidance on selecting and implementing IaC for your infrastructure setup, our team can assess your current environment and recommend the right tooling approach.

Frequently asked questions

What is the difference between Terraform and Pulumi?

Terraform describes infrastructure in HCL (HashiCorp Configuration Language), a declarative language designed specifically for infrastructure. Pulumi uses general-purpose languages such as TypeScript, Python and Go, with full access to loops, conditionals, functions and type systems. Both tools solve the same problem. HCL is approachable for operators who are not software engineers, while Pulumi in TypeScript adds IDE autocompletion, compile-time type checking and reusable abstractions built from classes and functions.

Should my team use Terraform or Pulumi?

Terraform suits a team made up mainly of operations engineers who prefer declarative configuration and want the broadest provider ecosystem and a large community module registry. Pulumi suits a team made up mainly of software engineers, especially when the infrastructure needs complex logic such as conditional resources or dynamic configuration. Pulumi is also the better fit when libraries and patterns should be shared with application code, or when type safety and IDE support matter.

How do I store Terraform state for a team?

Use a remote backend. For example, the s3 backend takes a bucket, a key such as production/ecs/terraform.tfstate, a region, a dynamodb_table for locks and encrypt = true. DynamoDB provides state locking, which prevents concurrent modifications. Encryption at rest is required because the state file, a JSON file mapping configuration to real-world resources, contains sensitive data such as passwords and keys.

Where does Pulumi store its state?

Pulumi Cloud is the default, used when running a plain pulumi login. Self-managed backends are also supported, including an S3 bucket and the local filesystem through pulumi login --local. Pulumi Cloud adds secrets encryption, RBAC and a web UI for viewing deployments, and a self-managed backend works the same way as Terraform's remote state.

How do Terraform modules compare with Pulumi component resources?

Both package infrastructure for reuse, in different styles. A Terraform module is called with a source path and input variables, for example a VPC module given a cidr_block and an az_count, and other modules read its output values. A Pulumi component resource is a class that extends pulumi.ComponentResource and creates its child resources in the constructor. Developers tend to find components natural, whereas modules require understanding input variables, output values and module composition patterns.

How do I write tests for Terraform configuration?

Terraform tests live in .tftest.hcl files such as tests/vpc_test.tftest.hcl. Each run block sets a command, for example plan, supplies variables and contains assert blocks with a condition and an error_message. A typical assertion checks that aws_vpc.main.cidr_block equals 10.0.0.0/16 and fails with a message such as VPC CIDR block does not match.

How do you unit test Pulumi infrastructure code?

Pulumi tests run in standard frameworks such as Jest, pytest and Go testing. A test calls pulumi.runtime.setMocks to replace real resource creation and calls with mocks, imports the program, and then checks resource outputs, for example that an ECS service's launchType resolves to FARGATE. Development teams already familiar with those frameworks find Pulumi testing more accessible as a result.

Can I import existing cloud resources into Terraform or Pulumi?

Yes. Terraform uses terraform import and Pulumi uses pulumi import to bring existing resources under management. Neither migration is trivial, so plan it in phases, starting with new infrastructure in the chosen tool and gradually importing the resources that already exist.

Read how we handle it in Cloud & Server Management.