Skip to content

Infrastructure as Code with Pulumi: Beyond Terraform

A practical comparison of Pulumi and Terraform: how general-purpose languages enable abstractions, testing and composition that HCL cannot express.

5 min read
Cloud infrastructure components defined in TypeScript code

The Case for Real Programming Languages in IaC

Terraform revolutionized infrastructure management. Before it, provisioning cloud resources meant clicking through consoles or writing brittle shell scripts. HCL gave us declarative, reproducible infrastructure definitions that could be version-controlled and reviewed.

But HCL is a configuration language, not a programming language. It has variables, conditionals, and loops—but no functions that return values, no real abstractions, no type systems, and no package managers. As infrastructure grows complex, these limitations compound.

Pulumi takes a different approach: write infrastructure in TypeScript, Python, Go, or C#. Use the same language features you use in application code—interfaces, classes, generics, unit tests. This guide explores what that unlocks and where Terraform still holds advantages.

Basic Resource Definition: Side by Side

Let's start with a simple comparison. An S3 bucket with versioning and encryption in both tools.

hclhcl
# Terraform HCL
resource "aws_s3_bucket" "data_bucket" {
  bucket = "my-app-data-${var.environment}"
}
 
resource "aws_s3_bucket_versioning" "data_bucket" {
  bucket = aws_s3_bucket.data_bucket.id
  versioning_configuration {
    status = "Enabled"
  }
}
 
resource "aws_s3_bucket_server_side_encryption_configuration" "data_bucket" {
  bucket = aws_s3_bucket.data_bucket.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}
tstypescript
// Pulumi TypeScript
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
 
const config = new pulumi.Config();
const environment = config.require("environment");
 
const dataBucket = new aws.s3.Bucket("data-bucket", {
  bucket: `my-app-data-${environment}`,
  versioning: { enabled: true },
  serverSideEncryptionConfiguration: {
    rule: {
      applyServerSideEncryptionByDefault: {
        sseAlgorithm: "AES256",
      },
    },
  },
});

For simple resources, the difference is marginal. Pulumi is slightly more concise because related configurations nest under the parent resource. Terraform splits them into separate resources. But the real advantages emerge when you start building abstractions.

Component Resources: Reusable Infrastructure Patterns

Terraform has modules. They work for packaging reusable infrastructure, but they are limited by HCL's type system and composition model. Pulumi's component resources are real classes with interfaces, validation, and composition.

tstypescript
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
 
interface SecureBucketArgs {
  namePrefix: string;
  environment: string;
  retentionDays?: number;
  enableAccessLogs?: boolean;
  allowedPrincipals?: string[];
}
 
class SecureBucket extends pulumi.ComponentResource {
  public readonly bucket: aws.s3.Bucket;
  public readonly bucketArn: pulumi.Output<string>;
 
  constructor(
    name: string,
    args: SecureBucketArgs,
    opts?: pulumi.ComponentResourceOptions
  ) {
    super("custom:storage:SecureBucket", name, {}, opts);
 
    const retention = args.retentionDays ?? 90;
 
    this.bucket = new aws.s3.Bucket(
      `${name}-bucket`,
      {
        bucket: `${args.namePrefix}-${args.environment}`,
        versioning: { enabled: true },
        serverSideEncryptionConfiguration: {
          rule: {
            applyServerSideEncryptionByDefault: {
              sseAlgorithm: "AES256",
            },
          },
        },
        lifecycleRules: [
          {
            enabled: true,
            noncurrentVersionExpiration: { days: retention },
          },
        ],
      },
      { parent: this }
    );
 
    new aws.s3.BucketPublicAccessBlock(
      `${name}-public-access-block`,
      {
        bucket: this.bucket.id,
        blockPublicAcls: true,
        blockPublicPolicy: true,
        ignorePublicAcls: true,
        restrictPublicBuckets: true,
      },
      { parent: this }
    );
 
    if (args.allowedPrincipals && args.allowedPrincipals.length > 0) {
      this.createBucketPolicy(name, args.allowedPrincipals);
    }
 
    if (args.enableAccessLogs) {
      this.createAccessLogs(name, args.environment);
    }
 
    this.bucketArn = this.bucket.arn;
    this.registerOutputs({ bucketArn: this.bucketArn });
  }
 
