# Pulumi, From Zero

> Infrastructure as actual code: define cloud resources in TypeScript, Python, or Go, with real loops and functions, while Pulumi tracks state like Terraform.


---

# Pulumi, From Zero

You already know a programming language. You can write a loop, pull a value into a variable, factor a repeated thing into a function. Then you open a Terraform file and all of that goes out the window: a new syntax, `count` and `for_each` instead of loops, string-templated logic that fights you. Pulumi's bet is that you should keep the language you know and aim it at the cloud.

This guide shows you what Pulumi actually is, how the daily loop works (`preview`, `up`, `destroy`), and where giving yourself a full programming language quietly hands you enough rope to hurt yourself.

## How to read this

Read the phases in order the first time. Phase 1 builds the mental model so the commands in phase 2 aren't magic. Phase 3 is the part you'll come back to once you've shipped something and hit a wall. If you've used Terraform, you'll find the ideas familiar and the trade-offs are the interesting part.

## The phases

1. [What Pulumi actually is](01-what-pulumi-actually-is.md) - the mental model: a real language describing a desired state, plus a state file and a diff engine.
2. [The everyday loop](02-the-everyday-loop.md) - projects, stacks, config, and the `preview`/`up`/`destroy` rhythm you'll live in.
3. [Where the rope gets you](03-where-the-rope-gets-you.md) - the gotchas a general-purpose language invites, and when to reach for Pulumi over HCL.


---

# What Pulumi actually is

You've probably created cloud resources by hand at least once: clicked through a console, made a bucket, set a permission, forgot which checkbox you ticked. A week later nobody can say what's actually deployed or why. Infrastructure as Code fixes that by putting the answer in a file you can read, review, and replay. Pulumi's particular move is that the file is written in a language you already know.

## The one idea: a program that describes a desired end state

Here's the mental model to carry through everything else. Your Pulumi program does **not** run commands like "go make a bucket." It *declares* what should exist. You write code that constructs resource objects, Pulumi looks at what you declared, compares it to what's already out there, and figures out the create/update/delete steps to close the gap.

That word *declares* is the whole thing. Your program is a description, not a script. Running it twice with no changes does nothing the second time, because the description still matches reality.

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

// This is a DECLARATION, not a command.
// "There should be a bucket named like this, set up this way."
const logs = new aws.s3.Bucket("app-logs", {
    tags: { team: "platform" },
});

export const bucketName = logs.id;
```

*What just happened:* constructing `new aws.s3.Bucket(...)` didn't create anything yet. It registered your intent with Pulumi. The actual create happens later, when you run `pulumi up` and Pulumi reconciles this declaration against the real cloud.

## The part that's different from Terraform: it's real code

If you've seen Terraform, this is declarative too. The difference is the *language*. Terraform uses HCL, a domain-specific language built only for this. Pulumi uses a general-purpose language - TypeScript, Python, Go, C#, Java - running on its normal runtime.

That means the things you already do in code, you do here. Need ten buckets? A loop. Need to compute a name from two values? A function. Need a type checked before you deploy? Your language's type system does it.

```python
import pulumi_aws as aws

# A real for-loop. No special meta-syntax to learn.
environments = ["dev", "staging", "prod"]

buckets = {}
for env in environments:
    buckets[env] = aws.s3.Bucket(f"data-{env}",
        tags={"env": env})
```

*What just happened:* a plain Python `for` loop created three bucket declarations, named `data-dev`, `data-staging`, and `data-prod`. There's no new looping construct to memorize - it's the same loop you'd write in any other Python program.

Compare that to HCL, where you'd reach for `for_each` and a `toset(...)` expression. Neither is wrong; the Pulumi version is the language you already think in. That's the entire pitch in one example.

## State: how it remembers what it built

A declaration alone isn't enough. To know whether to create, update, or delete, Pulumi needs to remember what it made last time. That memory is the **state** - a record mapping each resource in your program to the real cloud resource it manages, including its current properties.

```text
your program          state file              real cloud
------------          ----------              ----------
"app-logs" bucket  →  id: app-logs-a1b2c3  →  s3://app-logs-a1b2c3
                      tags: {team: ...}        (actual bucket)
