Skip to content
Codeloom
DevOps

Pulumi for Infrastructure as Code with Real Programming Languages

Learn how Pulumi lets you define cloud infrastructure using TypeScript, Python, or Go instead of domain-specific languages.

·6 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • How Pulumi differs from Terraform and CloudFormation
  • Defining AWS infrastructure with TypeScript
  • Using loops, conditionals, and abstractions for DRY infrastructure
  • Managing state, stacks, and secrets in Pulumi

Prerequisites

  • Basic cloud concepts (VPCs, EC2, S3)
  • Familiarity with TypeScript or Python

Why Pulumi

Terraform uses HCL, a domain-specific language that works well until you need a loop with conditional logic, string manipulation, or a reusable abstraction with type checking. HCL can do some of these things, but awkwardly.

Pulumi takes a different approach. You write infrastructure code in TypeScript, Python, Go, C#, or Java. You get IDE autocompletion, type safety, unit testing with your existing test framework, and the full power of a general-purpose language. If you can write it in TypeScript, you can use it to define infrastructure.

Under the hood, Pulumi works similarly to Terraform. It maintains a state file, computes a diff between desired and actual state, and makes API calls to cloud providers to reconcile the difference.

Getting Started

# Install Pulumi
curl -fsSL https://get.pulumi.com | sh

# Create a new project
mkdir my-infra && cd my-infra
pulumi new aws-typescript

# This generates:
# - Pulumi.yaml (project config)
# - Pulumi.dev.yaml (stack config)
# - index.ts (your infrastructure code)
# - package.json
# - tsconfig.json

Defining Resources

Here is a basic S3 bucket and a Lambda function in TypeScript:

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

// Create an S3 bucket
const bucket = new aws.s3.Bucket("data-bucket", {
  versioning: {
    enabled: true,
  },
  serverSideEncryptionConfiguration: {
    rule: {
      applyServerSideEncryptionByDefault: {
        sseAlgorithm: "AES256",
      },
    },
  },
  tags: {
    Environment: pulumi.getStack(),
    ManagedBy: "pulumi",
  },
});

// Create an IAM role for Lambda
const lambdaRole = new aws.iam.Role("api-lambda-role", {
  assumeRolePolicy: JSON.stringify({
    Version: "2012-10-17",
    Statement: [
      {
        Action: "sts:AssumeRole",
        Principal: { Service: "lambda.amazonaws.com" },
        Effect: "Allow",
      },
    ],
  }),
});

// Attach basic execution policy
new aws.iam.RolePolicyAttachment("lambda-basic-execution", {
  role: lambdaRole.name,
  policyArn: aws.iam.ManagedPolicy.AWSLambdaBasicExecutionRole,
});

// Create a Lambda function
const apiFunction = new aws.lambda.Function("api-handler", {
  runtime: "nodejs20.x",
  handler: "index.handler",
  role: lambdaRole.arn,
  code: new pulumi.asset.AssetArchive({
    ".": new pulumi.asset.FileArchive("./lambda"),
  }),
  environment: {
    variables: {
      BUCKET_NAME: bucket.id,
    },
  },
});

// Export outputs
export const bucketName = bucket.id;
export const functionArn = apiFunction.arn;

Run pulumi up to preview and apply changes. Pulumi shows a diff of what will be created, updated, or deleted, just like terraform plan.

The Power of Real Code

Here is where Pulumi shines. You can use standard programming constructs:

Loops

const azs = ["us-east-1a", "us-east-1b", "us-east-1c"];

const subnets = azs.map((az, index) =>
  new aws.ec2.Subnet(`subnet-${az}`, {
    vpcId: vpc.id,
    cidrBlock: `10.0.${index}.0/24`,
    availabilityZone: az,
    tags: {
      Name: `app-subnet-${az}`,
      Tier: "private",
    },
  })
);

Conditionals

const config = new pulumi.Config();
const enableMonitoring = config.getBoolean("enableMonitoring") ?? false;

if (enableMonitoring) {
  new aws.cloudwatch.MetricAlarm("high-cpu", {
    comparisonOperator: "GreaterThanThreshold",
    evaluationPeriods: 2,
    metricName: "CPUUtilization",
    namespace: "AWS/EC2",
    period: 300,
    statistic: "Average",
    threshold: 80,
    alarmActions: [snsTopic.arn],
  });
}

Reusable Abstractions

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

interface WebServiceArgs {
  image: string;
  port: number;
  cpu: number;
  memory: number;
  desiredCount: number;
  subnetIds: pulumi.Input<string>[];
  vpcId: pulumi.Input<string>;
}

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

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

    const taskDef = new aws.ecs.TaskDefinition(`${name}-task`, {
      family: name,
      networkMode: "awsvpc",
      requiresCompatibilities: ["FARGATE"],
      cpu: String(args.cpu),
      memory: String(args.memory),
      containerDefinitions: JSON.stringify([
        {
          name: name,
          image: args.image,
          portMappings: [{ containerPort: args.port }],
          essential: true,
        },
      ]),
    }, { parent: this });

    const service = new aws.ecs.Service(`${name}-service`, {
      cluster: cluster.arn,
      taskDefinition: taskDef.arn,
      desiredCount: args.desiredCount,
      launchType: "FARGATE",
      networkConfiguration: {
        subnets: args.subnetIds,
        assignPublicIp: false,
      },
    }, { parent: this });

    this.url = pulumi.interpolate`http://${loadBalancer.dnsName}`;
    this.registerOutputs({ url: this.url });
  }
}

Now creating a service is a single line:

// index.ts
import { WebService } from "./components/web-service";

const api = new WebService("api", {
  image: "registry.example.com/api:v1.2.0",
  port: 8080,
  cpu: 512,
  memory: 1024,
  desiredCount: 3,
  subnetIds: privateSubnetIds,
  vpcId: vpc.id,
});

export const apiUrl = api.url;

This is a typed, composable, testable abstraction. Try doing this in HCL.

Stacks and Configuration

Pulumi uses stacks to manage multiple environments:

# Create stacks
pulumi stack init staging
pulumi stack init production

# Set per-stack configuration
pulumi config set aws:region us-east-1
pulumi config set instanceCount 2 --stack staging
pulumi config set instanceCount 5 --stack production

# Set secrets (encrypted in state)
pulumi config set --secret dbPassword 'sup3r-s3cret'

Access configuration in code:

const config = new pulumi.Config();
const instanceCount = config.requireNumber("instanceCount");
const dbPassword = config.requireSecret("dbPassword");

Secrets are encrypted in the stack configuration file. Pulumi supports multiple encryption backends: Pulumi Cloud, AWS KMS, GCP KMS, Azure Key Vault, or a local passphrase.

Testing

Since your infrastructure is real code, you can write real tests:

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

// Mock Pulumi runtime
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("Infrastructure", () => {
  let infra: typeof import("./index");

  beforeAll(async () => {
    infra = await import("./index");
  });

  test("bucket has versioning enabled", (done) => {
    pulumi.all([infra.bucketName]).apply(([name]) => {
      expect(name).toBeDefined();
      done();
    });
  });
});

You can also use policy-as-code with Pulumi CrossGuard to enforce rules across all stacks:

new PolicyPack("aws-policies", {
  policies: [
    {
      name: "s3-no-public-read",
      description: "S3 buckets must not have public read access",
      enforcementLevel: "mandatory",
      validateResource: validateResourceOfType(aws.s3.Bucket, (bucket, args, reportViolation) => {
        if (bucket.acl === "public-read" || bucket.acl === "public-read-write") {
          reportViolation("S3 bucket must not be publicly readable");
        }
      }),
    },
  ],
});

State Management

Pulumi stores state in a backend. Options include:

  • Pulumi Cloud (default): managed, includes history, RBAC, and drift detection.
  • S3/GCS/Azure Blob: self-managed, you handle locking and encryption.
  • Local file: for development only.
# Use S3 backend
pulumi login s3://my-pulumi-state-bucket

# Use Pulumi Cloud
pulumi login

When to Choose Pulumi Over Terraform

Choose Pulumi when your team is already proficient in TypeScript, Python, or Go and wants to leverage that expertise. Choose it when your infrastructure logic requires complex conditionals, loops, or abstractions that feel awkward in HCL. Choose it when you want to test infrastructure with your existing test framework.

Stick with Terraform when your team knows HCL well, when you rely on a large ecosystem of community modules, or when your organization has already invested heavily in Terraform tooling and workflows.

Both tools solve the same fundamental problem. The best choice depends on your team’s skills and existing investment.