Infrastructure as Code: Pulumi vs. Terraform

·18 min read·Cloud and DevOpsintermediate

Why modern teams need clear choices between general-purpose languages and declarative DSLs as cloud complexity grows

A developer workstation displaying code and infrastructure diagrams side by side to illustrate the IaC workflow

I started using Terraform back when state files were still a source of daily anxiety, and I reached for Pulumi later, during a migration to Kubernetes where our team needed more abstraction and programming language features. Over the years, I’ve deployed small services, multi-account AWS setups, and internal platforms using both. The temptation to pick a “winner” is strong, but real-world projects rarely fit one tool perfectly. Teams pick a tool based on who builds infrastructure, how they handle change, and how quickly they can reason about complex systems.

If you are evaluating Infrastructure as Code for your next project, you likely want to know what problems each tool solves best, where they struggle, and how they feel on a day-to-day basis. This article compares Pulumi and Terraform with practical examples, from simple cloud resources to realistic multi-environment setups, and offers guidance on which tool might fit your team. I will avoid hype and focus on tradeoffs that matter in production: clarity, collaboration, state management, and developer experience.

Where Pulumi and Terraform fit today

Terraform remains the most widely adopted Infrastructure as Code tool, particularly for teams that prefer a declarative domain-specific language (HCL). It is the de facto standard for cloud provisioning across AWS, Azure, and GCP, and it integrates with countless providers and modules. Its declarative model focuses on describing desired state, with Terraform plan and apply workflows driving change control. If you work in a platform team or a compliance-heavy environment, Terraform’s module ecosystem and state management features often make it a safe default.

Pulumi takes a different approach: it uses general-purpose languages like TypeScript, JavaScript, Python, Go, C#, and Java to define infrastructure. This is attractive to developer-centric teams who want to reuse programming language features such as functions, classes, loops, and typing. Pulumi’s engine translates your code into a plan of resource operations and then executes it, similar to Terraform, but the authoring experience is closer to writing application code. Pulumi also supports a YAML-based configuration language for simpler use cases and teams that want to avoid programming languages.

At a high level:

  • Terraform: Declarative HCL, provider ecosystem, mature community, strong cadence around plan/apply, and predictable upgrades.
  • Pulumi: Imperative/declarative code in general-purpose languages, rich abstractions, reuse of application development patterns, and a unified approach to cloud and Kubernetes resources.

In practice, both tools can manage the same cloud resources via their providers. The differences often show up in collaboration patterns, testing strategies, and how teams handle complexity and state.

Core concepts and practical examples

Declarative HCL vs. general-purpose languages

Terraform’s HCL is designed for configuration and readability. It separates variables, resources, data sources, and outputs, making it easy to see what you are provisioning without reading through code logic. It is intentionally limited to avoid procedural complexity, which helps keep infrastructure predictable.

Pulumi’s use of real programming languages allows you to define reusable components, write helper functions, and structure code like an application. This can reduce duplication and make complex patterns easier to express, but it also requires discipline to avoid over-abstraction.

State management and drift detection

Both tools rely on state to map your configuration to real resources:

  • Terraform stores state in a backend like S3 with DynamoDB locking, Terraform Cloud, or other supported options. It detects drift during plan and can correct it on apply.
  • Pulumi stores state in the Pulumi Service or a self-hosted backend (e.g., S3). Drift detection is supported, and refresh operations reconcile local state with the real world.

In real projects, I prefer locking and remote state to avoid conflicts when multiple engineers apply changes. For Terraform, this is battle-tested. For Pulumi, it’s equally reliable but requires attention to backend configuration and secrets handling.

Modules vs. components

Terraform modules are the primary abstraction mechanism. Modules encapsulate resources and expose inputs and outputs. The registry offers a rich set of community modules.

Pulumi uses “components” — classes that encapsulate resources and logic. Components are powerful when you need higher-level patterns like a standard service scaffold that includes compute, networking, and monitoring.

Provider ecosystem and coverage

Terraform’s provider ecosystem is massive. For cloud-native services, Terraform providers are usually available immediately when a new service launches. Pulumi’s providers are also broad and cover major clouds and Kubernetes. For niche or rapidly changing platforms, Terraform often has an edge in community contributions and module examples.

Practical code scenarios

Simple AWS S3 bucket with Terraform

The following example creates an S3 bucket with versioning and server-side encryption. It shows the declarative style and the typical variable/resource/output structure.

# variables.tf
variable "environment" {
  description = "Environment name (e.g., dev, staging, prod)"
  type        = string
}

variable "region" {
  description = "AWS region"
  type        = string
  default     = "us-east-1"
}

# main.tf
provider "aws" {
  region = var.region
}

resource "aws_s3_bucket" "logs" {
  bucket = "acme-logs-${var.environment}-${var.region}"

  tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_s3_bucket_versioning" "logs" {
  bucket = aws_s3_bucket.logs.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
  bucket = aws_s3_bucket.logs.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

# outputs.tf
output "bucket_name" {
  value       = aws_s3_bucket.logs.bucket
  description = "Name of the created S3 bucket"
}

output "bucket_arn" {
  value       = aws_s3_bucket.logs.arn
  description = "ARN of the created S3 bucket"
}

This HCL is straightforward. Variables make it reusable across environments, and outputs expose key attributes. A typical workflow looks like this:

# Initialize Terraform (download providers, set up backend)
terraform init

# Plan changes to see what will be created
terraform plan -var="environment=dev" -var="region=us-east-1"

# Apply changes (approve interactively)
terraform apply -var="environment=dev" -var="region=us-east-1"

# Destroy resources when no longer needed
terraform destroy -var="environment=dev" -var="region=us-east-1"

Same S3 bucket with Pulumi (TypeScript)

The Pulumi version uses TypeScript. It demonstrates functions, type safety, and how you can encapsulate configuration and resources.

// index.ts
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";

// Configuration via Pulumi stack or environment variables
const config = new pulumi.Config();
const environment = config.require("environment");
const region = config.get("region") ?? "us-east-1";

// Create an AWS provider instance
const provider = new aws.Provider("aws-provider", { region });

// Helper function to generate bucket name
function bucketName(service: string): string {
  return `acme-${service}-${environment}-${region}`.toLowerCase();
}

// Create an S3 bucket with versioning and encryption
const logsBucket = new aws.s3.Bucket(
  "logs",
  {
    bucket: bucketName("logs"),
    tags: {
      Environment: environment,
      ManagedBy: "pulumi",
    },
  },
  { provider }
);

const versioning = new aws.s3.BucketVersioning("logs-versioning", {
  bucket: logsBucket.id,
  versioningConfiguration: {
    status: "Enabled",
  },
}, { provider });

const encryption = new aws.s3.BucketServerSideEncryptionConfiguration("logs-encryption", {
  bucket: logsBucket.id,
  rule: [
    {
      applyServerSideEncryptionByDefault: {
        sseAlgorithm: "AES256",
      },
    },
  ],
}, { provider });

// Export outputs
export const bucketNameOutput = logsBucket.bucket;
export const bucketArnOutput = logsBucket.arn;

Pulumi’s workflow uses the CLI to manage stacks (environments) and deployments:

# Create a new stack (environment)
pulumi stack init dev

# Set configuration values
pulumi config set environment dev
pulumi config set region us-east-1

# Preview changes
pulumi preview

# Deploy changes
pulumi up

# Tear down resources
pulumi destroy

Notice the difference in authoring: HCL focuses on configuration and declarative resource definitions; Pulumi TypeScript uses functions and classes to organize logic. In complex projects, this helps with reuse, but it also introduces responsibility to keep the code readable.

A realistic pattern: reusable service component with Pulumi

In a real-world project, we built a “service component” that provisions a standard set of resources for a microservice: an ECR repository, an ECS service with Fargate, IAM roles, and CloudWatch logging. Here is a simplified version to illustrate the concept.

// components/service.ts
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";

export interface ServiceInputs {
  name: string;
  image: pulumi.Input<string>;
  cpu: number;
  memory: number;
  port: number;
  environment: string;
  vpcId: pulumi.Input<string>;
  subnets: pulumi.Input<string>[];
  securityGroupId: pulumi.Input<string>;
}

export class Microservice extends pulumi.ComponentResource {
  public readonly taskRole: aws.iam.Role;
  public readonly executionRole: aws.iam.Role;
  public readonly logGroup: aws.cloudwatch.LogGroup;
  public readonly service: aws.ecs.Service;

  constructor(name: string, args: ServiceInputs, opts?: pulumi.ComponentResourceOptions) {
    super("acme:microservice", name, {}, opts);

    const defaultProvider = opts?.provider;

    this.logGroup = new aws.cloudwatch.LogGroup(`${name}-logs`, {
      retentionInDays: 7,
      tags: { Environment: args.environment },
    }, { provider: defaultProvider });

    this.taskRole = new aws.iam.Role(`${name}-task-role`, {
      assumeRolePolicy: aws.iam.getPolicyDocumentOutput({
        statements: [
          {
            actions: ["sts:AssumeRole"],
            principals: [{ type: "Service", identifiers: ["ecs-tasks.amazonaws.com"] }],
          },
        ],
      }).json,
    }, { provider: defaultProvider });

    this.executionRole = new aws.iam.Role(`${name}-exec-role`, {
      assumeRolePolicy: aws.iam.getPolicyDocumentOutput({
        statements: [
          {
            actions: ["sts:AssumeRole"],
            principals: [{ type: "Service", identifiers: ["ecs-tasks.amazonaws.com"] }],
          },
        ],
      }).json,
    }, { provider: defaultProvider });

    // Minimal inline policy for task role (example)
    new aws.iam.RolePolicy(`${name}-task-policy`, {
      role: this.taskRole.id,
      policy: JSON.stringify({
        Version: "2012-10-17",
        Statement: [
          {
            Effect: "Allow",
            Action: ["logs:CreateLogStream", "logs:PutLogEvents"],
            Resource: pulumi.interpolate`${this.logGroup.arn}:*`,
          },
        ],
      }),
    }, { provider: defaultProvider });

    const taskDefinition = new aws.ecs.TaskDefinition(`${name}-task`, {
      family: `${name}-${args.environment}`,
      cpu: args.cpu.toString(),
      memory: args.memory.toString(),
      networkMode: "awsvpc",
      requiresCompatibilities: ["FARGATE"],
      executionRoleArn: this.executionRole.arn,
      taskRoleArn: this.taskRole.arn,
      containerDefinitions: JSON.stringify([
        {
          name: name,
          image: args.image,
          portMappings: [{ containerPort: args.port }],
          logConfiguration: {
            logDriver: "awslogs",
            options: {
              "awslogs-group": this.logGroup.name,
              "awslogs-region": aws.getRegionOutput().name,
              "awslogs-stream-prefix": name,
            },
          },
        },
      ]),
      tags: { Environment: args.environment },
    }, { provider: defaultProvider });

    this.service = new aws.ecs.Service(`${name}-svc`, {
      name: `${name}-${args.environment}`,
      cluster: args.vpcId, // simplified: in real usage you would pass a cluster ID
      taskDefinition: taskDefinition.arn,
      desiredCount: 1,
      launchType: "FARGATE",
      networkConfiguration: {
        subnets: args.subnets,
        securityGroups: [args.securityGroupId],
        assignPublicIp: false,
      },
      tags: { Environment: args.environment },
    }, { provider: defaultProvider });

    this.registerOutputs({
      taskRole: this.taskRole,
      executionRole: this.executionRole,
      logGroup: this.logGroup,
      service: this.service,
    });
  }
}

In a consuming stack, you would instantiate this component:

// index.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import { Microservice } from "./components/service";

const config = new pulumi.Config();
const environment = config.require("environment");

const vpcId = config.require("vpcId");
const subnets = config.requireObject<string[]>("subnets");
const securityGroupId = config.require("securityGroupId");

// Use a public image for demonstration
const image = "nginx:latest";

const apiService = new Microservice("api", {
  name: "api",
  image,
  cpu: 256,
  memory: 512,
  port: 80,
  environment,
  vpcId,
  subnets,
  securityGroupId,
});

export const serviceName = apiService.service.name;
export const taskRoleArn = apiService.taskRole.arn;

This pattern captures the essence of Pulumi’s strength: reusable, testable infrastructure abstractions that feel like writing library code.

A realistic pattern: multi-environment modules with Terraform

Terraform teams often build a module that encapsulates service resources and call it multiple times using workspaces or directory-based environments. The module structure helps enforce consistency and governance.

# modules/service/main.tf
variable "name" {
  type = string
}

variable "environment" {
  type = string
}

variable "vpc_id" {
  type = string
}

variable "subnet_ids" {
  type = list(string)
}

variable "security_group_ids" {
  type = list(string)
}

variable "container_image" {
  type = string
}

variable "cpu" {
  type    = number
  default = 256
}

variable "memory" {
  type    = number
  default = 512
}

resource "aws_cloudwatch_log_group" "service" {
  name              = "/ecs/${var.name}-${var.environment}"
  retention_in_days = 7

  tags = {
    Environment = var.environment
  }
}

resource "aws_iam_role" "task_role" {
  name = "${var.name}-${var.environment}-task"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
      }
    ]
  })
}