  private createBucketPolicy(name: string, principals: string[]): void {
    new aws.s3.BucketPolicy(
      `${name}-policy`,
      {
        bucket: this.bucket.id,
        policy: this.bucket.arn.apply((arn) =>
          JSON.stringify({
            Version: "2012-10-17",
            Statement: [
              {
                Effect: "Allow",
                Principal: { AWS: principals },
                Action: ["s3:GetObject", "s3:PutObject"],
                Resource: `${arn}/*`,
              },
            ],
          })
        ),
      },
      { parent: this }
    );
  }
 
  private createAccessLogs(name: string, environment: string): void {
    new aws.s3.Bucket(
      `${name}-access-logs`,
      {
        bucket: `${name}-access-logs-${environment}`,
        acl: "log-delivery-write",
        lifecycleRules: [
          {
            enabled: true,
            expiration: { days: 30 },
          },
        ],
      },
      { parent: this }
    );
  }
}
 
// Usage is clean and type-safe
const appData = new SecureBucket("app-data", {
  namePrefix: "myapp-data",
  environment: "production",
  retentionDays: 365,
  enableAccessLogs: true,
  allowedPrincipals: ["arn:aws:iam::123456789:role/app-role"],
});

The SecureBucketArgs interface enforces the contract at compile time. Misspell a property name and the TypeScript compiler catches it before deployment. Terraform validates at plan time, which is slower feedback.

Testing Infrastructure Code

This is where Pulumi's language-first approach pays the biggest dividend. You can unit test infrastructure code with the same tools you use for application code.

tstypescript
import * as pulumi from "@pulumi/pulumi";
import { describe, it, expect, beforeAll } from "vitest";
 
// Mock Pulumi runtime for unit tests
pulumi.runtime.setMocks({
  newResource: (args: pulumi.runtime.MockResourceArgs) => {
    return { id: `${args.name}-id`, state: args.inputs };
  },
  call: (args: pulumi.runtime.MockCallArgs) => {
    return args.inputs;
  },
});
 
describe("SecureBucket", () => {
  let bucket: SecureBucket;
 
  beforeAll(() => {
    bucket = new SecureBucket("test-bucket", {
      namePrefix: "test",
      environment: "testing",
      retentionDays: 30,
    });
  });
 
  it("should enable versioning", async () => {
    const versioning = await new Promise<{ enabled: boolean }>((resolve) =>
      bucket.bucket.versioning.apply((v) => resolve(v!))
    );
    expect(versioning.enabled).toBe(true);
  });
 
  it("should enable encryption", async () => {
    const encryption = await new Promise((resolve) =>
      bucket.bucket.serverSideEncryptionConfiguration.apply((c) =>
        resolve(c)
      )
    );
    expect(encryption).toBeDefined();
  });
 
  it("should set lifecycle rules based on retention", async () => {
    const rules = await new Promise<aws.types.input.s3.BucketLifecycleRule[]>(
      (resolve) =>
        bucket.bucket.lifecycleRules.apply((r) => resolve(r || []))
    );
    expect(rules.length).toBeGreaterThan(0);
    expect(rules[0].noncurrentVersionExpiration?.days).toBe(30);
  });
});

Testing Terraform modules requires integration tests with real infrastructure or complex mocking frameworks. Pulumi's mock system lets you verify resource configuration without deploying anything. This shifts infrastructure bugs left—catching them in CI, not in production.

Dynamic Infrastructure with Loops and Conditionals

HCL's for_each and count handle basic iteration, but complex conditional logic quickly becomes unreadable. Pulumi uses standard language constructs.

tstypescript
// ❌ Terraform: Complex conditional resource creation
// resource "aws_route53_record" "cert_validation" {
//   for_each = {
//     for dvo in aws_acm_certificate.cert.domain_validation_options : dvo.domain_name => {
//       name   = dvo.resource_record_name
//       record = dvo.resource_record_value
//       type   = dvo.resource_record_type
//     }
//   }
//   ...
// }
tstypescript
// ✅ Pulumi: Standard TypeScript patterns
interface ServiceConfig {
  name: string;
  port: number;
  replicas: number;
  publicFacing: boolean;
  healthCheckPath?: string;
}
 