```

*What just happened:* the state sits between your code and the cloud as the source of truth for "what I manage." When you run a command, Pulumi reads state, reads your program, and diffs the two. This is the same model Terraform uses; if you know `terraform.tfstate`, you know the concept.

State lives somewhere - by default the Pulumi Cloud service (a free tier exists), or a backend you control like an S3 bucket, a GCS bucket, or even a local file. The backend is a choice, not a lock-in.

> Treat state as precious and shared. It is the only thing that knows which real resources your code owns. Lose it or let two people write it at once and Pulumi can lose track of resources or try to recreate things that already exist. Phase 3 returns to this.

## The flow you'll actually use

Three verbs carry most of the work, and they mirror the mental model exactly:

- `pulumi preview` - diff only. "Here's what I *would* change." No mutations.
- `pulumi up` - apply the diff. Create, update, delete to match your program.
- `pulumi destroy` - tear down everything in this stack.

```console
$ pulumi preview
Previewing update (dev)

     Type                 Name          Plan
 +   pulumi:pulumi:Stack  app-dev       create
 +   └─ aws:s3:Bucket     app-logs      create

Resources:
    + 2 to create
```

*What just happened:* `preview` showed a plan with `+ 2 to create` and changed nothing. The `+` marks creations, much like `terraform plan`. You read this before every `up` so there are no surprises - it's the "measure twice" step.

## For builders

The "real language" idea pays off most when infrastructure has logic in it: derive a config from an environment, generate resources from a list pulled at runtime, share a helper across projects as a normal package. If your infra is mostly static and flat, the advantage is smaller - and the extra power becomes extra rope, which is exactly what phase 3 is about.

If Pulumi's state and diff model feels familiar, that's because it shares DNA with [/guides/infrastructure-as-code-terraform](/guides/infrastructure-as-code-terraform). And if "what's a bucket, a VPC, an IAM role" is the fuzzy part, [/guides/cloud-platforms-explained](/guides/cloud-platforms-explained) fills that in.

```quiz
[
  {
    "q": "What does constructing a resource object (e.g. new aws.s3.Bucket(...)) actually do when the program runs?",
    "choices": [
      "Immediately creates the bucket in the cloud",
      "Registers a declaration of intent that Pulumi reconciles later during up",
      "Writes directly to the state file and stops",
      "Sends an HTTP request to the cloud provider's console"
    ],
    "answer": 1,
    "explain": "Pulumi programs declare desired state. The real create/update/delete happens during pulumi up, when Pulumi diffs the declaration against state and the cloud."
  },
  {
    "q": "What is the main thing that distinguishes Pulumi from Terraform's HCL?",
    "choices": [
      "Pulumi does not use a state file",
      "Pulumi is imperative and runs commands step by step",
      "Pulumi uses a general-purpose language with real loops, functions, and types",
      "Pulumi can only target AWS"
    ],
    "answer": 2,
    "explain": "Both are declarative and both keep state. Pulumi's distinguishing trait is using TypeScript/Python/Go/etc. instead of a purpose-built DSL."
  },
  {
    "q": "Why does Pulumi need a state file?",
    "choices": [
      "To remember which real cloud resources its program manages, so it can diff and decide create/update/delete",
      "To store your cloud credentials",
      "To cache the provider plugin binaries",
      "It does not; state is optional and unused"
    ],
    "answer": 0,
    "explain": "State maps each resource in your program to the real resource it manages. Without it, Pulumi can't tell what already exists or what it owns."
  }
]
```


---

# The everyday loop

Now the day-to-day. You have a folder, you run a couple of commands, resources appear. This phase walks the real rhythm: starting a project, what a stack is, where config and secrets go, and reading the output of `up` so it stops looking like a wall of green text.

## Starting a project

`pulumi new` scaffolds a project from a template. It asks a few questions, drops in starter files, and installs the SDK for your language.

```console
$ mkdir infra && cd infra
$ pulumi new aws-typescript
project name: infra
stack name: dev
aws:region: us-east-1