resource "aws_iam_role_policy" "task_policy" {
  name = "${var.name}-${var.environment}-policy"
  role = aws_iam_role.task_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ]
        Resource = "${aws_cloudwatch_log_group.service.arn}:*"
      }
    ]
  })
}

resource "aws_ecs_task_definition" "service" {
  family                   = "${var.name}-${var.environment}"
  cpu                      = var.cpu
  memory                   = var.memory
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  task_role_arn            = aws_iam_role.task_role.arn

  container_definitions = jsonencode([
    {
      name  = var.name
      image = var.container_image
      portMappings = [
        {
          containerPort = 80
        }
      ]
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          awslogs-group         = aws_cloudwatch_log_group.service.name
          awslogs-region        = "us-east-1"
          awslogs-stream-prefix = var.name
        }
      }
    }
  ])
}

resource "aws_ecs_service" "service" {
  name            = "${var.name}-${var.environment}"
  cluster         = var.vpc_id # simplified
  task_definition = aws_ecs_task_definition.service.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = var.subnet_ids
    security_groups  = var.security_group_ids
    assign_public_ip = false
  }
}

output "task_role_arn" {
  value = aws_iam_role.task_role.arn
}

output "service_name" {
  value = aws_ecs_service.service.name
}

Consuming the module for multiple environments:

# environments/dev/main.tf
module "api_dev" {
  source = "../../modules/service"

  name        = "api"
  environment = "dev"
  vpc_id      = "vpc-123456"
  subnet_ids  = ["subnet-abc", "subnet-def"]
  security_group_ids = ["sg-789"]

  container_image = "nginx:latest"
}

output "api_service_name" {
  value = module.api_dev.service_name
}

output "api_task_role_arn" {
  value = module.api_dev.task_role_arn
}

The Terraform approach emphasizes modularity and clarity, especially when combined with a consistent directory layout and remote state. For larger teams, you may integrate tools like Terragrunt to reduce boilerplate for remote state and variable management.

Evaluation: strengths, weaknesses, and tradeoffs

When Terraform shines

  • Multi-cloud and compliance-heavy environments: HCL’s declarative style makes it easier for auditors and operators to understand intent. The ecosystem of modules and providers is vast.
  • Stable workflows: Plan/apply cadence and rigorous state management fit established DevOps practices. Integrations with CI/CD and policy engines (e.g., Sentinel) are mature.
  • Operator-centric teams: If your team consists of platform engineers who are comfortable with DSLs and configuration, Terraform reduces cognitive overhead.

When Pulumi shines

  • Developer-centric teams: If your infrastructure is tightly coupled with application code, Pulumi allows you to use the same language, tooling, and testing patterns. This improves iteration speed.
  • Abstractions and reuse: Components enable higher-level constructs that reduce duplication across services and environments.
  • Kubernetes and multi-resource orchestration: For complex Kubernetes manifests and cross-resource dependencies, Pulumi’s programming model offers flexibility.

Potential drawbacks

  • Terraform: HCL can become verbose in deeply nested configurations. Complex logic often requires external tools or creative use of locals and data sources. Rapidly evolving cloud services may arrive as new providers or resource types that need careful upgrades.
  • Pulumi: With general-purpose languages, you risk over-engineering. Debugging depends on language-specific tooling, and learning curves differ across languages. The Pulumi Service’s hosted features may require subscription considerations for some organizations.

Security and secrets

  • Terraform: Integrates with Vault, AWS Secrets Manager, and SSM via data sources. Secrets in state files remain a concern, and best practices recommend encrypted backends and strict access controls.
  • Pulumi: Built-in secrets management marks values as encrypted, and stacks support environment-specific configuration. It’s straightforward but still requires operational discipline to secure state and access.

Personal experience: learning curves and real-world moments

I learned Terraform early when my team was standardizing on AWS and needed repeatable environments across regions. The biggest hurdle was not HCL syntax but thinking in a purely declarative way. We had to accept that Terraform is not a scripting language and avoid trying to encode procedural logic. Once we embraced modules and remote state, our workflows became predictable. A memorable win came when we automated account baselines (VPCs, IAM policies, guardrails). Upgrading providers from v2 to v3 required careful planning, but the upgrade tooling and documentation were helpful, and the process highlighted the importance of pinning versions.

When I moved to a team building a Kubernetes platform, Pulumi became more appealing. The ability to define reusable components for microservices, tie them to CI/CD pipelines, and write unit tests in TypeScript made a tangible difference. One lesson stood out: without guardrails, developers can create clever but opaque abstractions. We added conventions for naming, tagging, and limits to keep our Pulumi code readable. Another moment of value was during a complex migration from Helm to custom resources. Pulumi’s diffing and preview features helped us avoid mistakes, and we could programmatically generate manifests for multiple environments without templating logic.

