Infrastructure as Code with Pulumi: 5 Patterns to Manage Cloud Resources with TypeScript

DevOps

Pulumi: Managing Infrastructure with Real Programming Languages

Terraform uses HCL for configuration—when you need loops, conditions, or reuse, you write a bunch of workarounds. Pulumi lets you define infrastructure with real programming languages like TypeScript, Python, and Go. Loops, conditions, functions, classes—all programming capabilities are available. In 2026, Pulumi supports 80+ cloud providers, Pulumi AI auto-generates infrastructure code, and Pulumi Deployments enables GitOps automation.

This article walks through 5 core patterns, covering the full pipeline from project setup → component abstraction → multi-cloud deployment → state management → GitOps.


Core Concepts

Concept Description
Pulumi IaC tool for defining infrastructure with programming languages
Stack Different environments of the same project (dev/staging/prod)
Resource Abstract representation of a cloud resource
ComponentResource Custom component encapsulating multiple Resources
Provider Cloud provider plugin (AWS/GCP/Azure/K8s)
State Persistent storage of infrastructure state
Output Async value representing a resource's post-creation properties
Config Stack-level configuration key-value pairs

Problem Analysis: 5 Challenges in Pulumi IaC

  1. Learning curve: Mindset shift required when migrating from HCL to TypeScript
  2. Output handling: Outputs can't be used directly as strings; need to understand async chains
  3. State management: Recovery from corrupted or conflicting state files
  4. Component design: How to abstract reusable infrastructure components
  5. Multi-cloud orchestration: Dependencies and coordination across different cloud provider resources

Step-by-Step: 5 Pulumi IaC Patterns

Pattern 1: Pulumi Project & Basic Resources

// index.ts - Pulumi project entry point
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';
import * as awsx from '@pulumi/awsx';

const config = new pulumi.Config();
const environment = config.get('environment') || 'dev';
const vpcCidr = config.get('vpcCidr') || '10.0.0.0/16';

const vpc = new aws.ec2.Vpc('main-vpc', {
  cidrBlock: vpcCidr,
  enableDnsHostnames: true,
  enableDnsSupport: true,
  tags: {
    Name: `${environment}-vpc`,
    Environment: environment,
  },
});

const publicSubnets = [0, 1, 2].map((i) => {
  return new aws.ec2.Subnet(`public-${i}`, {
    vpcId: vpc.id,
    cidrBlock: `10.0.${i}.0/24`,
    availabilityZone: aws.getAvailabilityZonesOutput().names[i],
    mapPublicIpOnLaunch: true,
    tags: { Name: `${environment}-public-${i}`, Environment: environment },
  });
});

const privateSubnets = [0, 1, 2].map((i) => {
  return new aws.ec2.Subnet(`private-${i}`, {
    vpcId: vpc.id,
    cidrBlock: `10.0.${i + 10}.0/24`,
    availabilityZone: aws.getAvailabilityZonesOutput().names[i],
    tags: { Name: `${environment}-private-${i}`, Environment: environment },
  });
});

const cluster = new aws.ecs.Cluster('main-cluster', {
  tags: { Name: `${environment}-cluster`, Environment: environment },
});

const sg = new aws.ec2.SecurityGroup('app-sg', {
  vpcId: vpc.id,
  ingress: [
    { protocol: 'tcp', fromPort: 80, toPort: 80, cidrBlocks: ['0.0.0.0/0'] },
    { protocol: 'tcp', fromPort: 443, toPort: 443, cidrBlocks: ['0.0.0.0/0'] },
  ],
  egress: [
    { protocol: '-1', fromPort: 0, toPort: 0, cidrBlocks: ['0.0.0.0/0'] },
  ],
  tags: { Name: `${environment}-app-sg`, Environment: environment },
});

export const vpcId = vpc.id;
export const clusterArn = cluster.arn;
export const securityGroupId = sg.id;

Pattern 2: ComponentResource Component Abstraction

// components/ecs-service.ts
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';

interface EcsServiceArgs {
  clusterArn: pulumi.Input<string>;
  subnets: pulumi.Input<string>[];
  securityGroups: pulumi.Input<string>[];
  imageName: pulumi.Input<string>;
  cpu: number;
  memory: number;
  port: number;
  desiredCount: number;
  environment: string;
}

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

  constructor(name: string, args: EcsServiceArgs, opts?: pulumi.ComponentResourceOptions) {
    super('custom:ecs:Service', name, {}, opts);

    const alb = new aws.lb.LoadBalancer(`${name}-alb`, {
      subnets: args.subnets,
      securityGroups: args.securityGroups,
      internal: false,
      loadBalancerType: 'application',
      tags: { Name: `${args.environment}-${name}-alb`, Environment: args.environment },
    }, { parent: this });

    const targetGroup = new aws.lb.TargetGroup(`${name}-tg`, {
      port: args.port,
      protocol: 'HTTP',
      vpcId: args.subnets[0],
      targetType: 'ip',
      healthCheck: {
        path: '/health',
        interval: 30,
        timeout: 5,
        healthyThreshold: 2,
        unhealthyThreshold: 3,
      },
      tags: { Name: `${args.environment}-${name}-tg`, Environment: args.environment },
    }, { parent: this });

    const listener = new aws.lb.Listener(`${name}-listener`, {
      loadBalancerArn: alb.arn,
      port: 80,
      protocol: 'HTTP',
      defaultActions: [{
        type: 'forward',
        targetGroupArn: targetGroup.arn,
      }],
    }, { parent: this });

    const taskDefinition = new aws.ecs.TaskDefinition(`${name}-task`, {
      family: `${args.environment}-${name}`,
      networkMode: 'awsvpc',
      requiresCompatibilities: ['FARGATE'],
      cpu: args.cpu.toString(),
      memory: args.memory.toString(),
      containerDefinitions: pulumi.jsonStringify([{
        name: name,
        image: args.imageName,
        essential: true,
        portMappings: [{ containerPort: args.port, protocol: 'tcp' }],
        logConfiguration: {
          logDriver: 'awslogs',
          options: {
            'awslogs-group': `/ecs/${args.environment}-${name}`,
            'awslogs-region': 'us-east-1',
            'awslogs-stream-prefix': 'ecs',
          },
        },
      }]),
    }, { parent: this });

    const service = new aws.ecs.Service(`${name}-svc`, {
      cluster: args.clusterArn,
      desiredCount: args.desiredCount,
      launchType: 'FARGATE',
      taskDefinition: taskDefinition.arn,
      networkConfiguration: {
        awsvpcConfiguration: {
          subnets: args.subnets,
          securityGroups: args.securityGroups,
          assignPublicIp: true,
        },
      },
      loadBalancers: [{
        targetGroupArn: targetGroup.arn,
        containerName: name,
        containerPort: args.port,
      }],
    }, { parent: this });

    this.serviceArn = service.id;
    this.albDns = alb.dnsName;

    this.registerOutputs({
      serviceArn: this.serviceArn,
      albDns: this.albDns,
    });
  }
}

Pattern 3: Multi-Cloud Deployment

// multi-cloud.ts
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';
import * as gcp from '@pulumi/gcp';

const config = new pulumi.Config();
const cloud = config.get('cloud') || 'aws';

function createAwsInfrastructure() {
  const bucket = new aws.s3.Bucket('data-bucket', {
    forceDestroy: true,
    tags: { Name: 'data-bucket' },
  });

  const db = new aws.rds.Instance('database', {
    engine: 'postgres',
    engineVersion: '16.1',
    instanceClass: 'db.t3.micro',
    allocatedStorage: 20,
    username: 'admin',
    password: config.requireSecret('dbPassword'),
    skipFinalSnapshot: true,
  });

  return { bucketArn: bucket.arn, dbEndpoint: db.endpoint };
}

function createGcpInfrastructure() {
  const bucket = new gcp.storage.Bucket('data-bucket', {
    location: 'US',
    forceDestroy: true,
  });

  const db = new gcp.sql.DatabaseInstance('database', {
    databaseVersion: 'POSTGRES_16',
    settings: { tier: 'db-f1-micro' },
    deletionProtection: false,
  });

  return { bucketName: bucket.name, dbConnection: db.connectionName };
}

const infra = cloud === 'aws' ? createAwsInfrastructure() : createGcpInfrastructure();

export const bucketOutput = 'bucketArn' in infra ? infra.bucketArn : infra.bucketName;
export const dbOutput = 'dbEndpoint' in infra ? infra.dbEndpoint : infra.dbConnection;

Pattern 4: State Management & Disaster Recovery

// pulumi.state-config.ts
// Use S3 backend for state storage
const backendConfig = {
  url: 's3://pulumi-state-bucket/my-project',
};

// pulumi stack init my-project-dev
// pulumi config set aws:region us-east-1
// pulumi up
# State lock conflict resolution
pulumi stack import --file backup-state.json

# State file backup and recovery
pulumi stack export --file state-backup-$(date +%Y%m%d).json

# Force unlock (use with caution)
pulumi stack unlock

# Refresh state to sync with actual resources
pulumi refresh

# Destroy all resources
pulumi destroy
// State drift detection
import * as pulumi from '@pulumi/pulumi';

pulumi.runtime.setOptions({
  stackTransform: (args) => {
    args.resource.options.protect = true;
    return Promise.resolve(args);
  },
});

Pattern 5: Pulumi GitOps & Automation

# .github/workflows/pulumi-deploy.yml
name: Pulumi Deploy
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  preview:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - uses: pulumi/actions@v5
        with:
          command: preview
          stack-name: my-project-staging
          comment-on-pr: true
        env:
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_TOKEN }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_KEY }}

  deploy:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    needs: []
    steps:
      - uses: actions/checkout@v4
      - uses: pulumi/actions@v5
        with:
          command: up
          stack-name: my-project-prod
          skip-install: false
        env:
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_TOKEN }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_KEY }}
// Policy as Code - Compliance checks
import * as pulumi from '@pulumi/pulumi';
import { PolicyPack, validateResourceOfType } from '@pulumi/policy';

new PolicyPack('infra-policies', {
  policies: [
    {
      name: 'no-public-egress',
      description: 'Security groups must not allow unrestricted egress',
      enforcementLevel: 'mandatory',
      validateResource: validateResourceOfType(aws.ec2.SecurityGroup, (sg, args, reportViolation) => {
        for (const rule of sg.egress || []) {
          if (rule.cidrBlocks?.includes('0.0.0.0/0')) {
            reportViolation('Security group allows unrestricted egress');
          }
        }
      }),
    },
    {
      name: 'encryption-required',
      description: 'S3 buckets must have encryption enabled',
      enforcementLevel: 'mandatory',
      validateResource: validateResourceOfType(aws.s3.Bucket, (bucket, args, reportViolation) => {
        if (!bucket.serverSideEncryptionConfiguration) {
          reportViolation('S3 bucket must have server-side encryption enabled');
        }
      }),
    },
    {
      name: 'tag-compliance',
      description: 'All resources must have required tags',
      enforcementLevel: 'advisory',
      validateResource: (args, reportViolation) => {
        const tags = args.props.tags || {};
        if (!tags.Environment) {
          reportViolation('Resource missing required tag: Environment');
        }
      },
    },
  ],
});

Pitfall Guide

Pitfall 1: Output Can't Be Used as String

// ❌ Wrong: Direct Output concatenation
const url = `https://${alb.dnsName}/api`;  // Type error

// ✅ Correct: Use pulumi.interpolate or apply
const url = pulumi.interpolate`https://${alb.dnsName}/api`;
// Or
const url = alb.dnsName.apply(dns => `https://${dns}/api`);

Pitfall 2: Resource Dependencies Not Declared

// ❌ Wrong: Implicit dependency, creation order may be wrong
const db = new aws.rds.Instance('db', { ... });
const app = new aws.ecs.Service('app', {
  environment: [{ name: 'DB_HOST', value: db.endpoint }],  // Implicit dependency
});

// ✅ Correct: Explicitly declare dependency
const app = new aws.ecs.Service('app', {
  environment: [{ name: 'DB_HOST', value: db.endpoint }],
}, { dependsOn: [db] });

Pitfall 3: Secrets Stored in Plaintext

// ❌ Wrong: Password in plaintext
const db = new aws.rds.Instance('db', {
  password: 'my-password-123',
});

// ✅ Correct: Use Config Secret
const password = config.requireSecret('dbPassword');
const db = new aws.rds.Instance('db', {
  password: password,
});

Pitfall 4: Resource Protection Not Set

// ❌ Wrong: Production database unprotected, could be accidentally deleted
const db = new aws.rds.Instance('prod-db', {
  skipFinalSnapshot: true,
});

// ✅ Correct: Enable protection
const db = new aws.rds.Instance('prod-db', {
  skipFinalSnapshot: false,
  finalSnapshotIdentifier: 'prod-db-final',
}, { protect: true });

Pitfall 5: State File Not Encrypted

# ❌ Wrong: Local file backend, unencrypted
pulumi login --local

# ✅ Correct: Use Pulumi Cloud or encrypted S3 backend
pulumi login
# Or
pulumi login s3://pulumi-state-bucket?region=us-east-1

Error Troubleshooting

# Error Message Cause Solution
1 Cannot read property of Output Output not using apply Use pulumi.interpolate or apply
2 Resource already exists State out of sync with actual pulumi import existing resource
3 Conflict: stack is locked State lock conflict pulumi stack unlock
4 403 Access Denied Invalid AWS credentials Check AWS_ACCESS_KEY_ID config
5 Provider credential failed Cloud provider auth failed Verify Provider configuration
6 Destroy failed: protected resource Resource is protected pulumi state unprotect
7 Stack not found Stack doesn't exist pulumi stack init to create
8 Plugin not found Provider plugin missing pulumi plugin install
9 Drift detected State drift from actual pulumi refresh to sync
10 Secret exposed in state Secret not encrypted Use requireSecret and encrypted backend

Advanced Optimization

  1. Pulumi AI: Auto-generate infrastructure code from natural language descriptions
  2. Automation API: Embed Pulumi in applications for self-service platforms
  3. Pulumi Deployments: Auto-trigger deployments on Git push
  4. CrossGuard: Policy as Code compliance checks
  5. Pulumi ESC: Centralized environment secrets and config management

Comparison

Dimension Pulumi Terraform CDK for Terraform AWS CDK
Language TS/Python/Go/C# HCL TS/Python/Java TS/Python/Java
State Management Pulumi Cloud/S3 S3/Consul/Cloud Terraform Cloud CloudFormation
Testing Unit + Integration terraform test CDK testing CDK testing
Multi-cloud 80+ providers 3000+ providers 3000+ providers AWS only
Component Reuse Native language Module CDK Construct Construct
Learning Curve Medium Low Medium-High Medium

Summary: Pulumi defines infrastructure with real programming languages, giving IaC the power of loops, conditions, type checking, and unit testing. ComponentResource abstraction + Output async chains + Policy as Code compliance checks form a trinity that evolves infrastructure code from "configuration files" to "engineered code." In 2026, the maturing Pulumi AI and Automation API make infrastructure management more intelligent and automated.


Try these browser-local tools — no sign-up required →

#基础设施即代码#Pulumi#IaC#TypeScript IaC#2026#DevOps