LabHub

Blog

Pulumi IaC Practical Guide: Cloud Infrastructure Automation with TypeScript

한국어English日本語

Pulumi IaC

Introduction

Infrastructure as Code (IaC) is no longer optional. As of 2025-2026, leading organizations have gone past merely provisioning infrastructure and now treat it like software — applying every software-engineering practice to it, from testing and version control to code review and CI/CD pipelines.

Existing IaC tools, though — Terraform's HCL (HashiCorp Configuration Language) in particular — are not general-purpose programming languages. Try to implement complex conditional branching, iteration logic, type safety, or unit tests and you run into HCL's limits. Pulumi solves this by letting you define infrastructure in general-purpose languages such as TypeScript, Python, Go, C#, and Java.

This article covers everything you need in practice, centered on TypeScript, from Pulumi's core concepts through running it in production. We will work through the comparison with Terraform, building AWS infrastructure, stack management, the Automation API, testing strategy, CI/CD integration, and troubleshooting, with code examples throughout.

Pulumi Core Concepts

Here are the core concepts you have to understand before using Pulumi.

Project

A project is a directory that holds a Pulumi program. The Pulumi.yaml file defines the project root and specifies the project name and the runtime to use (nodejs, python, go, and so on).

Stack

A stack is an independent instance of a project. When you deploy the same program to different environments such as dev, staging, and production, you manage each one as a stack. Every stack has its own configuration values and state.

Resource

A resource is the basic unit of cloud infrastructure. S3 buckets, EC2 instances, VPCs — all of them are resources. In Pulumi they are expressed as instances of TypeScript classes.

State

Pulumi tracks the current state of the deployed resources. State can be stored in Pulumi Cloud (the default), AWS S3, Azure Blob Storage, Google Cloud Storage, the local filesystem, and elsewhere.

Provider

A provider is a plugin that talks to a particular cloud service. There are more than 150 providers, covering AWS, GCP, Azure, Kubernetes, and more.

Output and Input

The properties of a Pulumi resource come back as the Output<T> type. This expresses their asynchronous nature: the value is unknown until the resource is actually created. When you pass such a value to another resource, it is received as the Input<T> type.

import * as aws from '@pulumi/aws'

// Create an S3 bucket
const bucket = new aws.s3.Bucket('my-bucket', {
  website: {
    indexDocument: 'index.html',
  },
})

// bucket.id has the type Output<string>
// It can be passed straight into another resource as an Input
const bucketPolicy = new aws.s3.BucketPolicy('my-bucket-policy', {
  bucket: bucket.id, // Output<string> -> Input<string>, converted automatically
  policy: bucket.arn.apply((arn) =>
    JSON.stringify({
      Version: '2012-10-17',
      Statement: [
        {
          Effect: 'Allow',
          Principal: '*',
          Action: 's3:GetObject',
          Resource: `${arn}/*`,
        },
      ],
    })
  ),
})

// Exporting an Output value surfaces it as a stack output
export const bucketName = bucket.id
export const websiteUrl = bucket.websiteEndpoint

Comparing IaC Tools: Pulumi vs Terraform vs CDK

Here is a comparison of the three major IaC tools from several angles. Use it as a reference for picking the tool that fits your team's stack and requirements.

ItemPulumiTerraformAWS CDK
LanguageTypeScript, Python, Go, C#, Java, YAMLHCL (DSL)TypeScript, Python, Java, C#, Go
Multi-cloud150+ providers: AWS, GCP, Azure, K8s, etc.Thousands of official/community providersAWS only
State managementPulumi Cloud, S3, GCS, Azure Blob, localTerraform Cloud, S3, GCS, local, etc.Delegated to CloudFormation
TestingStandard test frameworks (Jest, Mocha)terraform test (HCL-based)Standard test frameworks (Jest, etc.)
Type safetyTypeScript static type checkingLimited (HCL variable types)TypeScript static type checking
IDE supportVS Code IntelliSense, autocompleteNeeds an HCL pluginVS Code IntelliSense, autocomplete
Learning curveLow if you have programming experienceYou have to learn HCLNeeds AWS plus programming knowledge
State lockingBuilt in (Pulumi Cloud), S3 DynamoDBS3 + DynamoDB, provided by CloudManaged by CloudFormation itself
Drift detectionpulumi refreshterraform planCloudFormation drift detection
Secret managementBuilt-in encryption, ESC supportNeeds a Vault integrationSecrets Manager/SSM integration
Automation APIProgrammatic execution supportedLimited (CLI wrapper)Limited
Community/ecosystemGrowing (GitHub 22k+ stars)Very mature (GitHub 43k+ stars)Within the AWS ecosystem
LicenseApache 2.0 (open source)BSL (Business Source License)Apache 2.0 (open source)

When Should You Choose Pulumi?

When Should You Stay on Terraform?

Environment Setup and Project Initialization

Installing the Pulumi CLI

# macOS
brew install pulumi/tap/pulumi

# Linux (curl)
curl -fsSL https://get.pulumi.com | sh

# Windows (Chocolatey)
choco install pulumi

# Confirm the installation
pulumi version
# v3.x.x

# Check Node.js (required when using TypeScript)
node --version
# v20.x.x or later recommended

# Log in to Pulumi (using Pulumi Cloud)
pulumi login

# Or use an S3 backend
pulumi login s3://my-pulumi-state-bucket

# Use the local filesystem
pulumi login --local

Creating a New Project

# Create a new directory
mkdir my-infra && cd my-infra

# Initialize the project from the AWS TypeScript template
pulumi new aws-typescript

# Configure it at the interactive prompt
# project name: my-infra
# project description: Production infrastructure
# stack name: dev
# aws:region: ap-northeast-2

Let us look at the file structure created after initialization.

my-infra/
├── Pulumi.yaml          # Project metadata
├── Pulumi.dev.yaml      # dev stack configuration
├── index.ts             # Main program
├── package.json         # npm dependencies
└── tsconfig.json        # TypeScript configuration

The contents of the Pulumi.yaml file look like this.

name: my-infra
runtime:
  name: nodejs
  options:
    typescript: true
description: Production infrastructure
config:
  pulumi:tags:
    value:
      pulumi:template: aws-typescript

Here is an example of the Pulumi.dev.yaml stack configuration file.

config:
  aws:region: ap-northeast-2
  my-infra:environment: dev
  my-infra:dbPassword:
    secure: AAABADEFaBCDeFgHiJkLmNoPqRsTuVwXyZ== # Encrypted secret

Building AWS Infrastructure in Practice

Let us build AWS infrastructure in TypeScript that you could genuinely run in production.

VPC and Network Configuration

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.require('environment')

// Create a VPC using awsx (a high-level abstraction)
const vpc = new awsx.ec2.Vpc(`${environment}-vpc`, {
  cidrBlock: '10.0.0.0/16',
  numberOfAvailabilityZones: 3,
  subnetStrategy: awsx.ec2.SubnetAllocationStrategy.Auto,
  enableDnsHostnames: true,
  enableDnsSupport: true,
  subnetSpecs: [
    {
      type: awsx.ec2.SubnetType.Public,
      name: 'public',
      cidrMask: 24,
    },
    {
      type: awsx.ec2.SubnetType.Private,
      name: 'private',
      cidrMask: 24,
    },
    {
      type: awsx.ec2.SubnetType.Isolated,
      name: 'isolated',
      cidrMask: 24,
    },
  ],
  tags: {
    Environment: environment,
    ManagedBy: 'pulumi',
  },
})

// Create security groups
const albSecurityGroup = new aws.ec2.SecurityGroup(`${environment}-alb-sg`, {
  vpcId: vpc.vpcId,
  description: 'Security group for ALB',
  ingress: [
    {
      protocol: 'tcp',
      fromPort: 80,
      toPort: 80,
      cidrBlocks: ['0.0.0.0/0'],
      description: 'HTTP',
    },
    {
      protocol: 'tcp',
      fromPort: 443,
      toPort: 443,
      cidrBlocks: ['0.0.0.0/0'],
      description: 'HTTPS',
    },
  ],
  egress: [
    {
      protocol: '-1',
      fromPort: 0,
      toPort: 0,
      cidrBlocks: ['0.0.0.0/0'],
      description: 'Allow all outbound',
    },
  ],
  tags: {
    Name: `${environment}-alb-sg`,
    Environment: environment,
  },
})

export const vpcId = vpc.vpcId
export const publicSubnetIds = vpc.publicSubnetIds
export const privateSubnetIds = vpc.privateSubnetIds

Deploying an ECS Fargate Service

import * as aws from '@pulumi/aws'
import * as awsx from '@pulumi/awsx'
import * as pulumi from '@pulumi/pulumi'

const config = new pulumi.Config()
const environment = config.require('environment')
const containerPort = config.getNumber('containerPort') || 3000
const cpu = config.getNumber('cpu') || 256
const memory = config.getNumber('memory') || 512
const desiredCount = config.getNumber('desiredCount') || 2

// Create an ECR repository
const repo = new awsx.ecr.Repository(`${environment}-app-repo`, {
  forceDelete: environment !== 'production',
  lifecyclePolicy: {
    rules: [
      {
        description: 'Keep last 10 images',
        maximumNumberOfImages: 10,
        tagStatus: 'any',
      },
    ],
  },
})

// Build and push the Docker image
const image = new awsx.ecr.Image(`${environment}-app-image`, {
  repositoryUrl: repo.url,
  context: '../app',
  platform: 'linux/amd64',
})

// Create an ECS cluster
const cluster = new aws.ecs.Cluster(`${environment}-cluster`, {
  settings: [
    {
      name: 'containerInsights',
      value: 'enabled',
    },
  ],
  tags: {
    Environment: environment,
    ManagedBy: 'pulumi',
  },
})

// ALB + ECS Fargate service (a high-level awsx component)
const service = new awsx.ecs.FargateService(`${environment}-service`, {
  cluster: cluster.arn,
  desiredCount: desiredCount,
  networkConfiguration: {
    subnets: vpc.privateSubnetIds,
    securityGroups: [albSecurityGroup.id],
    assignPublicIp: false,
  },
  taskDefinitionArgs: {
    container: {
      name: 'app',
      image: image.imageUri,
      cpu: cpu,
      memory: memory,
      essential: true,
      portMappings: [
        {
          containerPort: containerPort,
          targetGroup: loadBalancer.defaultTargetGroup,
        },
      ],
      environment: [
        { name: 'NODE_ENV', value: environment },
        { name: 'PORT', value: String(containerPort) },
      ],
      logConfiguration: {
        logDriver: 'awslogs',
        options: {
          'awslogs-group': `/ecs/${environment}-app`,
          'awslogs-region': aws.config.region!,
          'awslogs-stream-prefix': 'ecs',
        },
      },
    },
  },
  tags: {
    Environment: environment,
    ManagedBy: 'pulumi',
  },
})

export const serviceUrl = pulumi.interpolate`http://${loadBalancer.loadBalancer.dnsName}`

Creating an RDS Database

import * as aws from '@pulumi/aws'
import * as pulumi from '@pulumi/pulumi'
import * as random from '@pulumi/random'

const config = new pulumi.Config()
const environment = config.require('environment')
const dbName = config.require('dbName')

// Generate a random password
const dbPassword = new random.RandomPassword(`${environment}-db-password`, {
  length: 32,
  special: true,
  overrideSpecial: '!#$%&*()-_=+[]{}<>:?',
})

// Store the password in Secrets Manager
const dbSecret = new aws.secretsmanager.Secret(`${environment}-db-secret`, {
  name: `${environment}/database/master-password`,
  tags: { Environment: environment },
})

const dbSecretVersion = new aws.secretsmanager.SecretVersion(`${environment}-db-secret-version`, {
  secretId: dbSecret.id,
  secretString: pulumi
    .all([dbPassword.result])
    .apply(([password]) => JSON.stringify({ username: 'admin', password })),
})

// DB subnet group
const dbSubnetGroup = new aws.rds.SubnetGroup(`${environment}-db-subnet`, {
  subnetIds: vpc.isolatedSubnetIds,
  tags: { Environment: environment },
})

// DB security group
const dbSecurityGroup = new aws.ec2.SecurityGroup(`${environment}-db-sg`, {
  vpcId: vpc.vpcId,
  description: 'Security group for RDS',
  ingress: [
    {
      protocol: 'tcp',
      fromPort: 5432,
      toPort: 5432,
      securityGroups: [albSecurityGroup.id],
      description: 'PostgreSQL from ECS',
    },
  ],
  tags: { Name: `${environment}-db-sg`, Environment: environment },
})

// Create the RDS instance
const db = new aws.rds.Instance(`${environment}-postgres`, {
  engine: 'postgres',
  engineVersion: '16.4',
  instanceClass: environment === 'production' ? 'db.r6g.large' : 'db.t4g.micro',
  allocatedStorage: 20,
  maxAllocatedStorage: environment === 'production' ? 100 : 50,
  dbName: dbName,
  username: 'admin',
  password: dbPassword.result,
  dbSubnetGroupName: dbSubnetGroup.name,
  vpcSecurityGroupIds: [dbSecurityGroup.id],
  multiAz: environment === 'production',
  backupRetentionPeriod: environment === 'production' ? 14 : 1,
  deletionProtection: environment === 'production',
  skipFinalSnapshot: environment !== 'production',
  finalSnapshotIdentifier:
    environment === 'production' ? `${environment}-final-snapshot` : undefined,
  storageEncrypted: true,
  performanceInsightsEnabled: environment === 'production',
  tags: {
    Environment: environment,
    ManagedBy: 'pulumi',
  },
})

export const dbEndpoint = db.endpoint
export const dbSecretArn = dbSecret.arn

What stands out in the code above is that it uses TypeScript conditional expressions to apply different settings per environment quite naturally. In HCL you would have to work within the limited syntax of count, for_each, and ternaries; in Pulumi you can use ordinary programming logic as-is.

Stack Management and Environment Separation

Creating and Switching Stacks

# Create a new stack
pulumi stack init staging
pulumi stack init production

# List the stacks
pulumi stack ls
# NAME        LAST UPDATE  RESOURCE COUNT  URL
# dev*        2 minutes    15              https://app.pulumi.com/...
# staging     n/a          n/a             https://app.pulumi.com/...
# production  n/a          n/a             https://app.pulumi.com/...

# Switch stacks
pulumi stack select staging

# Per-stack configuration
pulumi config set environment staging
pulumi config set aws:region ap-northeast-2
pulumi config set desiredCount 2
pulumi config set --secret dbPassword 'super-secret-password'

# Show all configuration
pulumi config
# KEY              VALUE
# aws:region       ap-northeast-2
# dbPassword       [secret]
# desiredCount     2
# environment      staging

Stack References (Cross-Stack Reference)

On large projects you split infrastructure across several projects and connect them through stack references.

import * as pulumi from '@pulumi/pulumi'

// Reference the outputs of the network stack
const networkStack = new pulumi.StackReference('organization/network-infra/production')

// Fetch output values from another stack
const vpcId = networkStack.getOutput('vpcId')
const privateSubnetIds = networkStack.getOutput('privateSubnetIds')

// Use those values in the current stack
const service = new aws.ecs.Service('my-service', {
  networkConfiguration: {
    subnets: privateSubnetIds.apply((ids) => ids as string[]),
    // ...
  },
})

Self-Managed Backend (S3)

To use S3 as the backend instead of Pulumi Cloud, configure it as follows.

# Create the S3 bucket (AWS CLI)
aws s3 mb s3://my-company-pulumi-state --region ap-northeast-2

# Enable bucket versioning (protects the state file)
aws s3api put-bucket-versioning \
  --bucket my-company-pulumi-state \
  --versioning-configuration Status=Enabled

# Enable server-side encryption
aws s3api put-bucket-encryption \
  --bucket my-company-pulumi-state \
  --server-side-encryption-configuration '{
    "Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "aws:kms"}}]
  }'

# Point the Pulumi backend at S3
pulumi login s3://my-company-pulumi-state

# Configure secret encryption with a KMS key
pulumi stack init production \
  --secrets-provider="awskms://alias/pulumi-secrets?region=ap-northeast-2"

When you use the S3 backend, state locking is enabled by default, which stops several processes from modifying the state at once. To upgrade an existing DIY backend to project-scoped stacks, you can use the pulumi state upgrade command.

Pulumi ESC (Environments, Secrets, and Configuration)

Pulumi ESC is a feature for managing per-environment secrets and configuration centrally.

# Example Pulumi ESC environment definition (my-org/production.yaml)
imports:
  - my-org/base-config # Inherit the base configuration

values:
  aws:
    login:
      fn::open::aws-login:
        oidc:
          roleArn: arn:aws:iam::123456789012:role/pulumi-esc-role
          sessionName: pulumi-esc-session

  environmentVariables:
    AWS_ACCESS_KEY_ID: ${aws.login.accessKeyId}
    AWS_SECRET_ACCESS_KEY: ${aws.login.secretAccessKey}
    AWS_SESSION_TOKEN: ${aws.login.sessionToken}

  pulumiConfig:
    aws:region: ap-northeast-2
    environment: production
    dbInstanceClass: db.r6g.large

  secrets:
    fn::open::aws-secrets:
      region: ap-northeast-2
      login: ${aws.login}
      get:
        db-password:
          secretId: production/database/master-password

With ESC you can pull secrets dynamically from AWS OIDC, Azure OIDC, Google Cloud OIDC, HashiCorp Vault, AWS Secrets Manager, and other sources.

Putting the Automation API to Work

The Pulumi Automation API is one of Pulumi's strongest differentiators. It lets you run Pulumi operations programmatically without the CLI, which makes it ideal for platform engineering or for building a self-service infrastructure portal.

An Inline Program Example

import { InlineProgramArgs, LocalWorkspace } from '@pulumi/pulumi/automation'
import * as aws from '@pulumi/aws'

// Define the Pulumi program inline
const pulumiProgram = async () => {
  const bucket = new aws.s3.Bucket('auto-bucket', {
    website: {
      indexDocument: 'index.html',
    },
  })

  return {
    bucketName: bucket.id,
    websiteUrl: bucket.websiteEndpoint,
  }
}

async function deployInfrastructure(stackName: string, region: string) {
  const args: InlineProgramArgs = {
    stackName,
    projectName: 'auto-deploy',
    program: pulumiProgram,
  }

  // Create or select the stack
  const stack = await LocalWorkspace.createOrSelectStack(args)

  // Stack configuration
  await stack.setConfig('aws:region', { value: region })

  console.log('Running pulumi preview...')
  const previewResult = await stack.preview({ onOutput: console.log })
  console.log(`Preview: ${previewResult.changeSummary}`)

  console.log('Running pulumi up...')
  const upResult = await stack.up({ onOutput: console.log })
  console.log(`Update summary: ${JSON.stringify(upResult.summary)}`)
  console.log(`Outputs: ${JSON.stringify(upResult.outputs)}`)

  return upResult.outputs
}

async function destroyInfrastructure(stackName: string) {
  const args: InlineProgramArgs = {
    stackName,
    projectName: 'auto-deploy',
    program: pulumiProgram,
  }

  const stack = await LocalWorkspace.createOrSelectStack(args)

  console.log('Running pulumi destroy...')
  await stack.destroy({ onOutput: console.log })

  console.log('Removing stack...')
  await stack.workspace.removeStack(stackName)
}

// Usage example
;(async () => {
  try {
    const outputs = await deployInfrastructure('dev', 'ap-northeast-2')
    console.log(`Website URL: ${outputs.websiteUrl.value}`)
  } catch (err) {
    console.error(`Error: ${err}`)
    process.exit(1)
  }
})()

Exposing Infrastructure Through an HTTP API

Combine the Automation API with Express.js and you can expose infrastructure provisioning as a REST API.

import express from 'express'
import { InlineProgramArgs, LocalWorkspace } from '@pulumi/pulumi/automation'
import * as aws from '@pulumi/aws'

const app = express()
app.use(express.json())

// POST /api/environments - create a new environment
app.post('/api/environments', async (req, res) => {
  const { name, region, instanceType } = req.body

  try {
    const program = async () => {
      const vpc = new aws.ec2.Vpc(`${name}-vpc`, {
        cidrBlock: '10.0.0.0/16',
        tags: { Name: `${name}-vpc` },
      })

      const subnet = new aws.ec2.Subnet(`${name}-subnet`, {
        vpcId: vpc.id,
        cidrBlock: '10.0.1.0/24',
        tags: { Name: `${name}-subnet` },
      })

      return { vpcId: vpc.id, subnetId: subnet.id }
    }

    const stack = await LocalWorkspace.createOrSelectStack({
      stackName: name,
      projectName: 'self-service-infra',
      program,
    })

    await stack.setConfig('aws:region', { value: region || 'ap-northeast-2' })
    const result = await stack.up({ onOutput: console.log })

    res.json({
      status: 'deployed',
      outputs: result.outputs,
      summary: result.summary,
    })
  } catch (error: any) {
    res.status(500).json({ error: error.message })
  }
})

// DELETE /api/environments/:name - delete an environment
app.delete('/api/environments/:name', async (req, res) => {
  const { name } = req.params

  try {
    const stack = await LocalWorkspace.selectStack({
      stackName: name,
      projectName: 'self-service-infra',
      program: async () => ({}),
    })

    await stack.destroy({ onOutput: console.log })
    await stack.workspace.removeStack(name)

    res.json({ status: 'destroyed' })
  } catch (error: any) {
    res.status(500).json({ error: error.message })
  }
})

// GET /api/environments/:name - look up the environment's state
app.get('/api/environments/:name', async (req, res) => {
  const { name } = req.params

  try {
    const stack = await LocalWorkspace.selectStack({
      stackName: name,
      projectName: 'self-service-infra',
      program: async () => ({}),
    })

    const outputs = await stack.outputs()
    const info = await stack.info()

    res.json({ stack: name, outputs, lastUpdate: info })
  } catch (error: any) {
    res.status(404).json({ error: `Stack ${name} not found` })
  }
})

app.listen(3000, () => console.log('Infra API running on :3000'))

This pattern is extremely useful when building an internal developer platform (IDP). You can create a self-service portal where developers provision infrastructure themselves.

Testing Strategy

One of Pulumi's great advantages is that you can test infrastructure code with standard test frameworks. Broadly, this splits into unit tests and integration tests.

Unit Tests (Jest)

In a unit test you mock the Pulumi engine and verify the infrastructure logic without any real cloud resources.

// __tests__/infra.test.ts
import * as pulumi from '@pulumi/pulumi'

// Mock the Pulumi runtime - this must be set up before the imports
pulumi.runtime.setMocks({
  newResource: (args: pulumi.runtime.MockResourceArgs): { id: string; state: any } => {
    // Mocked responses per resource type
    switch (args.type) {
      case 'aws:s3/bucket:Bucket':
        return {
          id: `${args.name}-id`,
          state: {
            ...args.inputs,
            arn: `arn:aws:s3:::${args.name}`,
            bucket: args.name,
            websiteEndpoint: `${args.name}.s3-website.ap-northeast-2.amazonaws.com`,
          },
        }
      case 'aws:ec2/securityGroup:SecurityGroup':
        return {
          id: `sg-${args.name}`,
          state: {
            ...args.inputs,
            arn: `arn:aws:ec2:ap-northeast-2:123456789012:security-group/sg-${args.name}`,
          },
        }
      default:
        return {
          id: `${args.name}-id`,
          state: args.inputs,
        }
    }
  },
  call: (args: pulumi.runtime.MockCallArgs) => {
    return args.inputs
  },
})

// Import the infrastructure code under test (import after the mocks are set up)
import { bucket, securityGroup } from '../index'

describe('Infrastructure Tests', () => {
  test('the S3 bucket should have website configuration', async () => {
    const websiteConfig = await new Promise((resolve) =>
      bucket.website.apply((website) => resolve(website))
    )
    expect(websiteConfig).toBeDefined()
  })

  test('the S3 bucket name should include the environment prefix', async () => {
    const bucketName = await new Promise((resolve) => bucket.id.apply((name) => resolve(name)))
    expect(bucketName).toContain('dev')
  })

  test('the security group should allow port 443', async () => {
    const ingress = await new Promise((resolve) =>
      securityGroup.ingress.apply((rules) => resolve(rules))
    )
    const httpsRule = (ingress as any[]).find((r: any) => r.fromPort === 443)
    expect(httpsRule).toBeDefined()
    expect(httpsRule.protocol).toBe('tcp')
  })

  test('the security group should not open SSH (22) to 0.0.0.0/0', async () => {
    const ingress = await new Promise((resolve) =>
      securityGroup.ingress.apply((rules) => resolve(rules))
    )
    const sshRule = (ingress as any[]).find(
      (r: any) => r.fromPort === 22 && r.cidrBlocks?.includes('0.0.0.0/0')
    )
    expect(sshRule).toBeUndefined()
  })
})

Write the Jest configuration file as well.

// jest.config.ts
import type { Config } from 'jest'

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  testMatch: ['**/__tests__/**/*.test.ts'],
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
  transform: {
    '^.+\\.tsx?$': 'ts-jest',
  },
  // Prevents Pulumi Output timeouts
  testTimeout: 30000,
}

export default config

Policy as Code (CrossGuard)

With Pulumi CrossGuard you can define policy as code and have it verified automatically at deployment time.

// policy-pack/index.ts
import * as policy from '@pulumi/policy'

new policy.PolicyPack('security-policies', {
  policies: [
    {
      name: 's3-no-public-read',
      description: 'S3 buckets must not grant public read access',
      enforcementLevel: 'mandatory',
      validateResource: policy.validateResourceOfType(
        'aws:s3/bucket:Bucket',
        (bucket, args, reportViolation) => {
          if (bucket.acl === 'public-read' || bucket.acl === 'public-read-write') {
            reportViolation('This S3 bucket has a public ACL set.')
          }
        }
      ),
    },
    {
      name: 'rds-encryption-required',
      description: 'RDS instances must be encrypted',
      enforcementLevel: 'mandatory',
      validateResource: policy.validateResourceOfType(
        'aws:rds/instance:Instance',
        (instance, args, reportViolation) => {
          if (!instance.storageEncrypted) {
            reportViolation('Storage encryption is not enabled on this RDS instance.')
          }
        }
      ),
    },
    {
      name: 'ec2-no-public-ip',
      description: 'EC2 instances must not be assigned a public IP',
      enforcementLevel: 'advisory',
      validateResource: policy.validateResourceOfType(
        'aws:ec2/instance:Instance',
        (instance, args, reportViolation) => {
          if (instance.associatePublicIpAddress) {
            reportViolation('This EC2 instance has a public IP assigned.')
          }
        }
      ),
    },
  ],
})

CI/CD Pipeline Integration

GitHub Actions Workflow

Pulumi's official GitHub Actions let you build a PR-based workflow for infrastructure changes.

# .github/workflows/pulumi-preview.yml
name: Pulumi Preview

on:
  pull_request:
    branches: [main]
    paths:
      - 'infra/**'

env:
  PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
  AWS_REGION: ap-northeast-2

jobs:
  preview:
    name: Pulumi Preview
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infra

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: infra/package-lock.json

      # A plugin cache speeds up CI
      - uses: actions/cache@v4
        with:
          path: |
            ~/.pulumi/plugins
            ~/.pulumi/policies
          key: ${{ runner.os }}-pulumi-${{ hashFiles('infra/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-pulumi-

      - run: npm ci

      # Run the unit tests
      - name: Run Unit Tests
        run: npm test

      # Pulumi Preview
      - uses: pulumi/actions@v6
        with:
          command: preview
          stack-name: organization/my-infra/staging
          work-dir: infra
          comment-on-pr: true
          comment-on-summary: true
# .github/workflows/pulumi-deploy.yml
name: Pulumi Deploy

on:
  push:
    branches: [main]
    paths:
      - 'infra/**'

env:
  PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
  AWS_REGION: ap-northeast-2

jobs:
  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    environment: staging
    defaults:
      run:
        working-directory: infra

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: infra/package-lock.json

      - uses: actions/cache@v4
        with:
          path: |
            ~/.pulumi/plugins
            ~/.pulumi/policies
          key: ${{ runner.os }}-pulumi-${{ hashFiles('infra/package-lock.json') }}

      - run: npm ci

      - uses: pulumi/actions@v6
        with:
          command: up
          stack-name: organization/my-infra/staging
          work-dir: infra

  deploy-production:
    name: Deploy to Production
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production # GitHub Environment (allows manual approval)

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: infra/package-lock.json

      - uses: actions/cache@v4
        with:
          path: |
            ~/.pulumi/plugins
            ~/.pulumi/policies
          key: ${{ runner.os }}-pulumi-${{ hashFiles('infra/package-lock.json') }}

      - run: npm ci
        working-directory: infra

      - uses: pulumi/actions@v6
        with:
          command: up
          stack-name: organization/my-infra/production
          work-dir: infra

Install the GitHub App and a summary of the resource changes is posted automatically as a comment on the PR, which makes it easy for reviewers to grasp the impact of an infrastructure change.

Troubleshooting

1. Update Conflict (State Conflict)

Symptom: error: the stack is currently locked by 1 lock(s) or conflict: another update is in progress

Cause: this happens when another user is updating the same stack, or when a previous update terminated abnormally.

Fix:

# Cancel the current update (only when nobody else is working on it)
pulumi cancel

# Check the state
pulumi stack --show-urns

2. Interrupted Update

Symptom: error: update interrupted, or resources left in a pending state

Fix:

# Synchronize the state with the cloud provider
pulumi refresh --yes

# Any pending operations are cleaned up automatically
# After that, retry the deployment as normal
pulumi up

3. Resource Already Exists

Symptom: error: resource already exists - a collision with a resource created outside Pulumi

Fix:

# Bring the existing resource into Pulumi state (import)
pulumi import aws:s3/bucket:Bucket my-bucket my-existing-bucket-name

# Or use the import option in code
const existingBucket = new aws.s3.Bucket(
  'my-bucket',
  {
    bucket: 'my-existing-bucket-name',
  },
  {
    import: 'my-existing-bucket-name', // import the existing resource
  }
)

4. Problems Accessing Output Values

Symptom: a Calling [toString] on an [Output<T>] warning, or [object Object] in the output

Cause: this happens when you try to use an Output&lt;T&gt; value directly as a string

Fix:

// The wrong way
const url = `http://${bucket.websiteEndpoint}` // prints [object Object]

// The right way 1: the apply method
const url = bucket.websiteEndpoint.apply((ep) => `http://${ep}`)

// The right way 2: the pulumi.interpolate tagged template
const url = pulumi.interpolate`http://${bucket.websiteEndpoint}`

// The right way 3: combining several Outputs with pulumi.all
const combined = pulumi.all([bucket.id, bucket.arn]).apply(([id, arn]) => {
  return { id, arn }
})

5. Provider Version Conflicts

Symptom: a failed to load plugin or version mismatch error

Fix:

# Clear the plugin cache
rm -rf ~/.pulumi/plugins

# Reinstall the dependencies
npm install

# Pin a specific provider version
npm install @pulumi/aws@6.x.x --save-exact

# Install the Pulumi plugin
pulumi plugin install resource aws v6.x.x

6. State File Corruption

Symptom: checkpoint file is not valid, or unexpected resource state

Fix:

# Export the state file (backup)
pulumi stack export > state-backup.json

# Verify and edit the state file
# You can remove or repair the problem resource by hand

# Import the repaired state file
pulumi stack import < state-fixed.json

# Or remove a specific resource from the state
pulumi state delete 'urn:pulumi:dev::my-infra::aws:s3/bucket:Bucket::my-bucket'

Production Checklist

Check the following items before adopting Pulumi in production.

State Management

Secret Management

Code Quality

CI/CD

Operations

Security

Failure Cases and Recovery Procedures

Case 1: Accidentally Deleting a Production RDS Instance

Situation: someone meant to run pulumi destroy against the staging stack, but ran it while the production stack was selected, and the RDS instance was deleted.

Recovery procedure:

# 1. Check the stack state immediately
pulumi stack select production
pulumi stack --show-urns

# 2. Look for the RDS final snapshot (if deletionProtection was not set)
aws rds describe-db-snapshots \
  --db-instance-identifier production-postgres \
  --query 'DBSnapshots[*].{ID:DBSnapshotIdentifier,Time:SnapshotCreateTime}' \
  --output table

# 3. Restore from the snapshot
aws rds restore-db-instance-from-db-snapshot \
  --db-instance-identifier production-postgres-restored \
  --db-snapshot-identifier production-final-snapshot

# 4. Import it back into Pulumi state
pulumi import aws:rds/instance:Instance production-postgres production-postgres-restored

Prevention:

// Always enable deletionProtection
const db = new aws.rds.Instance(
  'production-postgres',
  {
    // ... settings ...
    deletionProtection: true, // guards against accidental deletion
  },
  {
    protect: true, // protected at the Pulumi level as well
  }
)

Case 2: The State File and the Real Resources Diverge (Drift)

Situation: a security group rule was changed by hand in the AWS console, was never reflected in the Pulumi state, and caused a conflict on the next deployment

Recovery procedure:

# 1. Check for drift
pulumi refresh --diff

# Example output:
# ~ aws:ec2/securityGroup:SecurityGroup (update)
#   ~ ingress: [
#       + { fromPort: 8080, toPort: 8080, ... }  # a rule added by hand
#     ]

# 2-A. Reflect the real state in the code (apply the manual change to the code)
# After editing the code:
pulumi refresh --yes
pulumi up

# 2-B. Restore the state described by the code (revert the manual change)
pulumi up --yes  # running up without refresh restores what the code says

Prevention: set a team rule that forbids manual changes in the AWS console and requires every change to go through code. Detecting drift automatically with AWS Config Rules or Pulumi CrossGuard is another good approach.

Case 3: Concurrent Deployment Conflicts in CI/CD

Situation: two PRs merged at nearly the same moment, two pulumi up runs executed concurrently, and a state conflict resulted

Recovery procedure:

# 1. Resolve the conflict - check the lock first
pulumi cancel  # cancel the pending update

# 2. Synchronize the state with refresh
pulumi refresh --yes

# 3. Deploy again
pulumi up --yes

Prevention: use the concurrency setting in GitHub Actions to prevent concurrent deployments.

# .github/workflows/pulumi-deploy.yml
concurrency:
  group: pulumi-${{ github.ref }}
  cancel-in-progress: false # do not cancel a deployment already in progress

Case 4: Preventing Resource Recreation During a Large Refactor

Situation: after changing a resource's name or structure, Pulumi wants to delete the existing resource and create a new one (replace)

Recovery procedure:

# 1. Review the changes with preview
pulumi preview --diff

# 2. Use aliases when renaming a resource
// Existing code: new aws.s3.Bucket("old-name", {...})
// After the change:
const bucket = new aws.s3.Bucket(
  'new-name',
  {
    // ... settings ...
  },
  {
    aliases: [{ name: 'old-name' }], // declare the previous name as an alias
  }
)
# 3. Or rename the resource directly in the state
pulumi state rename 'urn:pulumi:dev::project::aws:s3/bucket:Bucket::old-name' 'new-name'

References

Comments

No comments yet.

Sign in to leave a comment