Available for day contractsFrom 21st September I have availability for day and half day contracts. Please contact for more information.

Contact →
mikepreston.org

Pulumi

Modern Infrastructure as Code platform supporting multiple programming languages (Python, TypeScript, Go, C#, Java) for cloud resource provisioning.

Pulumi

Modern Infrastructure as Code platform supporting multiple programming languages (Python, TypeScript, Go, C#, Java) for cloud resource provisioning.

Overview

Pulumi enables infrastructure definition using familiar programming languages instead of domain-specific languages. It provides full access to language features like loops, conditionals, functions, and classes whilst managing state and orchestrating deployments across cloud providers. Pulumi uses a declarative execution model where you describe desired infrastructure state, and the engine determines the necessary changes.

ApproveRejectStack UpdatesWrite CodePython/TypeScript/etcpulumi previewReview Planpulumi upResources CreatedState Updated inBackendOutputs AvailableApproveRejectStack UpdatesWrite CodePython/TypeScript/etcpulumi previewReview Planpulumi upResources CreatedState Updated inBackendOutputs Available

Key Architecture

Cloud ProvidersBackendPulumi ServiceDeveloper EnvironmentPulumi ProgramLanguage HostPython/Node/etcDeployment EngineResource ProvidersState StorageS3/Azure/ServiceSecrets EncryptionAWSAzureGCPKubernetesCloud ProvidersBackendPulumi ServiceDeveloper EnvironmentPulumi ProgramLanguage HostPython/Node/etcDeployment EngineResource ProvidersState StorageS3/Azure/ServiceSecrets EncryptionAWSAzureGCPKubernetes

Project and Stack Structure

Projects are the basic unit of organisation in Pulumi, containing program code and configuration. Stacks represent isolated, independently configurable instances of a project (e.g., dev, staging, prod).

Key Concepts

  • Project - Directory containing Pulumi.yaml and program code
  • Stack - Isolated instance with its own state and configuration
  • Program - Code that declares desired infrastructure state
  • Resources - Cloud infrastructure components (VMs, databases, networks)

Project Initialisation

# Create new project with template
pulumi new aws-python
pulumi new azure-typescript
pulumi new kubernetes-go

# Create from scratch
mkdir my-infrastructure && cd my-infrastructure
pulumi new --name my-project --stack dev

# List available templates
pulumi new --list

Project Structure

Pulumi.yaml (project file):

name: my-infrastructure
runtime:
  name: python
  options:
    virtualenv: venv
description: Production infrastructure for web application

Pulumi.dev.yaml (stack configuration):

config:
  aws:region: eu-west-1
  my-infrastructure:instance_count: "3"
  my-infrastructure:db_password:
    secure: AAABAKqKdJ7r...  # Encrypted secret

Stack Management

# Create new stack
pulumi stack init staging

# List all stacks
pulumi stack ls

# Select active stack
pulumi stack select production

# View stack outputs
pulumi stack output

# Export/import stack state
pulumi stack export --file stack.json
pulumi stack import --file stack.json

# Delete stack
pulumi stack rm dev

Configuration

# Set configuration values
pulumi config set aws:region eu-west-2
pulumi config set instanceCount 5

# Set secrets (encrypted in state)
pulumi config set --secret dbPassword MyP@ssw0rd
pulumi config set --secret apiKey --

# Get configuration
pulumi config get aws:region
pulumi config get dbPassword --show-secrets

# List all config
pulumi config

Examples

Python Project Structure:

my-infrastructure/
├── Pulumi.yaml
├── Pulumi.dev.yaml
├── Pulumi.prod.yaml
├── __main__.py
├── requirements.txt
├── components/
│   ├── __init__.py
│   ├── network.py
│   └── database.py
└── venv/

TypeScript Project Structure:

my-infrastructure/
├── Pulumi.yaml
├── Pulumi.dev.yaml
├── Pulumi.prod.yaml
├── index.ts
├── package.json
├── tsconfig.json
└── components/
    ├── network.ts
    └── database.ts

Multi-Language IaC Patterns

Pulumi supports defining infrastructure in multiple languages, each with full access to language ecosystems and tooling.

Key Concepts

  • Language Host - Runtime that executes your program (Node.js, Python, .NET, Go, Java)
  • SDK - Language-specific libraries for cloud providers
  • Resource Registration - Process where program declares resources to engine
  • Inputs/Outputs - Asynchronous values for resource properties

Python Patterns

import pulumi
import pulumi_aws as aws

# Get configuration
config = pulumi.Config()
instance_type = config.get("instanceType") or "t3.micro"
db_password = config.require_secret("dbPassword")

# Create VPC
vpc = aws.ec2.Vpc("app-vpc",
    cidr_block="10.0.0.0/16",
    enable_dns_hostnames=True,
    tags={"Name": "app-vpc"}
)

# Create subnet
subnet = aws.ec2.Subnet("app-subnet",
    vpc_id=vpc.id,
    cidr_block="10.0.1.0/24",
    availability_zone="eu-west-1a",
    tags={"Name": "app-subnet"}
)

# Create security group with dynamic rules
def create_ingress_rule(port, description):
    return aws.ec2.SecurityGroupIngressArgs(
        protocol="tcp",
        from_port=port,
        to_port=port,
        cidr_blocks=["0.0.0.0/0"],
        description=description
    )

security_group = aws.ec2.SecurityGroup("web-sg",
    vpc_id=vpc.id,
    description="Allow web traffic",
    ingress=[
        create_ingress_rule(80, "HTTP"),
        create_ingress_rule(443, "HTTPS"),
        create_ingress_rule(22, "SSH")
    ],
    egress=[aws.ec2.SecurityGroupEgressArgs(
        protocol="-1",
        from_port=0,
        to_port=0,
        cidr_blocks=["0.0.0.0/0"]
    )]
)

# Create EC2 instances with loop
instances = []
for i in range(config.get_int("instanceCount") or 2):
    instance = aws.ec2.Instance(f"web-{i}",
        ami="ami-0c55b159cbfafe1f0",
        instance_type=instance_type,
        subnet_id=subnet.id,
        vpc_security_group_ids=[security_group.id],
        tags={"Name": f"web-server-{i}"}
    )
    instances.append(instance)

# Apply transformation to output
public_ips = [instance.public_ip for instance in instances]

# Export outputs
pulumi.export("vpc_id", vpc.id)
pulumi.export("instance_ips", public_ips)
pulumi.export("security_group_id", security_group.id)

TypeScript Patterns

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

// Get configuration
const config = new pulumi.Config();
const instanceType = config.get("instanceType") || "t3.micro";
const dbPassword = config.requireSecret("dbPassword");

// Create VPC
const vpc = new aws.ec2.Vpc("app-vpc", {
    cidrBlock: "10.0.0.0/16",
    enableDnsHostnames: true,
    tags: { Name: "app-vpc" }
});

// Create subnet
const subnet = new aws.ec2.Subnet("app-subnet", {
    vpcId: vpc.id,
    cidrBlock: "10.0.1.0/24",
    availabilityZone: "eu-west-1a",
    tags: { Name: "app-subnet" }
});

// Helper function for security group rules
function createIngressRule(port: number, description: string): aws.types.input.ec2.SecurityGroupIngress {
    return {
        protocol: "tcp",
        fromPort: port,
        toPort: port,
        cidrBlocks: ["0.0.0.0/0"],
        description: description
    };
}

// Create security group
const securityGroup = new aws.ec2.SecurityGroup("web-sg", {
    vpcId: vpc.id,
    description: "Allow web traffic",
    ingress: [
        createIngressRule(80, "HTTP"),
        createIngressRule(443, "HTTPS"),
        createIngressRule(22, "SSH")
    ],
    egress: [{
        protocol: "-1",
        fromPort: 0,
        toPort: 0,
        cidrBlocks: ["0.0.0.0/0"]
    }]
});

// Create EC2 instances with loop
const instanceCount = config.getNumber("instanceCount") || 2;
const instances: aws.ec2.Instance[] = [];

for (let i = 0; i < instanceCount; i++) {
    const instance = new aws.ec2.Instance(`web-${i}`, {
        ami: "ami-0c55b159cbfafe1f0",
        instanceType: instanceType,
        subnetId: subnet.id,
        vpcSecurityGroupIds: [securityGroup.id],
        tags: { Name: `web-server-${i}` }
    });
    instances.push(instance);
}

// Apply transformation to output (handling Output<T> types)
const publicIps = pulumi.all(instances.map(i => i.publicIp));

// Export outputs
export const vpcId = vpc.id;
export const instanceIps = publicIps;
export const securityGroupId = securityGroup.id;

// Interpolate outputs for dependent resources
const loadBalancer = new aws.lb.LoadBalancer("app-lb", {
    subnets: [subnet.id],
    securityGroups: [securityGroup.id],
    tags: { Name: "app-lb" }
});

Working with Outputs

Outputs are asynchronous values that represent resource properties not known until deployment.

Python:

import pulumi

# Create output from value
output = pulumi.Output.from_input("hello")

# Transform output
upper_output = output.apply(lambda x: x.upper())

# Combine multiple outputs
combined = pulumi.Output.all(vpc.id, subnet.id).apply(
    lambda args: f"VPC: {args[0]}, Subnet: {args[1]}"
)

# Handle secret outputs
secret_output = pulumi.Output.secret("sensitive-value")

TypeScript:

import * as pulumi from "@pulumi/pulumi";

// Create output from value
const output = pulumi.output("hello");

// Transform output
const upperOutput = output.apply(x => x.toUpperCase());

// Combine multiple outputs
const combined = pulumi.all([vpc.id, subnet.id]).apply(
    ([vpcId, subnetId]) => `VPC: ${vpcId}, Subnet: ${subnetId}`
);

// Handle secret outputs
const secretOutput = pulumi.secret("sensitive-value");

State Backends and Secrets

Pulumi stores infrastructure state in backends (Pulumi Service, self-managed cloud storage, or local files) and encrypts secrets using provider-specific or custom encryption.

Key Concepts

  • State - Record of deployed resources and their properties
  • Backend - Storage location for state (Service, S3, Azure Blob, GCS, local)
  • Secrets Provider - Encryption mechanism (passphrase, AWS KMS, Azure KeyVault, GCP KMS, HashiCorp Vault)
  • Checkpoints - State snapshots taken during updates

Backend Configuration

# Login to Pulumi Service (default)
pulumi login

# Login to self-managed backend
pulumi login s3://my-pulumi-state-bucket
pulumi login azblob://my-container
pulumi login gs://my-pulumi-state-bucket
pulumi login file://~/.pulumi/local

# Use local file backend
pulumi login --local

# Logout from current backend
pulumi logout

# View current backend
pulumi whoami

Self-Managed Backend Setup

AWS S3 Backend:

# Create S3 bucket for state
aws s3 mb s3://company-pulumi-state --region eu-west-1
aws s3api put-bucket-versioning \
    --bucket company-pulumi-state \
    --versioning-configuration Status=Enabled

# Login to backend
pulumi login s3://company-pulumi-state

# Set encryption provider
pulumi stack init dev --secrets-provider="awskms://alias/pulumi-secrets?region=eu-west-1"

Azure Blob Backend:

# Create storage account and container
az storage account create \
    --name companypulumistate \
    --resource-group pulumi-state-rg \
    --location ukwest

az storage container create \
    --name pulumi-state \
    --account-name companypulumistate

# Login to backend
pulumi login azblob://pulumi-state

# Set encryption provider
pulumi stack init dev --secrets-provider="azurekeyvault://mykeyvault.vault.azure.net/keys/pulumi"

Secrets Management

# Set secret with default encryption (passphrase)
pulumi config set --secret dbPassword MySecretPassword

# Set passphrase for stack
export PULUMI_CONFIG_PASSPHRASE="my-strong-passphrase"
pulumi stack init dev

# Use cloud provider KMS
pulumi stack init dev --secrets-provider="awskms://arn:aws:kms:eu-west-1:123456789:key/abc-123"
pulumi stack init dev --secrets-provider="gcpkms://projects/my-project/locations/europe-west2/keyRings/pulumi/cryptoKeys/pulumi-secrets"

# Use HashiCorp Vault
pulumi stack init dev --secrets-provider="hashivault://mykey"

# Change secrets provider for existing stack
pulumi stack change-secrets-provider "azurekeyvault://mykeyvault.vault.azure.net/keys/pulumi"

Examples

Python - Using Secrets:

import pulumi
import pulumi_aws as aws

config = pulumi.Config()

# Get secret from config
db_password = config.require_secret("dbPassword")
api_key = config.require_secret("apiKey")

# Create RDS instance with secret password
db = aws.rds.Instance("app-db",
    engine="postgres",
    instance_class="db.t3.micro",
    allocated_storage=20,
    username="admin",
    password=db_password,  # Encrypted in state
    skip_final_snapshot=True
)

# Store secret in AWS Secrets Manager
secret = aws.secretsmanager.Secret("app-secret",
    name="app-api-key"
)

secret_version = aws.secretsmanager.SecretVersion("app-secret-version",
    secret_id=secret.id,
    secret_string=api_key
)

# Export secret output (remains encrypted)
pulumi.export("db_endpoint", db.endpoint)
pulumi.export("secret_arn", secret.arn)

TypeScript - State Export/Import:

import * as pulumi from "@pulumi/pulumi";

// Stack reference to access outputs from another stack
const infraStack = new pulumi.StackReference("org/infrastructure/prod");
const vpcId = infraStack.getOutput("vpcId");
const subnetIds = infraStack.getOutput("subnetIds");

// Use outputs from referenced stack
import * as aws from "@pulumi/aws";

const instance = new aws.ec2.Instance("app-server", {
    ami: "ami-0c55b159cbfafe1f0",
    instanceType: "t3.micro",
    subnetId: subnetIds.apply(ids => ids[0]),
    vpcSecurityGroupIds: [infraStack.getOutput("securityGroupId")]
});

State Management

# View state
pulumi stack export

# Refresh state from cloud provider
pulumi refresh

# Cancel in-progress update
pulumi cancel

# View update history
pulumi history

# Rollback to previous deployment
pulumi stack export --version 5 > previous.json
pulumi stack import --file previous.json

# Repair corrupted state
pulumi stack export --file backup.json
# Edit backup.json manually
pulumi stack import --file backup.json

# Remove resource from state (without deleting cloud resource)
pulumi state delete 'urn:pulumi:dev::my-app::aws:ec2/instance:Instance::web-server'

Component Resources and Modules

Component resources encapsulate multiple related resources into reusable abstractions. They enable modular infrastructure code and enforce organisational standards.

Key Concepts

  • Component Resource - Custom resource grouping multiple child resources
  • Resource Options - Settings like dependencies, protection, providers
  • Custom Resource - Direct cloud resource (EC2, S3, etc.)
  • Transformations - Automatic modifications applied to child resources

Component Resource Pattern

Python Component:

import pulumi
import pulumi_aws as aws
from typing import Optional

class VpcNetworkArgs:
    def __init__(self,
                 cidr_block: str,
                 availability_zones: list[str],
                 enable_nat_gateway: bool = True,
                 tags: Optional[dict] = None):
        self.cidr_block = cidr_block
        self.availability_zones = availability_zones
        self.enable_nat_gateway = enable_nat_gateway
        self.tags = tags or {}

class VpcNetwork(pulumi.ComponentResource):
    """
    Multi-AZ VPC with public and private subnets, NAT gateways, and route tables.
    """

    def __init__(self,
                 name: str,
                 args: VpcNetworkArgs,
                 opts: Optional[pulumi.ResourceOptions] = None):
        super().__init__("custom:network:VpcNetwork", name, {}, opts)

        # Child resource options to set parent
        child_opts = pulumi.ResourceOptions(parent=self)

        # Create VPC
        self.vpc = aws.ec2.Vpc(f"{name}-vpc",
            cidr_block=args.cidr_block,
            enable_dns_hostnames=True,
            enable_dns_support=True,
            tags={**args.tags, "Name": f"{name}-vpc"},
            opts=child_opts
        )

        # Create Internet Gateway
        self.igw = aws.ec2.InternetGateway(f"{name}-igw",
            vpc_id=self.vpc.id,
            tags={**args.tags, "Name": f"{name}-igw"},
            opts=child_opts
        )

        # Create public and private subnets per AZ
        self.public_subnets = []
        self.private_subnets = []
        self.nat_gateways = []

        for i, az in enumerate(args.availability_zones):
            # Public subnet
            public_subnet = aws.ec2.Subnet(f"{name}-public-{i}",
                vpc_id=self.vpc.id,
                cidr_block=f"10.0.{i}.0/24",
                availability_zone=az,
                map_public_ip_on_launch=True,
                tags={**args.tags, "Name": f"{name}-public-{az}", "Type": "public"},
                opts=child_opts
            )
            self.public_subnets.append(public_subnet)

            # Private subnet
            private_subnet = aws.ec2.Subnet(f"{name}-private-{i}",
                vpc_id=self.vpc.id,
                cidr_block=f"10.0.{i + 10}.0/24",
                availability_zone=az,
                tags={**args.tags, "Name": f"{name}-private-{az}", "Type": "private"},
                opts=child_opts
            )
            self.private_subnets.append(private_subnet)

            # NAT Gateway (if enabled)
            if args.enable_nat_gateway:
                eip = aws.ec2.Eip(f"{name}-nat-eip-{i}",
                    domain="vpc",  # `vpc=True` is deprecated; use domain
                    tags={**args.tags, "Name": f"{name}-nat-eip-{az}"},
                    opts=child_opts
                )

                nat = aws.ec2.NatGateway(f"{name}-nat-{i}",
                    subnet_id=public_subnet.id,
                    allocation_id=eip.id,
                    tags={**args.tags, "Name": f"{name}-nat-{az}"},
                    opts=child_opts
                )
                self.nat_gateways.append(nat)

        # Public route table
        public_rt = aws.ec2.RouteTable(f"{name}-public-rt",
            vpc_id=self.vpc.id,
            routes=[aws.ec2.RouteTableRouteArgs(
                cidr_block="0.0.0.0/0",
                gateway_id=self.igw.id
            )],
            tags={**args.tags, "Name": f"{name}-public-rt"},
            opts=child_opts
        )

        # Associate public subnets with public route table
        for i, subnet in enumerate(self.public_subnets):
            aws.ec2.RouteTableAssociation(f"{name}-public-rta-{i}",
                subnet_id=subnet.id,
                route_table_id=public_rt.id,
                opts=child_opts
            )

        # Private route tables (one per NAT gateway)
        for i, nat in enumerate(self.nat_gateways):
            private_rt = aws.ec2.RouteTable(f"{name}-private-rt-{i}",
                vpc_id=self.vpc.id,
                routes=[aws.ec2.RouteTableRouteArgs(
                    cidr_block="0.0.0.0/0",
                    nat_gateway_id=nat.id
                )],
                tags={**args.tags, "Name": f"{name}-private-rt-{i}"},
                opts=child_opts
            )

            aws.ec2.RouteTableAssociation(f"{name}-private-rta-{i}",
                subnet_id=self.private_subnets[i].id,
                route_table_id=private_rt.id,
                opts=child_opts
            )

        # Register outputs
        self.register_outputs({
            "vpc_id": self.vpc.id,
            "public_subnet_ids": [s.id for s in self.public_subnets],
            "private_subnet_ids": [s.id for s in self.private_subnets]
        })

# Usage
network = VpcNetwork("app-network",
    VpcNetworkArgs(
        cidr_block="10.0.0.0/16",
        availability_zones=["eu-west-1a", "eu-west-1b", "eu-west-1c"],
        enable_nat_gateway=True,
        tags={"Environment": "production"}
    )
)

pulumi.export("vpc_id", network.vpc.id)
pulumi.export("public_subnets", [s.id for s in network.public_subnets])

TypeScript Component:

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

export interface VpcNetworkArgs {
    cidrBlock: string;
    availabilityZones: string[];
    enableNatGateway?: boolean;
    tags?: { [key: string]: string };
}

export class VpcNetwork extends pulumi.ComponentResource {
    public readonly vpc: aws.ec2.Vpc;
    public readonly publicSubnets: aws.ec2.Subnet[];
    public readonly privateSubnets: aws.ec2.Subnet[];
    public readonly natGateways: aws.ec2.NatGateway[];

    constructor(name: string, args: VpcNetworkArgs, opts?: pulumi.ComponentResourceOptions) {
        super("custom:network:VpcNetwork", name, {}, opts);

        const childOpts = { parent: this };
        const defaultTags = args.tags || {};

        // Create VPC
        this.vpc = new aws.ec2.Vpc(`${name}-vpc`, {
            cidrBlock: args.cidrBlock,
            enableDnsHostnames: true,
            enableDnsSupport: true,
            tags: { ...defaultTags, Name: `${name}-vpc` }
        }, childOpts);

        // Create Internet Gateway
        const igw = new aws.ec2.InternetGateway(`${name}-igw`, {
            vpcId: this.vpc.id,
            tags: { ...defaultTags, Name: `${name}-igw` }
        }, childOpts);

        // Create subnets
        this.publicSubnets = [];
        this.privateSubnets = [];
        this.natGateways = [];

        args.availabilityZones.forEach((az, i) => {
            // Public subnet
            const publicSubnet = new aws.ec2.Subnet(`${name}-public-${i}`, {
                vpcId: this.vpc.id,
                cidrBlock: `10.0.${i}.0/24`,
                availabilityZone: az,
                mapPublicIpOnLaunch: true,
                tags: { ...defaultTags, Name: `${name}-public-${az}`, Type: "public" }
            }, childOpts);
            this.publicSubnets.push(publicSubnet);

            // Private subnet
            const privateSubnet = new aws.ec2.Subnet(`${name}-private-${i}`, {
                vpcId: this.vpc.id,
                cidrBlock: `10.0.${i + 10}.0/24`,
                availabilityZone: az,
                tags: { ...defaultTags, Name: `${name}-private-${az}`, Type: "private" }
            }, childOpts);
            this.privateSubnets.push(privateSubnet);

            // NAT Gateway
            if (args.enableNatGateway !== false) {
                const eip = new aws.ec2.Eip(`${name}-nat-eip-${i}`, {
                    domain: "vpc",  // `vpc: true` is deprecated; use domain
                    tags: { ...defaultTags, Name: `${name}-nat-eip-${az}` }
                }, childOpts);

                const nat = new aws.ec2.NatGateway(`${name}-nat-${i}`, {
                    subnetId: publicSubnet.id,
                    allocationId: eip.id,
                    tags: { ...defaultTags, Name: `${name}-nat-${az}` }
                }, childOpts);
                this.natGateways.push(nat);
            }
        });

        // Public route table
        const publicRt = new aws.ec2.RouteTable(`${name}-public-rt`, {
            vpcId: this.vpc.id,
            routes: [{
                cidrBlock: "0.0.0.0/0",
                gatewayId: igw.id
            }],
            tags: { ...defaultTags, Name: `${name}-public-rt` }
        }, childOpts);

        this.publicSubnets.forEach((subnet, i) => {
            new aws.ec2.RouteTableAssociation(`${name}-public-rta-${i}`, {
                subnetId: subnet.id,
                routeTableId: publicRt.id
            }, childOpts);
        });

        // Private route tables
        this.natGateways.forEach((nat, i) => {
            const privateRt = new aws.ec2.RouteTable(`${name}-private-rt-${i}`, {
                vpcId: this.vpc.id,
                routes: [{
                    cidrBlock: "0.0.0.0/0",
                    natGatewayId: nat.id
                }],
                tags: { ...defaultTags, Name: `${name}-private-rt-${i}` }
            }, childOpts);

            new aws.ec2.RouteTableAssociation(`${name}-private-rta-${i}`, {
                subnetId: this.privateSubnets[i].id,
                routeTableId: privateRt.id
            }, childOpts);
        });

        this.registerOutputs({
            vpcId: this.vpc.id,
            publicSubnetIds: this.publicSubnets.map(s => s.id),
            privateSubnetIds: this.privateSubnets.map(s => s.id)
        });
    }
}

// Usage
const network = new VpcNetwork("app-network", {
    cidrBlock: "10.0.0.0/16",
    availabilityZones: ["eu-west-1a", "eu-west-1b", "eu-west-1c"],
    enableNatGateway: true,
    tags: { Environment: "production" }
});

export const vpcId = network.vpc.id;
export const publicSubnets = network.publicSubnets.map(s => s.id);

Resource Options

import pulumi
import pulumi_aws as aws

# Explicit dependencies
database = aws.rds.Instance("db", ...)
app = aws.ec2.Instance("app",
    ...,
    opts=pulumi.ResourceOptions(depends_on=[database])
)

# Protect from deletion
prod_db = aws.rds.Instance("prod-db",
    ...,
    opts=pulumi.ResourceOptions(protect=True)
)

# Ignore changes to specific properties
instance = aws.ec2.Instance("web",
    ...,
    opts=pulumi.ResourceOptions(
        ignore_changes=["tags", "user_data"]
    )
)

# Custom timeouts
cluster = aws.ecs.Cluster("cluster",
    ...,
    opts=pulumi.ResourceOptions(
        custom_timeouts=pulumi.CustomTimeouts(
            create="30m",
            update="20m",
            delete="10m"
        )
    )
)

# Delete before replacement (instead of default create-before-delete)
instance = aws.ec2.Instance("web",
    ...,
    opts=pulumi.ResourceOptions(delete_before_replace=True)
)

# Use specific provider configuration
eu_provider = aws.Provider("eu-provider", region="eu-west-1")
us_provider = aws.Provider("us-provider", region="us-east-1")

eu_bucket = aws.s3.Bucket("eu-bucket",
    opts=pulumi.ResourceOptions(provider=eu_provider)
)

Transformations

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

// Apply transformation to all child resources
const addCommonTags: pulumi.ResourceTransformation = (args) => {
    if (args.type.startsWith("aws:")) {
        args.props["tags"] = {
            ...args.props["tags"],
            ManagedBy: "Pulumi",
            Environment: pulumi.getStack(),
            Project: pulumi.getProject()
        };
    }
    return { props: args.props, opts: args.opts };
};

// Apply to component resource
const network = new VpcNetwork("app-network", {
    cidrBlock: "10.0.0.0/16",
    availabilityZones: ["eu-west-1a", "eu-west-1b"]
}, {
    transformations: [addCommonTags]
});

CI/CD Integration

Pulumi integrates with CI/CD pipelines for automated infrastructure deployments, supporting major CI/CD platforms and providing native GitHub Actions and GitLab CI integration.

Key Concepts

  • Automation API - Programmatic interface for Pulumi operations
  • Preview - Show planned changes without applying
  • Update - Apply infrastructure changes
  • Destroy - Remove all stack resources
  • Stack Outputs - Values exported for use in subsequent pipeline stages

Deployment Workflow

NoYesFeatureMainNoYesCode PushCI TriggerInstall Dependenciespulumi previewChanges OK?Fail BuildBranch?Preview Onlypulumi up --yesRun TestsTests Pass?pulumi up --yesRollbackExport OutputsDeploy ApplicationNoYesFeatureMainNoYesCode PushCI TriggerInstall Dependenciespulumi previewChanges OK?Fail BuildBranch?Preview Onlypulumi up --yesRun TestsTests Pass?pulumi up --yesRollbackExport OutputsDeploy Application

GitHub Actions

name: Pulumi Infrastructure

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

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: eu-west-1

jobs:
  preview:
    name: Preview Changes
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v4
        with:
          python-version: '3.11'

      - name: Install Dependencies
        run: |
          # pip is the workflow Pulumi's tooling expects; `uv pip install -r
          # requirements.txt` works as a faster drop-in alternative if uv is available.
          pip install -r requirements.txt

      - uses: pulumi/actions@v4
        with:
          command: preview
          stack-name: dev
          comment-on-pr: true
          diff: true

  deploy:
    name: Deploy Infrastructure
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    needs: preview
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v4
        with:
          python-version: '3.11'

      - name: Install Dependencies
        run: |
          pip install -r requirements.txt

      - uses: pulumi/actions@v4
        with:
          command: up
          stack-name: production
          upsert: true
        env:
          PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}

      - name: Export Stack Outputs
        id: pulumi_outputs
        run: |
          echo "vpc_id=$(pulumi stack output vpc_id)" >> $GITHUB_OUTPUT
          echo "subnet_ids=$(pulumi stack output subnet_ids)" >> $GITHUB_OUTPUT

      - name: Use Outputs
        run: |
          echo "VPC ID: ${{ steps.pulumi_outputs.outputs.vpc_id }}"

GitLab CI

# .gitlab-ci.yml
stages:
  - preview
  - deploy
  - destroy

variables:
  PULUMI_ACCESS_TOKEN: $PULUMI_ACCESS_TOKEN
  AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID
  AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY
  AWS_DEFAULT_REGION: eu-west-1

.pulumi_base:
  # Pin to a major.minor CLI tag (images are tagged by Pulumi CLI version) rather
  # than `latest`, to avoid surprise upgrades; bump deliberately when you test it.
  image: pulumi/pulumi-python:3.246
  before_script:
    - pip install -r requirements.txt
    - pulumi login
    - pulumi stack select ${CI_ENVIRONMENT_NAME}

preview:
  extends: .pulumi_base
  stage: preview
  script:
    - pulumi preview --diff
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

deploy:dev:
  extends: .pulumi_base
  stage: deploy
  environment:
    name: dev
  script:
    - pulumi up --yes
    # dotenv reports require KEY=value lines, not JSON. Emit each output as an
    # env var so downstream jobs can consume them via `needs`.
    - pulumi stack output --json | jq -r 'to_entries[] | "\(.key | ascii_upcase)=\(.value)"' > stack.env
    - pulumi stack output --json > stack-outputs.json
  artifacts:
    reports:
      dotenv: stack.env
    paths:
      - stack-outputs.json
    expire_in: 1 week
  rules:
    - if: '$CI_COMMIT_BRANCH == "develop"'

deploy:production:
  extends: .pulumi_base
  stage: deploy
  environment:
    name: production
  script:
    - pulumi up --yes
    - pulumi stack output --json > stack-outputs.json
  artifacts:
    paths:
      - stack-outputs.json
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: manual

destroy:
  extends: .pulumi_base
  stage: destroy
  script:
    - pulumi destroy --yes
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: manual

Jenkins Pipeline

pipeline {
    agent any

    environment {
        PULUMI_ACCESS_TOKEN = credentials('pulumi-access-token')
        AWS_ACCESS_KEY_ID = credentials('aws-access-key')
        AWS_SECRET_ACCESS_KEY = credentials('aws-secret-key')
        AWS_REGION = 'eu-west-1'
        PULUMI_CONFIG_PASSPHRASE = credentials('pulumi-passphrase')
    }

    stages {
        stage('Install Dependencies') {
            steps {
                sh '''
                    pip install -r requirements.txt
                    curl -fsSL https://get.pulumi.com | sh
                    export PATH=$PATH:$HOME/.pulumi/bin
                '''
            }
        }

        stage('Preview') {
            steps {
                script {
                    sh '''
                        export PATH=$PATH:$HOME/.pulumi/bin
                        pulumi stack select dev
                        pulumi preview --diff > preview.txt
                    '''
                    archiveArtifacts artifacts: 'preview.txt'
                }
            }
        }

        stage('Deploy') {
            when {
                branch 'main'
            }
            steps {
                script {
                    def userInput = input(
                        message: 'Deploy infrastructure?',
                        parameters: [
                            booleanParam(defaultValue: false, description: 'Proceed with deployment', name: 'DEPLOY')
                        ]
                    )

                    if (userInput) {
                        sh '''
                            export PATH=$PATH:$HOME/.pulumi/bin
                            pulumi stack select production
                            pulumi up --yes
                            pulumi stack output --json > outputs.json
                        '''
                        archiveArtifacts artifacts: 'outputs.json'
                    }
                }
            }
        }

        stage('Export Outputs') {
            steps {
                script {
                    def outputs = readJSON file: 'outputs.json'
                    env.VPC_ID = outputs.vpc_id
                    env.SUBNET_IDS = outputs.subnet_ids
                }
            }
        }
    }

    post {
        always {
            cleanWs()
        }
        failure {
            emailext(
                subject: "Pulumi Deployment Failed: ${env.JOB_NAME}",
                body: "Deployment failed. Check console output.",
                to: "${env.CHANGE_AUTHOR_EMAIL}"
            )
        }
    }
}

Automation API

Python Automation API:

import pulumi
from pulumi import automation as auto
import os

def pulumi_program():
    """Define infrastructure programmatically"""
    import pulumi_aws as aws

    # Note: the inline `acl` property is deprecated (S3 blocks ACLs by
    # default). For ACL control use a separate aws.s3.BucketAcl resource.
    bucket = aws.s3.Bucket("my-bucket",
        tags={"Environment": "dev"}
    )

    pulumi.export("bucket_name", bucket.id)

# Create or select stack
project_name = "automation-api-example"
stack_name = "dev"

stack = auto.create_or_select_stack(
    stack_name=stack_name,
    project_name=project_name,
    program=pulumi_program
)

# Set configuration
stack.set_config("aws:region", auto.ConfigValue("eu-west-1"))

# Install dependencies
# Note: modern Pulumi prefers package-based installation - declare the provider
# in requirements.txt and run `pulumi install` - over imperative install_plugin.
stack.workspace.install_plugin("aws", "v7.0.0")

# Preview changes
print("Previewing infrastructure changes...")
preview_result = stack.preview(on_output=print)
print(f"Preview summary: {preview_result.change_summary}")

# Apply changes
print("Deploying infrastructure...")
up_result = stack.up(on_output=print)
print(f"Update summary: {up_result.summary.resource_changes}")

# Get outputs
outputs = stack.outputs()
print(f"Bucket name: {outputs['bucket_name'].value}")

# Export stack state
stack_export = stack.export_stack()
with open("stack-state.json", "w") as f:
    import json
    json.dump(stack_export.deployment, f, indent=2)

TypeScript Automation API:

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as automation from "@pulumi/pulumi/automation";

const pulumiProgram = async () => {
    // Note: the inline `acl` property is deprecated (S3 blocks ACLs by
    // default). For ACL control use a separate aws.s3.BucketAcl resource.
    const bucket = new aws.s3.Bucket("my-bucket", {
        tags: { Environment: "dev" }
    });

    return { bucketName: bucket.id };
};

const run = async () => {
    const projectName = "automation-api-example";
    const stackName = "dev";

    // Create or select stack
    const stack = await automation.LocalWorkspace.createOrSelectStack({
        stackName,
        projectName,
        program: pulumiProgram
    });

    // Set configuration
    await stack.setConfig("aws:region", { value: "eu-west-1" });

    // Install plugins
    // Note: modern Pulumi prefers package-based installation - declare the
    // provider in package.json and run `pulumi install` - over imperative installPlugin.
    await stack.workspace.installPlugin("aws", "v7.0.0");

    // Preview changes
    console.log("Previewing infrastructure changes...");
    const previewResult = await stack.preview({ onOutput: console.log });
    console.log(`Preview summary: ${JSON.stringify(previewResult.changeSummary)}`);

    // Apply changes
    console.log("Deploying infrastructure...");
    const upResult = await stack.up({ onOutput: console.log });
    console.log(`Update summary: ${JSON.stringify(upResult.summary.resourceChanges)}`);

    // Get outputs
    const outputs = await stack.outputs();
    console.log(`Bucket name: ${outputs.bucketName.value}`);

    // Export stack
    const stackExport = await stack.exportStack();
    await require('fs').promises.writeFile(
        'stack-state.json',
        JSON.stringify(stackExport.deployment, null, 2)
    );
};

run().catch(console.error);

Environment-Specific Deployments

# Deploy to multiple environments
#!/bin/bash
set -e

ENVIRONMENTS=("dev" "staging" "production")

for env in "${ENVIRONMENTS[@]}"; do
    echo "Deploying to $env..."
    pulumi stack select $env

    if [ "$env" == "production" ]; then
        # Require manual approval for production
        pulumi preview
        read -p "Deploy to production? (yes/no): " confirm
        if [ "$confirm" != "yes" ]; then
            echo "Skipping production deployment"
            continue
        fi
    fi

    pulumi up --yes
    pulumi stack output --json > "outputs-$env.json"
done

Quick Reference

Common Commands

Command Description
pulumi new Create new project from template
pulumi up Preview and deploy infrastructure
pulumi preview Show planned changes without applying
pulumi destroy Delete all stack resources
pulumi refresh Sync state with cloud provider
pulumi stack Manage stacks
pulumi config Manage configuration
pulumi stack output View stack outputs
pulumi state Manage state
pulumi history View deployment history
pulumi cancel Cancel in-progress update
pulumi import Import existing resources

Python Quick Patterns

# Configuration
config = pulumi.Config()
value = config.get("key")
secret = config.require_secret("secretKey")

# Outputs
pulumi.export("name", resource.property)
pulumi.export("secret", pulumi.Output.secret(value))

# Stack reference
other_stack = pulumi.StackReference("org/project/stack")
value = other_stack.get_output("outputName")

# Component resource
class MyComponent(pulumi.ComponentResource):
    def __init__(self, name, args, opts=None):
        super().__init__("pkg:index:MyComponent", name, {}, opts)
        # Create resources...
        self.register_outputs({"output": value})

TypeScript Quick Patterns

// Configuration
const config = new pulumi.Config();
const value = config.get("key");
const secret = config.requireSecret("secretKey");

// Outputs
export const name = resource.property;
export const secretValue = pulumi.secret(value);

// Stack reference
const otherStack = new pulumi.StackReference("org/project/stack");
const value = otherStack.getOutput("outputName");

// Component resource
class MyComponent extends pulumi.ComponentResource {
    constructor(name: string, args: Args, opts?: pulumi.ComponentResourceOptions) {
        super("pkg:index:MyComponent", name, {}, opts);
        // Create resources...
        this.registerOutputs({ output: value });
    }
}

Resource Options

# Python
opts = pulumi.ResourceOptions(
    depends_on=[other_resource],
    protect=True,
    ignore_changes=["tags"],
    delete_before_replace=True,
    provider=custom_provider,
    parent=component_resource
)
// TypeScript
const opts: pulumi.ResourceOptions = {
    dependsOn: [otherResource],
    protect: true,
    ignoreChanges: ["tags"],
    deleteBeforeReplace: true,
    provider: customProvider,
    parent: componentResource
};

Common Issues and Solutions

State Conflicts

Issue: Concurrent updates cause state conflicts.

Solution:

# Cancel in-progress update
pulumi cancel

# If cancel doesn't work, force unlock (use cautiously)
pulumi stack export > backup.json
pulumi stack import --file backup.json

# Use concurrency controls in CI/CD
# GitLab: resource_group
# GitHub Actions: concurrency group

Secret Decryption Failures

Issue: failed to decrypt errors when passphrase is incorrect or missing.

Solution:

# Set passphrase environment variable
export PULUMI_CONFIG_PASSPHRASE="your-passphrase"

# Or change secrets provider
pulumi stack change-secrets-provider "awskms://..."

# Recover with backup
pulumi stack export --show-secrets > backup.json

Resource Import Issues

Issue: Existing cloud resources not managed by Pulumi.

Solution:

# Import resource into state
pulumi import aws:ec2/instance:Instance my-server i-1234567890abcdef0

# Bulk import with JSON
cat > import.json <<EOF
{
    "resources": [{
        "type": "aws:ec2/instance:Instance",
        "name": "web-server",
        "id": "i-1234567890abcdef0"
    }]
}
EOF
pulumi import --file import.json

# Generate code from imported resources
pulumi import --generate-code

Dependency Errors

Issue: Resources created in wrong order or circular dependencies.

Solution:

# Explicit dependencies
resource_b = aws.Resource("b",
    ...,
    opts=pulumi.ResourceOptions(depends_on=[resource_a])
)

# Use outputs to create implicit dependencies
subnet = aws.ec2.Subnet("subnet", vpc_id=vpc.id)  # Implicit dependency on vpc

# Break circular dependencies with ignoreChanges
resource = aws.Resource("res",
    ...,
    opts=pulumi.ResourceOptions(ignore_changes=["circular_field"])
)

Provider Plugin Errors

Issue: Missing or incompatible provider plugins.

Solution:

# Install specific plugin version
pulumi plugin install resource aws v5.42.0

# List installed plugins
pulumi plugin ls

# Remove old plugins
pulumi plugin rm resource aws v5.0.0

# Reinstall all project plugins
pulumi install

Stack Output Not Available

Issue: Stack outputs show as empty or undefined.

Solution:

# Ensure outputs are exported
pulumi.export("vpc_id", vpc.id)

# Handle Output types correctly
vpc_id = pulumi.Output.from_input(vpc.id)
result = vpc_id.apply(lambda id: f"VPC: {id}")

# Stack reference requires full name
stack_ref = pulumi.StackReference("organization/project/stack")

Performance Issues

Issue: Slow previews or updates with many resources.

Solution:

# Enable parallel operations (default: 16)
pulumi up --parallel 20

# Skip previewing unchanged resources
pulumi up --target-dependents --target urn:pulumi:stack::project::type::name

# Use refresh selectively
pulumi refresh --target specific-resource

# Optimize state backend
# Use cloud storage (S3, Azure Blob, GCS) instead of local
pulumi login s3://my-state-bucket

Language-Specific Issues

Python - Module Import Errors:

# Ensure virtual environment is activated
source venv/bin/activate
pip install -r requirements.txt

# Regenerate requirements
pip freeze > requirements.txt

TypeScript - Type Errors:

# Ensure types are installed
npm install --save-dev @types/node

# Check tsconfig.json
{
    "compilerOptions": {
        "strict": true,
        "target": "ES2016",
        "module": "commonjs",
        "moduleResolution": "node",
        "esModuleInterop": true
    }
}

Cost Management

Issue: Unexpected cloud costs from abandoned stacks.

Solution:

# List all stacks with last update time
pulumi stack ls --all

# Destroy abandoned stacks
pulumi stack select old-experiment
pulumi destroy --yes
pulumi stack rm old-experiment

# Use tags for cost allocation
tags = {
    "Project": pulumi.get_project(),
    "Stack": pulumi.get_stack(),
    "ManagedBy": "Pulumi"
}

# Set resource quotas in component resources
if resource_count > 10:
    raise Exception("Resource limit exceeded")