const services: ServiceConfig[] = [
  { name: "api", port: 3000, replicas: 3, publicFacing: true, healthCheckPath: "/health" },
  { name: "worker", port: 4000, replicas: 2, publicFacing: false },
  { name: "gateway", port: 8080, replicas: 2, publicFacing: true, healthCheckPath: "/ping" },
];
 
// Create ECS services with conditional load balancer attachment
for (const service of services) {
  const taskDef = new aws.ecs.TaskDefinition(`${service.name}-task`, {
    family: service.name,
    containerDefinitions: JSON.stringify([
      {
        name: service.name,
        image: `${ecrRepo.repositoryUrl}:${service.name}-latest`,
        portMappings: [{ containerPort: service.port }],
        healthCheck: service.healthCheckPath
          ? {
              command: [
                "CMD-SHELL",
                `curl -f http://localhost:${service.port}${service.healthCheckPath} || exit 1`,
              ],
              interval: 30,
              timeout: 5,
              retries: 3,
            }
          : undefined,
      },
    ]),
  });
 
  const ecsService = new aws.ecs.Service(`${service.name}-service`, {
    cluster: cluster.arn,
    taskDefinition: taskDef.arn,
    desiredCount: service.replicas,
    loadBalancers: service.publicFacing
      ? [
          {
            targetGroupArn: createTargetGroup(service).arn,
            containerName: service.name,
            containerPort: service.port,
          },
        ]
      : undefined,
  });
}

The conditional health check and optional load balancer attachment are natural TypeScript patterns. In HCL, these require nested dynamic blocks and ternary expressions that obscure the intent.

Stack References: Cross-Stack Composition

Large infrastructure splits across multiple stacks (or Terraform state files). Pulumi's stack references provide typed cross-stack data sharing.

tstypescript
// Network stack exports
export const vpcId = vpc.id;
export const privateSubnetIds = privateSubnets.map((s) => s.id);
export const publicSubnetIds = publicSubnets.map((s) => s.id);
 
// Application stack imports from network stack
const networkStack = new pulumi.StackReference("org/network/production");
 
const vpcId = networkStack.getOutput("vpcId");
const privateSubnetIds = networkStack.getOutput("privateSubnetIds");
 
const appCluster = new aws.ecs.Cluster("app-cluster", {});
 
const appService = new aws.ecs.Service("app-service", {
  cluster: appCluster.arn,
  networkConfiguration: {
    subnets: privateSubnetIds as pulumi.Output<string[]>,
    securityGroups: [appSecurityGroup.id],
  },
});

Terraform achieves similar composition with terraform_remote_state data sources, but loses type information. Pulumi stack references carry the output types from the source stack.

When Terraform Still Wins

Pulumi is not universally better. Terraform has legitimate advantages in specific scenarios.

Terraform's plan output is more readable for infrastructure review. The diff shows exactly what will be created, modified, or destroyed in a format that operations teams understand. Pulumi's preview exists but is less mature.

Terraform's ecosystem is larger. More providers, more modules, more community examples. If you are using a niche cloud service, the Terraform provider is more likely to exist and be well-maintained.

tstypescript
// Pulumi can consume Terraform providers via the bridge
// But native Pulumi providers are better when available
import * as cloudflare from "@pulumi/cloudflare";
 
const record = new cloudflare.Record("api-record", {
  zoneId: zone.id,
  name: "api",
  type: "A",
  value: loadBalancer.dnsName,
  proxied: true,
});

For teams with existing Terraform codebases, migration is non-trivial. Pulumi can import Terraform state and even consume Terraform providers through its bridge, but a full migration takes dedicated effort.

Key Takeaways

Infrastructure as code is evolving beyond declarative configuration languages. Pulumi's approach—using real programming languages—unlocks component abstractions, type safety, unit testing, and composition patterns that HCL cannot express.

The choice between Pulumi and Terraform is not binary. Pulumi excels when infrastructure is complex, when you need reusable components with validation, and when your team already thinks in TypeScript or Python. Terraform excels when you value a larger ecosystem, simpler plan review, and broader organizational familiarity.

What matters most is not which tool you pick—it is that your infrastructure is defined in code, version-controlled, reviewed by peers, and tested before deployment. Both tools deliver that fundamental capability. Everything beyond that is about developer experience and organizational fit.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX