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.
flowchart LR
A[Write Code<br/>Python/TypeScript/etc] --> B[pulumi preview]
B --> C{Review Plan}
C -->|Approve| D[pulumi up]
C -->|Reject| A
D --> E[Resources Created]
E --> F[State Updated in Backend]
F --> G[Outputs Available]
G -.->|Stack Updates| A
Key Architecture
graph TB
subgraph "Developer Environment"
A[Pulumi Program]
end
subgraph "Pulumi Service"
B[Language Host<br/>Python/Node/etc]
C[Deployment Engine]
D[Resource Providers]
end
subgraph "Backend"
E[State Storage<br/>S3/Azure/Service]
F[Secrets Encryption]
end
subgraph "Cloud Providers"
G[AWS]
H[Azure]
I[GCP]
J[Kubernetes]
end
A --> B
B --> C
C --> D
C --> E
C --> F
D --> G
D --> H
D --> I
D --> J
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.yamland 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
flowchart TB
A[Code Push] --> B[CI Trigger]
B --> C[Install Dependencies]
C --> D[pulumi preview]
D --> E{Changes OK?}
E -->|No| F[Fail Build]
E -->|Yes| G{Branch?}
G -->|Feature| H[Preview Only]
G -->|Main| I[pulumi up --yes]
I --> J[Run Tests]
J --> K{Tests Pass?}
K -->|No| L[pulumi up --yes<br/>Rollback]
K -->|Yes| M[Export Outputs]
M --> N[Deploy 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")