Created stack 'dev'
Installing dependencies...
```

*What just happened:* you got a runnable project and your first stack, `dev`. The template picked a language (TypeScript), a provider (AWS), and wired up `Pulumi.yaml` plus a `Pulumi.dev.yaml` for that stack's config.

Two files matter most:

```text
Pulumi.yaml         the PROJECT: name, runtime (nodejs/python/go), description
Pulumi.dev.yaml     the STACK:   per-environment config and secrets for "dev"
index.ts            your program: the actual resource declarations
```

*What just happened:* the project file describes the program once; each stack file holds the settings that differ between environments. Same code, different stacks.

## Stacks: one program, many environments

A **stack** is an independent instance of your program with its own state and its own config. `dev`, `staging`, `prod` are three stacks of the *same* code. This is how you avoid copy-pasting infrastructure per environment - you write it once and select a stack.

```console
$ pulumi stack ls
NAME       LAST UPDATE     RESOURCE COUNT
dev*       2 hours ago     7
staging    1 day ago       7
prod       3 days ago      9

$ pulumi stack select prod
```

*What just happened:* `stack ls` listed every stack with its resource count; the `*` marks the one you're on. `stack select prod` switched the active stack, so the next `up` targets production's state and config - not dev's. Always glance at which stack is active before you run anything that mutates.

## Config and secrets

Hard-coding a region or an instance size into your program is the thing you'll regret. Config lets each stack carry its own values. You set them with `pulumi config set` and read them in code.

```console
$ pulumi config set aws:region us-west-2
$ pulumi config set instanceCount 3
$ pulumi config set --secret dbPassword 'S3cr3t!'
```

*What just happened:* the first two went into `Pulumi.dev.yaml` as plain values. The `--secret` flag encrypted `dbPassword` before writing it, so the password never sits in the file as cleartext - it's stored encrypted and only decrypted at deploy time.

Reading config back in your program is ordinary code:

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

const config = new pulumi.Config();
const count = config.requireNumber("instanceCount");   // fails fast if missing
const password = config.requireSecret("dbPassword");   // stays a secret value
```

*What just happened:* `requireNumber` pulled `instanceCount` as a typed number and would error before any deploy if it were absent. `requireSecret` keeps the value marked as secret so Pulumi masks it in logs and stores it encrypted in state.

> Secret values stay encrypted in the stack config and in state, and Pulumi masks them in CLI output. That's strong, but it's not a vault - anyone who can run `pulumi config get --secret` or read decrypted state can see them. Scope who has that access.

## The preview-then-up rhythm

This is the loop you'll run dozens of times a day. Preview to see the plan, then apply.

```console
$ pulumi up
Previewing update (dev)

     Type                  Name           Plan       Info
 +   pulumi:pulumi:Stack   infra-dev      create
 +   ├─ aws:s3:Bucket      data           create
 ~   └─ aws:s3:BucketV2    assets         update     [diff: ~tags]

Resources:
    + 1 to create
    ~ 1 to update
    1 unchanged

Do you want to perform this update? [Use arrows] yes / no
```

*What just happened:* `up` showed the same diff `preview` would, then paused for confirmation. The symbols are worth memorizing: `+` create, `~` update in place, `-` delete, and a `+-` (replace) means the resource must be destroyed and recreated. The `[diff: ~tags]` tells you *which* property changed - here, tags.

Answer `yes` and Pulumi applies, streaming each resource as it completes:

```console
Updating (dev)

 +   aws:s3:Bucket  data    created (1s)
 ~   aws:s3:BucketV2 assets  updated (0.8s)

Outputs:
    dataBucket: "data-9f3c1a0"

Resources:
    + 1 created
    ~ 1 updated

Duration: 6s
```

*What just happened:* resources were created/updated in dependency order, and the `Outputs` block printed values you exported with `export` (TS) or `pulumi.export` (Python). Those outputs are how one stack hands values to a person, a script, or another stack.

In CI you'll skip the prompt with `pulumi up --yes`, and you can run `pulumi preview` as a required check on pull requests so reviewers see the plan before anything merges.

## Tearing down

When a stack has served its purpose:

```console
$ pulumi destroy
$ pulumi stack rm dev
```

*What just happened:* `destroy` deleted every resource the `dev` stack manages (with a preview and confirmation first, same as `up`). `stack rm` then removed the now-empty stack and its state. Order matters - destroy before you remove the stack, or you orphan real resources with no state pointing at them.

## In the wild

A common setup: one Git repo, one Pulumi project, a stack per environment, and `pulumi preview` wired into CI on every PR plus `pulumi up --yes` on merge to the matching branch. Reviewers read the plan in the PR; merging applies it. Same code path for everyone, no console clicking, and the diff is part of the review.

```quiz
[
  {
    "q": "What is a Pulumi stack?",
    "choices": [
      "A separate copy of your program's source code per environment",
      "An independent instance of one program with its own state and config (e.g. dev, staging, prod)",
      "The state backend where resources are stored",
      "A template used by pulumi new"
    ],
    "answer": 1,
    "explain": "Stacks let one program target many environments. Each stack has its own state and its own config values, selected with pulumi stack select."
  },
  {
    "q": "How do you store a database password in stack config without leaving it as cleartext?",
    "choices": [
      "pulumi config set dbPassword '...' and hope nobody opens the file",
      "Put it directly in index.ts as a string constant",
      "pulumi config set --secret dbPassword '...', which encrypts it before writing",
      "Set it as an environment variable only; Pulumi cannot store secrets"
    ],
    "answer": 2,
    "explain": "The --secret flag encrypts the value in the stack config and state, and Pulumi masks it in CLI output. Read it back with requireSecret."
  },
  {
    "q": "In pulumi up output, what does a +- (replace) symbol on a resource mean?",
    "choices": [
      "The resource is updated in place with no downtime",
      "The resource will be destroyed and recreated",
      "The resource is unchanged",
      "The resource's config has a syntax error"
    ],
    "answer": 1,
    "explain": "+ is create, ~ is update in place, - is delete, and +- means a replacement: destroy then recreate. Worth catching in preview before you apply."
  }
]
```


---

# Where the rope gets you

Phase 1 sold you on the upside: a real language means real loops, functions, and types. This phase is the bill for that power. A general-purpose language can express things infrastructure shouldn't do, and the most common Pulumi surprises come from forgetting that your program describes a desired state - it isn't a script that runs top to bottom the way you read it.

## Outputs aren't values yet

This is the single biggest source of confusion for newcomers. A resource property like a bucket's name or an instance's IP doesn't exist until Pulumi creates the resource. So Pulumi gives you an **Output** - a promise of a future value, not the value itself. You can't treat it like a plain string.

```typescript
const bucket = new aws.s3.Bucket("data");

// WRONG: bucket.id is an Output, not a string.
const url = "https://" + bucket.id + ".example.com";
// → you get "https://Calculating...something or [object Object]", not a URL

// RIGHT: enter the Output to use its eventual value.
const url = pulumi.interpolate`https://${bucket.id}.example.com`;
```

*What just happened:* the first line tried to concatenate an Output as if it were already a string, producing garbage. `pulumi.interpolate` (or `bucket.id.apply(id => ...)`) waits for the real value and computes the URL once it exists. The rule: to use what's *inside* an Output, you go through `.apply` or `interpolate` - you never read it directly.

> If you find yourself logging an Output and seeing `Calculating...` or `[object Object]`, that's the tell. The value isn't ready; you need `.apply`.

## Side effects in your program will burn you

Because it's real code, nothing stops you from calling an API, reading a file, or generating a random value at the top level of your program. But Pulumi may run your program during `preview` and again during `up`, and it expects the program to be a pure description. Side effects make your infrastructure non-deterministic.

```python
import random
import pulumi_aws as aws

# WRONG: a new name every run → Pulumi thinks the resource changed,
# and replaces it every single deploy.
name = f"cache-{random.randint(0, 9999)}"
bucket = aws.s3.Bucket(name)
```

*What just happened:* `random.randint` produced a different name on each run, so Pulumi saw the declaration drift from state and kept replacing the bucket. The fix is to let Pulumi own randomness via a resource built for it (the `random` provider's `RandomId`/`RandomPet`), which stores the value in state so it's stable across runs.

The general rule: keep your program deterministic. Compute things from config and other resources, not from the clock, the filesystem, or live API calls at deploy time.

## State is shared and fragile

Phase 1 called state precious; here's why it bites. State records what you own. Two dangers dominate:

- **Concurrent writes.** Two people (or two CI jobs) running `up` on the same stack at once can corrupt state. Pulumi's managed backends take a lock per stack to prevent this; a plain local-file backend does not.
- **State drift.** Someone changes a resource by hand in the console. Now state and reality disagree, and your next `up` may try to "fix" the manual change or fail.

```console
$ pulumi refresh
     Type              Name      Plan
 ~   aws:s3:Bucket     data      update   [diff: ~tags]

# refresh updates STATE to match the real cloud; it doesn't change your code.
```

*What just happened:* `refresh` reconciled state with the actual cloud, showing that someone had changed the bucket's tags by hand. After a refresh you can decide: update your code to match, or `up` to push your code's version back. The lesson underneath: pick a locking backend for any shared stack, and discourage console edits to managed resources.

## A whole language is more to get wrong

HCL is limited on purpose; that limitation is also a guardrail. Give a team a full language and you'll eventually find a 300-line function generating resources that nobody can follow, a clever abstraction that leaks, or a dependency on some npm package that breaks the build. The power that makes a hard case elegant makes a simple case over-engineered.

```typescript
// Tempting, and usually a mistake: deep cleverness in infra.
const tiers = computeTiersFromSomeHeuristic(loadExternalPlan());
tiers.forEach(t => buildEntireSubsystem(t));   // good luck reviewing the diff
```

*What just happened:* the readable, reviewable property of IaC quietly evaporated - the `preview` diff is now a function of code nobody can trace. Keep infra code boring. Loops and small helpers, yes; a framework, no. The goal is still that a reviewer reads the plan and understands it.

## So when should you reach for Pulumi over HCL?

Lean **Pulumi** when:

- Your team already lives in TypeScript/Python/Go and the cost of learning HCL is real.
- Infrastructure genuinely needs logic: values derived at deploy time, resources generated from dynamic lists, real unit tests over your infra code.
- You want to share infra as normal packages and use your language's tooling (types, IDE, linters).

Lean **HCL/Terraform** when:

- The team or ecosystem is already standardized on it, with established modules.
- Infrastructure is mostly static and flat, where a constrained DSL is a feature, not a limit.
- You want the guardrail of a language that *can't* do clever things.

Both share the same core model - declarative, state, plan-then-apply - so this is a choice of ergonomics and team fit, not of capability. If you want the other side of that comparison, [/guides/infrastructure-as-code-terraform](/guides/infrastructure-as-code-terraform) covers HCL directly.

## In the wild

The teams that stay happy on Pulumi treat the language as a tool for clarity, not a playground: deterministic programs, a locking shared backend, `preview` as a required CI check, and infra code held to the same boring standard as the rest of the repo. The rope is fine. You keep it short.

```quiz
[
  {
    "q": "Why can't you concatenate bucket.id directly into a string?",
    "choices": [
      "Pulumi forbids string operations on resources",
      "bucket.id is an Output - a future value that doesn't exist until the resource is created",
      "The id is always null in TypeScript",
      "You must call pulumi up first, then hard-code the result"
    ],
    "answer": 1,
    "explain": "Resource properties are Outputs: promises of values that exist only after creation. Use pulumi.interpolate or .apply to work with the eventual value."
  },
  {
    "q": "What's wrong with naming a resource using random.randint(...) at the top level of your program?",
    "choices": [
      "Random numbers aren't allowed in Python",
      "It's slower than a fixed name",
      "The name differs every run, so Pulumi sees drift and replaces the resource each deploy",
      "Nothing - it's the recommended way to get unique names"
    ],
    "answer": 2,
    "explain": "Programs should be deterministic descriptions. Side-effecting randomness changes the declaration each run. Use the random provider's resources, which store the value in state."
  },
  {
    "q": "When is HCL/Terraform often the better fit over Pulumi?",
    "choices": [
      "When infrastructure needs heavy deploy-time logic and dynamic generation",
      "When the team is already standardized on it and infra is mostly static and flat",
      "When you want to write unit tests over your infrastructure code",
      "When your team only knows TypeScript and Python"
    ],
    "answer": 1,
    "explain": "A constrained DSL is a feature for static, flat infra and established ecosystems. Pulumi shines when you need real logic or already live in a general-purpose language."
  }
]
```
