Skip to content

Pulumi - Primer

Why This Matters

Pulumi lets you write infrastructure as code using real programming languages — Python, TypeScript, Go, C#, Java — instead of domain-specific languages like HCL. This is not just a syntax preference. Real languages give you loops, conditionals, functions, classes, type checking, IDE autocompletion, unit testing with standard frameworks, and access to every library in the language ecosystem. For teams already strong in a general-purpose language, Pulumi eliminates the cognitive overhead of learning HCL while providing the same cloud resource management. The trade-off: Pulumi's state management and workflow differ from Terraform's, and the ecosystem of community modules is smaller.

Who made it: Pulumi was founded in 2017 by Joe Duffy (former Microsoft engineer who led .NET and the Midori OS research project) and Eric Rudder (former Microsoft executive). The company is based in Seattle. Pulumi's first public release was in June 2018.

Core Concepts

1. Installation and Project Setup

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

# Verify
pulumi version

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

# Python project
pulumi new aws-python

# TypeScript project
pulumi new aws-typescript

# Go project
pulumi new aws-go

# From a template
pulumi new https://github.com/pulumi/examples/tree/master/aws-py-vpc

Project structure (Python):

my-infra/
  Pulumi.yaml          # project metadata
  Pulumi.dev.yaml      # stack-specific config (dev)
  Pulumi.prod.yaml     # stack-specific config (prod)
  __main__.py           # infrastructure code
  requirements.txt      # Python dependencies
  venv/                 # virtual environment

2. Language Examples

Python — create a VPC, subnet, and EC2 instance:

# __main__.py
import pulumi
import pulumi_aws as aws

# Read config
config = pulumi.Config()
instance_type = config.get("instanceType") or "t3.micro"

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

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

# Create security group
sg = aws.ec2.SecurityGroup("web-sg",
    vpc_id=vpc.id,
    ingress=[aws.ec2.SecurityGroupIngressArgs(
        protocol="tcp", from_port=443, to_port=443,
        cidr_blocks=["0.0.0.0/0"],
    )],
    egress=[aws.ec2.SecurityGroupEgressArgs(
        protocol="-1", from_port=0, to_port=0,
        cidr_blocks=["0.0.0.0/0"],
    )])

# Create EC2 instance
server = aws.ec2.Instance("web-server",
    instance_type=instance_type,
    ami="ami-0c55b159cbfafe1f0",
    subnet_id=subnet.id,
    vpc_security_group_ids=[sg.id],
    tags={"Name": "web-server"})

# Export outputs
pulumi.export("vpc_id", vpc.id)
pulumi.export("instance_ip", server.public_ip)

TypeScript — Kubernetes deployment:

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

const appLabels = { app: "nginx" };

const deployment = new k8s.apps.v1.Deployment("nginx", {
    spec: {
        replicas: 3,
        selector: { matchLabels: appLabels },
        template: {
            metadata: { labels: appLabels },
            spec: {
                containers: [{
                    name: "nginx",
                    image: "nginx:1.25",
                    ports: [{ containerPort: 80 }],
                }],
            },
        },
    },
});

const service = new k8s.core.v1.Service("nginx-svc", {
    spec: {
        type: "LoadBalancer",
        selector: appLabels,
        ports: [{ port: 80, targetPort: 80 }],
    },
});

export const url = service.status.loadBalancer.ingress[0].hostname;

3. State Backends

Pulumi state can be stored in several backends:

# Pulumi Cloud (default — managed service)
pulumi login

# Local filesystem
pulumi login --local
# State stored in ~/.pulumi/stacks/

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

# Azure Blob Storage
pulumi login azblob://my-container

# Google Cloud Storage
pulumi login gs://my-pulumi-bucket

# Check current backend
pulumi whoami -v

4. Stack Management

Analogy: Pulumi stacks are like Git branches for infrastructure. Each stack (dev, staging, prod) has its own state and config, but shares the same code. You switch between stacks like switching branches, and each one can diverge independently.

Stacks are isolated instances of your infrastructure (like Terraform workspaces but more first-class).

# Create stacks for environments
pulumi stack init dev
pulumi stack init staging
pulumi stack init production

# Switch stacks
pulumi stack select dev

# List stacks
pulumi stack ls

# Set stack-specific config
pulumi config set aws:region us-east-1
pulumi config set instanceType t3.large
pulumi config set --secret dbPassword 'my-secret-pass'  # encrypted

# View config
pulumi config

# Stack outputs
pulumi stack output
pulumi stack output vpc_id

# Export/import state
pulumi stack export > state.json
pulumi stack import < state.json

# Delete a stack (must destroy resources first)
pulumi destroy
pulumi stack rm dev

5. Preview/Up Workflow

# Preview changes (like terraform plan)
pulumi preview
pulumi preview --diff          # show detailed diff
pulumi preview --json          # machine-readable output

# Apply changes (like terraform apply)
pulumi up
pulumi up --yes                # skip confirmation
pulumi up --target urn:pulumi:dev::my-infra::aws:ec2/instance:Instance::web-server

# Destroy all resources
pulumi destroy
pulumi destroy --yes

# Refresh state from cloud (like terraform refresh)
pulumi refresh

# Import existing resources
pulumi import aws:ec2/instance:Instance web-server i-1234567890abcdef0

6. Component Resources (Reusable Abstractions)

# components/vpc.py
import pulumi
import pulumi_aws as aws

class VpcComponent(pulumi.ComponentResource):
    def __init__(self, name, cidr_block, num_subnets=2, opts=None):
        super().__init__("custom:network:VpcComponent", name, None, opts)

        self.vpc = aws.ec2.Vpc(f"{name}-vpc",
            cidr_block=cidr_block,
            enable_dns_hostnames=True,
            opts=pulumi.ResourceOptions(parent=self))

        self.subnets = []
        for i in range(num_subnets):
            subnet = aws.ec2.Subnet(f"{name}-subnet-{i}",
                vpc_id=self.vpc.id,
                cidr_block=f"10.0.{i}.0/24",
                opts=pulumi.ResourceOptions(parent=self))
            self.subnets.append(subnet)

        self.register_outputs({
            "vpc_id": self.vpc.id,
            "subnet_ids": [s.id for s in self.subnets],
        })

# Usage in __main__.py
from components.vpc import VpcComponent
network = VpcComponent("production", cidr_block="10.0.0.0/16", num_subnets=3)
pulumi.export("vpc_id", network.vpc.id)

7. Testing Infrastructure Code

# test_infra.py
import pulumi
import unittest

class MockMyMocks(pulumi.runtime.Mocks):
    def new_resource(self, args):
        return [args.name + "_id", args.inputs]

    def call(self, args):
        return {}

pulumi.runtime.set_mocks(MockMyMocks())

import __main__ as infra  # import your Pulumi program

class TestInfra(unittest.TestCase):
    @pulumi.runtime.test
    def test_vpc_has_dns(self):
        def check_dns(args):
            enable_dns = args[0]
            self.assertTrue(enable_dns, "VPC must have DNS hostnames enabled")
        return pulumi.Output.all(infra.vpc.enable_dns_hostnames).apply(check_dns)

    @pulumi.runtime.test
    def test_instance_type(self):
        def check_type(args):
            instance_type = args[0]
            self.assertNotEqual(instance_type, "t2.micro",
                "Should not use t2 generation")
        return pulumi.Output.all(infra.server.instance_type).apply(check_type)
# Run tests
python -m pytest test_infra.py

8. Operational Patterns

# Policy as code (CrossGuard)
pulumi policy new aws-python
# Edit PulumiPolicy.yaml and policy code
pulumi preview --policy-pack ./my-policy

# Automation API (embed Pulumi in application code)
# Python: from pulumi.automation import LocalWorkspace
# Enables programmatic infrastructure management without CLI

# CI/CD integration
# GitHub Actions example:
# - uses: pulumi/actions@v5
#   with:
#     command: up
#     stack-name: production
#   env:
#     PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}

> **Gotcha:** Pulumi's `Output` type is the most confusing concept for newcomers. Resource properties return `Output[T]` (a promise-like wrapper) because values aren't known until the resource is created. You can't just `print(vpc.id)` -- you need `vpc.id.apply(lambda id: print(id))`. This trips up everyone coming from Terraform where values resolve during `plan`.

# Debug logging
pulumi up -v=3 --logtostderr 2>debug.log

# Cancel a stuck update
pulumi cancel

# Force state unlock
pulumi stack export | pulumi stack import

Quick Reference

# Project lifecycle
pulumi new <template>              # create project
pulumi stack init <name>           # create stack
pulumi config set <key> <value>    # configure
pulumi preview                     # plan
pulumi up                          # apply
pulumi destroy                     # tear down
pulumi stack rm <name>             # delete stack

# State operations
pulumi stack output                # show outputs
pulumi refresh                     # sync state with cloud
pulumi import <type> <name> <id>   # import existing resource

# Diagnostics
pulumi stack ls                    # list all stacks
pulumi whoami -v                   # backend info
pulumi logs                        # cloud function logs

Interview tip: When asked "Pulumi vs Terraform," the honest answer is: Terraform has a larger ecosystem (more providers, more community modules, more Stack Overflow answers). Pulumi has a better developer experience for teams already strong in Python/TypeScript. Many organizations use both -- Terraform for shared infrastructure modules, Pulumi for application-specific infra where real language features shine.


Wiki Navigation

Prerequisites