Both tools are robust, but they reward different habits. Terraform rewards consistency, restraint, and careful state management. Pulumi rewards thoughtful abstractions and collaboration between platform and application developers.

Getting started: setup and mental models

Choosing your workflow

Think of IaC as a product your team builds. It needs:

  • A clear structure for environments and modules/components.
  • A predictable change process with plan/preview and approvals.
  • Remote state with locking to prevent concurrent changes.
  • Secrets management that aligns with your security posture.

Terraform project structure (line-based)

terraform/
  environments/
    dev/
      main.tf
      variables.tf
      outputs.tf
    staging/
      main.tf
      variables.tf
      outputs.tf
    prod/
      main.tf
      variables.tf
      outputs.tf
  modules/
    service/
      main.tf
      variables.tf
      outputs.tf
  backend.tf
  providers.tf

A typical backend configuration (S3 + DynamoDB) looks like:

# backend.tf
terraform {
  backend "s3" {
    bucket         = "acme-terraform-state"
    key            = "environments/dev/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "acme-terraform-locks"
    encrypt        = true
  }
}

Provider configuration is often centralized:

# providers.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.region
}

Pulumi project structure (line-based)

pulumi/
  stacks/
    dev.ts
    staging.ts
    prod.ts
  components/
    service.ts
  index.ts
  Pulumi.yaml
  Pulumi.dev.yaml
  Pulumi.staging.yaml
  Pulumi.prod.yaml

Pulumi.yaml defines the project:

name: acme-platform
runtime: nodejs
description: Core infrastructure for Acme services

Stack-specific configuration can live in Pulumi..yaml:

config:
  aws:region: us-east-1
  environment: dev
  vpcId: vpc-123456
  subnets:
    - subnet-abc
    - subnet-def
  securityGroupId: sg-789

Developer experience differences

  • Terraform: The mental model is “describe desired state, plan, and apply.” This is excellent for operators who want to understand changes at a glance. The terraform plan output is a strong communication tool with stakeholders.
  • Pulumi: The mental model is “write code that defines infrastructure, preview, and update.” You can leverage IDE features like autocomplete, type checking, and refactoring. Tests can target components directly. However, you need to design your code for readability to avoid surprises.

Tips for robust workflows

  • Pin provider and plugin versions.
  • Use remote backends and enable locking.
  • Avoid conditional logic that changes resource shapes based on runtime values; prefer explicit configuration.
  • Write plan/preview checks in CI and require approvals for production.
  • Tag resources consistently to aid cost reporting and audits.

Distinguishing features and ecosystem strengths

Terraform

  • Declarative DSL: Limits scope, which reduces errors and keeps configuration readable.
  • Provider ecosystem: Broad coverage and rapid updates for new cloud services.
  • Community modules: A rich set of battle-tested modules reduces build time.
  • Tooling: Workspaces, init, validate, fmt, and state management commands are mature.
  • Enterprise features: Policy enforcement with Sentinel, collaboration in Terraform Cloud.

Pulumi

  • General-purpose languages: Use loops, functions, classes, and packages to reduce duplication.
  • Component model: Encapsulate patterns as reusable libraries that can be published internally.
  • Multi-language support: TypeScript/JavaScript, Python, Go, C#, Java, and YAML for simple use cases.
  • Kubernetes integration: Unified approach to cloud resources and Kubernetes manifests.
  • Testing: Unit and integration tests using familiar language testing frameworks.

Developer outcomes

  • Terraform: Easier to onboard operators, straightforward review process, stable upgrade cadence. May require external templating or additional tools for complex logic.
  • Pulumi: Better for teams that want to move quickly and reuse application patterns. Requires discipline to keep code simple and reviewable.

Free learning resources

Who should use which tool

Choose Terraform if:

  • You operate in a multi-cloud or regulated environment and want a stable, declarative DSL with a large provider ecosystem.
  • Your team is primarily platform engineers and operators who value predictable plan/apply workflows.
  • You need mature policy and collaboration features for enterprise governance.

Choose Pulumi if:

  • Your infrastructure is closely tied to application code, and you want to reuse programming language features and testing frameworks.
  • You value higher-level abstractions and components to reduce duplication across services.
  • You are investing in Kubernetes and want a unified approach to infrastructure and container orchestration.

In practice, many organizations use both tools in different parts of their stack. The best choice depends on your team’s skills, the complexity of your infrastructure, and how you manage change. If you are early and unsure, Terraform’s declarative style is a safe starting point. If your team is developer-heavy and needs higher-level abstractions, Pulumi will likely accelerate delivery. Either way, start small, establish remote state with locking, and evolve your approach as your platform grows.