Introduction to Infrastructure as Code with Pulumi

Learn Pulumi fundamentals - projects, stacks, config, secrets, and state - using real TypeScript programs, and how it compares to Terraform

Pulumi is an Infrastructure as Code (IaC) tool that lets you define cloud infrastructure using general-purpose programming languages - TypeScript, Python, Go, C#, Java, or YAML - instead of a domain-specific language like Terraform’s HCL. This guide covers Pulumi’s core concepts and how it compares to Terraform, which the rest of this site’s infrastructure guides use.

Pulumi vs Terraform

Both are declarative-outcome IaC tools that plan a diff against real cloud state and apply only the changes needed. The difference is the authoring layer:

  • Terraform uses HCL, a purpose-built configuration language with its own loops, conditionals, and module system.
  • Pulumi uses a real programming language, so you get its native tooling for free: your editor’s autocomplete and type-checking, if/for without a special DSL, unit tests with your normal test framework, and npm/pip packages if you need one.
  • Provider coverage: Pulumi’s AWS/Azure/GCP “Classic” providers are generated directly from the same provider schemas Terraform uses, so almost every resource and argument documented in this site’s Terraform guides has a directly corresponding Pulumi resource with the same underlying behavior.
  • State: Terraform state is a file you manage yourself (typically in S3/Azure Blob/GCS with locking, as shown in the Terraform guides on this site). Pulumi state works the same way conceptually but defaults to a hosted backend (Pulumi Cloud, which is free for individual use) instead of requiring you to stand one up.

Neither is more “correct” - teams already fluent in HCL and Terraform’s module ecosystem often stay there; teams that want infrastructure code reviewed, tested, and structured the same way as their application code often prefer Pulumi.

Prerequisites

  • Node.js 18+ (for the TypeScript examples used throughout this guide and the ones that follow)
  • An AWS account with credentials configured (aws configure, same as for the Terraform guides)
  • The Pulumi CLI installed
curl -fsSL https://get.pulumi.com | sh
pulumi version

Creating a New Project

mkdir my-infra && cd my-infra
pulumi new aws-typescript

pulumi new scaffolds a project from a template - aws-typescript sets up a minimal AWS/TypeScript program and asks for a project name, description, and initial stack name. It generates:

my-infra/
├── Pulumi.yaml       # Project definition
├── Pulumi.dev.yaml   # Stack config (one per stack)
├── package.json
├── tsconfig.json
└── index.ts           # Your program's entry point
# Pulumi.yaml
name: my-infra
runtime: nodejs
description: A minimal AWS TypeScript Pulumi program

Stacks

A stack is an isolated, independently configured instance of your program - the Pulumi equivalent of a Terraform workspace or a separate state file per environment.

pulumi stack init dev
pulumi stack init prod
pulumi stack ls
pulumi stack select prod

Each stack gets its own Pulumi.<stack-name>.yaml config file and its own state, so dev and prod never share resources even though they run the exact same program.

Configuration and Secrets

pulumi config set aws:region us-west-2
pulumi config set instanceType t3.micro
pulumi config set --secret dbPassword "correct-horse-battery-staple"
import * as pulumi from "@pulumi/pulumi";

const config = new pulumi.Config();

// get() returns undefined if unset; require() throws if the key is missing
const instanceType = config.get("instanceType") ?? "t3.micro";
const dbPassword = config.requireSecret("dbPassword");

Values set with --secret are encrypted before being written to Pulumi.<stack>.yaml, using either Pulumi Cloud’s managed encryption or a KMS/Vault-backed provider you configure yourself. requireSecret() returns a pulumi.Output<string> marked secret - Pulumi automatically redacts it from CLI output and the Pulumi Cloud console.

A First Program: an S3 Bucket

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

const bucket = new aws.s3.BucketV2("my-bucket");

export const bucketName = bucket.bucket;

Every resource constructor takes a logical name ("my-bucket" here) - a stable identifier Pulumi uses to track the resource across deployments, independent of its actual cloud-assigned name or ID. Renaming the logical name tells Pulumi to destroy and recreate the resource; renaming a name/bucket/tags.Name argument just updates that property in place.

pulumi up

pulumi up computes a diff between your program’s desired state and the last recorded state, shows you the plan, and applies it after confirmation - directly analogous to terraform plan followed by terraform apply, but combined into one interactive step (pass --yes to skip confirmation in CI, or run pulumi preview first to see the diff without applying).

Outputs

Every resource property that isn’t known until after the cloud provider creates it - an ID, an ARN, a generated hostname - is a pulumi.Output<T>, not a plain value. You can’t read an Output synchronously (the resource might not exist yet when your program runs); instead you transform it with .apply() or combine several with pulumi.interpolate:

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

const bucket = new aws.s3.BucketV2("my-bucket");

// .apply() transforms one Output into another
const bucketArnUpper = bucket.arn.apply(arn => arn.toUpperCase());

// pulumi.interpolate builds a template string from Outputs
const bucketUrl = pulumi.interpolate`https://${bucket.bucket}.s3.amazonaws.com`;

export const url = bucketUrl;

export const statements at the top level of your program become stack outputs - values Pulumi records after pulumi up and that other stacks, CI pipelines, or pulumi stack output <name> can read back.

pulumi stack output url

State Backends

By default, pulumi new logs you into Pulumi Cloud (app.pulumi.com), which stores state, encrypts secrets, and locks concurrent updates for you - no S3 bucket or DynamoDB table to provision yourself, unlike the Terraform backend setup shown elsewhere on this site. If you’d rather self-host state, Pulumi supports the same object stores Terraform typically uses:

pulumi login s3://my-pulumi-state-bucket
pulumi login azblob://my-pulumi-state-container
pulumi login gs://my-pulumi-state-bucket
pulumi login file://~/.pulumi-state   # local only - not for team use

Destroying Resources

pulumi destroy
pulumi stack rm dev

pulumi destroy tears down every resource the stack currently manages, in dependency order - the direct equivalent of terraform destroy. pulumi stack rm additionally deletes the stack’s state and config once nothing is left in it.

Best Practices

  1. Project Structure

    • Keep one Pulumi project per deployable unit (matching how you’d split Terraform root modules), and factor shared resource groups into reusable functions or classes rather than copy-pasting across stacks.
    • Use component resources (a class extending pulumi.ComponentResource) to group related resources the way a Terraform module groups related resource blocks.
  2. Secrets

    • Never put real secret values in plain config keys - always use --secret, and prefer pulling from a real secrets manager (AWS Secrets Manager, Azure Key Vault) over long-lived Pulumi config secrets for anything rotated regularly.
  3. State

    • Treat Pulumi Cloud’s free tier as the default for personal/small-team projects; move to a self-hosted backend only when you have a specific compliance reason to keep state off Pulumi’s infrastructure.

Conclusion

Pulumi and Terraform solve the same problem with different authoring models - a real programming language versus a purpose-built DSL - while sharing nearly identical underlying provider behavior for AWS, Azure, and GCP. The rest of this site’s Pulumi guides follow the same services already covered in the Terraform guides, so you can compare the two directly.

For more Pulumi topics, check out: