تخطَّ إلى المحتوى
العودة إلى أدلة الدارسين
PulumiDevOpsInfrastructure as code3 مستويات133 قسمًايغطّي Pulumi 3.266دليل بالإنجليزية

The Complete Pulumi Guide

Provision infrastructure in Python, TypeScript or Go with Pulumi. Taught at three levels — Beginner, Mid-level and Senior — each with an in-depth guide, interview prep, and practical tips.

التوثيق الرسمي مسودّة بالذكاء الاصطناعي · مراجعة المجتمع جاريةساعدنا في مراجعته
22sections
70examples

This is part one of three. It covers everything you need to do real work with Pulumi, not a teaser. By the end you can install the tool, create a project, describe cloud infrastructure in a programming language you already know, preview what will change before it changes, deploy it, keep secrets out of your repository, run the same program for several environments, read the errors Pulumi prints, and tear everything down so you are not billed for it. Mid-level and Senior take the same topics further; nothing here is thrown away.

Each section ends with a Try it task. Do them as you go. They take a few minutes each, and infrastructure as code only sticks once you have watched a preview, a deployment, and a destroy with your own eyes. The guide is written against Pulumi CLI v3.266.0 (released 30 September 2026). Pulumi ships a new minor version about every week, so read "v3.26x or later" wherever a version matters, and never copy a patch version out of a blog post into your own notes.

What Pulumi is, and the problem it solves

Pulumi is a tool for creating and changing cloud infrastructure (servers, networks, databases, storage buckets, Kubernetes clusters, DNS records) by writing a program in a general-purpose language: TypeScript, JavaScript, Python, Go, C#, Java, or YAML. You describe the infrastructure you want. Pulumi compares that description with what it created last time, shows you the difference, and on your approval makes the cloud match.

PROGRAMyour code describes resources
→
PREVIEWwhat would change
→
UPmake the cloud match
→
STATEremember what exists

That diagram is the entire idea, and the rest of this guide is detail about each box. The word that matters most is declarative in intent, imperative in expression. Your code is ordinary code (it has variables, loops, functions, and imports) but running it does not create a server. Running it tells Pulumi "these are the resources that should exist." Pulumi then works out what to do.

Why does anyone need this? Think about how infrastructure is created without it. Someone opens the cloud provider's web console, clicks through a wizard to create a storage bucket, ticks a few options, and moves on. Two weeks later a colleague needs the same bucket in a second environment and cannot remember which options were ticked. A month later the bucket is misconfigured and nobody knows whether that was always so or whether someone changed it. The infrastructure exists, but the knowledge of how it was made lives in someone's memory and in a console history nobody reads.

Infrastructure as code moves that knowledge into files you commit. The bucket's settings are lines in a repository, so they are reviewed in a pull request, versioned, diffed, and reproducible. To make a second environment you run the same program again. To find out who changed a setting you run git log. To recover from a disaster you run the program against an empty account.

🔁

Reproducible environments

The same program creates dev, staging, and production, each as its own stack with its own settings.

👀

Preview before you change

Every change shows a diff first: what will be created, updated, replaced, or deleted.

🧩

Real-language abstractions

Loops, functions, classes, packages, and unit tests work on your infrastructure the way they work on application code.

🔐

Secrets handled properly

Passwords and tokens are encrypted in configuration and in state, never committed as plain text.

What you need to follow along: a terminal, a programming language runtime (this guide uses TypeScript on Node.js, with Python shown alongside wherever the difference matters), and ideally an AWS account, although every concept works the same on Azure, Google Cloud, or Kubernetes, and a section below shows how to practise with no cloud account at all.

Try it
  1. Think of one piece of cloud infrastructure you or your team created by clicking in a console.
  2. Write down every setting you would need to recreate it exactly in a different account.
  3. Note how many of those settings you actually remember.
a list with gaps. Every gap is knowledge that lives nowhere but the running system, and closing that gap is what the next hour of reading is for.

What came before, and where Pulumi fits

Three generations of tooling led here, and knowing them helps you understand the choices Pulumi made and helps in interviews, where the comparison comes up constantly.

Scripts. The first automation was shell scripts calling the cloud provider's command line: aws ec2 run-instances .... Scripts are imperative: they describe steps, not the desired outcome. Run one twice and you get two servers. Nothing records what the script made, so cleaning up means writing a second script. There is no preview and no notion of "the difference between what exists and what I want."

Template languages. Tools such as AWS CloudFormation (JSON and YAML templates) and Terraform (its own language, HCL) introduced the declarative model: you list the resources you want, and the tool computes the steps. They also introduced state, the tool's record of what it created, which makes preview, update, and delete possible. Their weakness is that the template language is deliberately limited. Loops, conditionals, and reuse exist but are awkward, and sharing logic means learning a parallel set of concepts (modules, count, for_each, functions) that your application language already does better.

General-purpose languages. Pulumi keeps the declarative model and the state, and replaces the template language with real languages. A loop is a for loop. A reusable building block is a class or a function. A dependency is an npm or pip package. You can unit-test a component with the test framework you already use. The trade-off is honest: a full programming language can do more, including things that make infrastructure harder to reason about. Section after section below comes back to keeping programs simple.

Terraform and OpenTofu remain widely used, and you will meet them in most job descriptions. They are covered in the Terraform guide. Pulumi can also read Terraform: it can convert HCL to a Pulumi program and can use Terraform providers. The two tools are cousins, not enemies, and the vocabulary you learn here (state, plan, apply) maps across with small renames.

Shell scripts Template tools (CloudFormation, HCL) Pulumi
Describes Steps Desired resources Desired resources
Language Shell A dedicated template language TypeScript, Python, Go, C#, Java, YAML
Preview a change No Yes Yes
Remembers what it made No Yes (state) Yes (state)
Loops and functions Yes, but imperative Limited, tool-specific The full language
Reuse Copy and paste Modules Packages, classes, components

One more neighbour deserves a mention. Pulumi also runs on Kubernetes (you can create Deployments and Services with it) and works alongside Helm, which is covered in the Helm guide. Whether you describe a cluster's workloads with Pulumi, with Helm, or with Kustomize is a team decision, and Pulumi can drive all three.

Try it
  1. Open the Pulumi documentation's home page at https://www.pulumi.com/docs/ and find the list of supported languages.
  2. Pick the one you know best. That is your language for the rest of this guide.
a choice of language. The code samples use TypeScript, with Python beside them where it differs, and the concepts are identical in every language.

The mental model: project, program, stack, resource

Pulumi has a small vocabulary, and nearly every confusing moment for a beginner is one of these words used loosely. Learn the four nouns below precisely and most of the tool makes sense.

the four nouns
ProjectA folder with Pulumi.yaml and your program
StackOne deployed copy: dev, staging, prod
ResourceOne cloud object the program declares
StateThe record of what each stack created

A project is a directory that contains a file named Pulumi.yaml. That file holds the project's name, the language runtime it uses, and a short description. The program is the code in the directory, index.ts or __main__.py or main.go. The project name may contain letters, digits, hyphens, underscores, and periods.

A stack is an isolated, independently configurable instance of a program. The code is one; the stacks are many. A typical project has a dev stack, a staging stack, and a prod stack, each with its own configuration (a small instance size in dev, a large one in prod) and each with its own state. Deploying dev does not touch prod, even though both come from the same code. Stack names may contain letters, digits, hyphens, underscores, and periods. The fully qualified name of a stack is organization/project/stack, for example acme/website/dev, and you will see it in the console and in commands that refer to another stack.

A resource is the declaration of one cloud object: a bucket, a virtual machine, a DNS record. In code it looks like new aws.s3.Bucket("my-bucket"). Resources come in two kinds. A custom resource is managed by a provider and maps to exactly one cloud object. A component resource is a logical grouping of other resources that you define yourself, for example "a web service" made of a load balancer, a container service, and a DNS record. Components appear later in this guide.

State is Pulumi's memory. After each deployment, Pulumi records every resource it manages: its inputs, its outputs, its dependencies, and which provider created it. The next time you run, Pulumi compares your program's declarations against this record. That comparison is the source of everything useful: a preview can tell you what will change because it knows what exists, and a deletion happens when you remove a resource from code because Pulumi remembers that it was there.

Three supporting words come up constantly, so define them now.

A provider is the plugin that knows how to talk to one cloud or service: aws, azure-native, gcp, kubernetes, cloudflare, random, and several hundred others. Your program says "I want a bucket"; the AWS provider knows which API calls create one. Providers are downloaded automatically the first time they are needed and cached under ~/.pulumi/plugins. Each provider also ships as a package for your language, such as @pulumi/aws on npm or pulumi_aws on PyPI, which gives you typed classes and editor autocompletion.

The backend is where state is stored. By default that is Pulumi Cloud, a hosted service with a free individual tier: it stores state, locks a stack while an update is running so two people cannot collide, keeps a history of every update, and encrypts secrets. The alternative is a DIY backend: state as files on your own disk, in an S3 bucket, in Azure Blob Storage, or in Google Cloud Storage. Both are covered in the next sections.

Config is the set of per-stack settings: which region, how large an instance, which domain name. It lives in a file named Pulumi.<stack>.yaml next to your program, for example Pulumi.dev.yaml.

The state file is not your cloud credentials Pulumi state records what was created, not how you log in to the cloud. Your AWS or Azure credentials stay on your machine (or in your CI system) and are read from the usual places: environment variables, a profile, or a workload identity. Pulumi never needs them stored in the project.
Try it
  1. Pretend you run an online shop with a website bucket, a database, and a DNS record, in development and in production.
  2. Write down which of these is a project, which is a stack, and which are resources.
one project (the shop's infrastructure), two stacks (dev and prod), and three resources per stack. If you wrote "a project per environment", reread the stack paragraph: environments are stacks of one project.

How a Pulumi run actually works

Knowing what happens when you type pulumi up turns many later errors from mysteries into things you can predict, so this section is worth the five minutes.

Your program is not run by Pulumi's engine directly. Three separate processes cooperate, all on your own machine, talking to each other over a local connection (gRPC on 127.0.0.1).

  1. The CLI and its deployment engine. This is the pulumi command you typed. The engine is the brain: it decides what to create, update, replace, or delete.
  2. The language host. A helper program named like pulumi-language-nodejs or pulumi-language-python. It starts your program using your installed Node.js or Python and watches it run.
  3. One or more resource providers. The plugins that make the real API calls to AWS, Azure, or whichever service is involved.

When your code executes new aws.s3.Bucket("my-bucket"), the language SDK does not call AWS. It sends a registration ("there should be a bucket called my-bucket with these arguments") to the engine. The engine reads the last recorded state, decides whether this bucket is new, changed, or unchanged, and if it needs to act, asks the provider to do so. The provider makes the API call and returns the result, which the engine records in state and hands back to your program as outputs.

YOUR CODEruns in the language host
→
ENGINEcompares with state
→
PROVIDERcalls the cloud API
→
STATEupdated and stored

Two consequences follow, and both cause real confusion if nobody tells you.

Your program's side effects are not infrastructure. If your code calls console.log, reads a file, or fetches a web page, that happens on your machine each time the program runs, including during preview. Only resources you register are managed. This is why you should keep programs free of side effects: a program that writes a file or sends an email will do so on every preview.

Anything removed from the code is deleted from the cloud. The engine deletes resources that exist in state but were not registered in this run. That is the feature (delete a resource from code, run pulumi up, it is gone) and also the trap: commenting out a block of code is a deletion request. The preview shows it, which is one of the many reasons the preview is not optional reading.

Pulumi does not refresh before every run Pulumi trusts its state. If someone changes a resource by hand in the console, Pulumi does not know until you run pulumi refresh (or pulumi up --refresh), which reads the real cloud and updates state to match. Hand edits that Pulumi has not yet seen are called drift, and they are a constant source of surprise in teams that mix console clicks with code.

If you ever see commands hang with no output on a work laptop, this architecture is usually why: a corporate proxy is intercepting the local 127.0.0.1 traffic between the three processes. Add 127.0.0.1 and localhost to your NO_PROXY setting. Pulumi also needs to reach https://api.pulumi.com (and app.pulumi.com for the console), so a firewall that blocks them will stall a Pulumi Cloud login.

Try it
  1. Once you have completed the install section below, run pulumi about in any folder.
  2. Read the plugin and language sections of its output.
a list of the CLI version, the language hosts, and the plugins installed on your machine. The language hosts in that list are the second of the three processes above.

Installing Pulumi and checking your setup

Pulumi needs about 2 GHz of CPU, 4 GB of RAM, and 1 GB of disk. On macOS you need Ventura (13) or later; on Windows, version 8 or later.

On macOS, the official Homebrew tap is the recommended route:

BASH
brew install pulumi/tap/pulumi

On Linux (and macOS without Homebrew) use the install script, which puts the CLI in ~/.pulumi/bin and edits your shell profile so the directory is on your PATH:

BASH
curl -fsSL https://get.pulumi.com | sh

You can pin a version by adding -s -- --version 3.266.0 after sh. On Windows, use either package manager:

POWERSHELL
winget install pulumi
POWERSHELL
choco install pulumi

Now verify it worked, and look at what you have:

BASH
pulumi version
TEXT
v3.266.0
BASH
pulumi about

pulumi about prints the CLI version, the plugins installed, the language runtime it detects, the current backend, and the host operating system. It is also the first thing to paste into a support question or an issue report.

Apple silicon: install the arm64 build On a Mac with an M-series chip, an x86_64 build of Pulumi running under Rosetta can crash or hang for no obvious reason. Run file $(which pulumi). If it says x86_64 on an Apple silicon machine, reinstall with Homebrew, which picks the right build.

Pulumi does not include your language. Install it separately:

  • Node.js for TypeScript and JavaScript. Since CLI 3.249.0 the Pulumi Node.js SDK requires Node.js 22 or later, and Node 20 no longer works. Use a current LTS release (22 or 24). A typical failure on an old laptop is a cryptic error right after pulumi up starts; node --version tells you if that is the cause. Keep TypeScript at version 6 or earlier: TypeScript 7 is not yet compatible with how Pulumi compiles your program.
  • Python, any currently supported version. Pulumi can use pip, poetry, or uv to manage dependencies, and pip with a virtual environment is the default.
  • Go, any currently supported version.
  • YAML needs nothing extra: the YAML runtime ships inside the CLI. It is a fine way to start if you only want to see the engine working, although most of this guide's ideas (loops, functions) need a real language.

Finally, install shell completion so you can tab-complete commands. Generate the script for your shell (bash, zsh, fish, or powershell) and follow your shell's instructions for loading it:

BASH
pulumi gen-completion zsh

Everything Pulumi stores on your machine (downloaded plugins, workspace files, templates, stored logins, logs) lives under ~/.pulumi by default. You can move that directory with the PULUMI_HOME environment variable, which is useful in CI or on a machine with a small home disk.

A corporate network can break downloads If a provider plugin fails to download, or the CLI reports a certificate error, a security proxy or TLS-inspecting gateway is the usual cause. Do not turn certificate verification off. Export your organisation's root certificate and point Node or your system trust store at it, or ask your platform team for the approved route.
Try it
  1. Install Pulumi with the method above for your operating system.
  2. Run pulumi version and pulumi about.
  3. Run node --version (or python3 --version, go version) and confirm it meets the requirement above.
a version string beginning v3.2 and a language runtime that Pulumi supports. If pulumi is "command not found", open a new terminal so your PATH change takes effect.

Logging in: where your state lives

Before Pulumi can remember anything it needs somewhere to store state. You choose this once with pulumi login, and the choice is remembered on your machine.

BASH
pulumi login

With no argument, this signs you in to Pulumi Cloud. A browser window opens (or, on a machine without one, you paste an access token), and a free individual account is created if you do not have one. Confirm who you are and where you are connected with:

BASH
pulumi whoami -v

The output lists your user name, the organizations you belong to, and the backend URL. Pulumi Cloud is the recommended starting point for a beginner for practical reasons. It locks a stack while an update runs, it keeps a browsable history of every update with a diff you can open in the browser, it encrypts secrets with a per-stack key, and it needs nothing set up beforehand. pulumi console opens the page for your current stack.

If you would rather not create an account, or you work where sending state to a third party is not allowed, use a DIY backend. The simplest is a local folder:

BASH
pulumi login --local

State is then written under ~/.pulumi on your machine. That is fine for learning, and useless for a team, because nobody else can see it. For a team without Pulumi Cloud the same command accepts a cloud bucket, for example pulumi login s3://my-team-pulumi-state, and Azure Blob Storage and Google Cloud Storage work the same way. A DIY backend is free, but you give up the hosted conveniences: there is no history page, no role-based access, and Pulumi ESC (covered in later levels) does not work with it. The Pulumi documentation also warns that a DIY backend cannot transparently recover from certain kinds of partial failure, which is why most teams choose Pulumi Cloud unless a rule forbids it.

Data residency is a real reason to choose a DIY backend Some employers in the Gulf and Egypt require that infrastructure metadata stays in a particular country or cloud region. State contains resource names, IDs, and encrypted secrets. If that cannot leave the region, put a DIY backend in a bucket in your approved region (AWS me-central-1 in the UAE, for example) or ask whether the organisation runs a self-hosted Pulumi Cloud, which is an Enterprise option. Ask before you choose; it is hard to migrate later.
pulumi logout is more destructive than it sounds Since CLI 3.246, pulumi logout deletes all stored backend configuration from your machine, not just the current session. You will need to run pulumi login again and, for a DIY backend, re-enter its URL. Nothing is deleted from the backend itself, and your stacks are safe, but expect to sign in again.

Your program also needs credentials for the cloud you are building on, and these are separate from Pulumi's login. For AWS, Pulumi uses the same credential chain as the AWS command line: environment variables such as AWS_ACCESS_KEY_ID, or a named profile in ~/.aws/credentials, or a single sign-on session. If aws sts get-caller-identity works in your terminal, Pulumi's AWS provider will find the same identity. Azure uses az login, and Google Cloud uses gcloud auth application-default login. You will see in a moment that a missing credential shows up as an error from the provider during pulumi up, not as a Pulumi login problem.

Try it
  1. Run pulumi login and complete the sign-in (or pulumi login --local if you prefer no account).
  2. Run pulumi whoami -v.
  3. In the same terminal run aws sts get-caller-identity (or the equivalent for your cloud) to confirm the cloud credentials work.
your user name and a backend URL from Pulumi, plus an account ID from AWS. Two separate identities, both needed: one to store state, one to create infrastructure.

Your first project, step by step

The fastest way to start is a template, a ready-made project for a given cloud and language. Create an empty folder and run pulumi new inside it:

BASH
mkdir hello-pulumi && cd hello-pulumi
pulumi new aws-typescript

If you run pulumi new with no template name, recent versions ask two plain questions: which cloud provider, and which language. (Before CLI 3.258 it listed every template; before 3.257 it also offered an AI mode, which has been retired. If an older tutorial shows different prompts, the tutorial is out of date.) Since 3.259 the interactive flow asks you to confirm the project name, stack name, and initial configuration together in one step rather than one prompt per value. Accept the defaults (project name hello-pulumi, stack name dev) and set the AWS region to one near you when asked.

When the command finishes, look at what it created:

BASH
ls
TEXT
Pulumi.dev.yaml  Pulumi.yaml  index.ts  node_modules  package.json  tsconfig.json

Each file has one job.

File What it is
Pulumi.yaml The project file: name, runtime, description
Pulumi.dev.yaml The config for the dev stack, such as aws:region
index.ts Your program: the resources you declare
package.json Node.js dependencies, including @pulumi/pulumi and @pulumi/aws
tsconfig.json TypeScript compiler settings

Open Pulumi.yaml. It is short:

Pulumi.yaml
name: hello-pulumi
description: A minimal AWS TypeScript Pulumi program
runtime:
  name: nodejs
  options:
    packagemanager: npm

The runtime key tells the CLI which language host to launch. Now open index.ts, which is your program. The template's content varies between releases, so replace it with this small, stable version:

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

// Declare one storage bucket.
const bucket = new aws.s3.Bucket("my-bucket");

// Export values so you can read them after deployment.
export const bucketName = bucket.id;

Read it line by line. The first two lines import the Pulumi core library and the AWS provider library. The next line creates a resource: new aws.s3.Bucket("my-bucket"). The first argument, "my-bucket", is the resource's logical name, the name Pulumi uses to identify it inside the stack. It is not necessarily the bucket's name in AWS (more on that shortly). The last line exports the bucket's ID as a stack output, so that after a deployment you can read it.

The Python equivalent, in __main__.py, is nearly the same:

__main__.py
import pulumi
import pulumi_aws as aws

bucket = aws.s3.Bucket("my-bucket")

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

Before deploying, make sure dependencies and plugins are installed. pulumi new usually does this, but running it again is harmless and is the fix when you clone someone else's project:

BASH
pulumi install

Now ask Pulumi what it would do, without doing it:

BASH
pulumi preview
TEXT
Previewing update (dev):
     Type                 Name              Plan
 +   pulumi:pulumi:Stack  hello-pulumi-dev  create
 +   └─ aws:s3:Bucket     my-bucket         create

Resources:
    + 2 to create

Two resources to create: the bucket you declared, and a stack resource that Pulumi adds automatically as the root of everything in the stack. Now make it real:

BASH
pulumi up

Pulumi runs the preview again, then asks for confirmation with a menu: yes, no, or details. Choose details once to see the full property list, then yes. After a few seconds:

TEXT
Updating (dev):
     Type                 Name              Status
 +   pulumi:pulumi:Stack  hello-pulumi-dev  created
 +   └─ aws:s3:Bucket     my-bucket         created

Outputs:
    bucketName: "my-bucket-d7c2fa0"

Resources:
    + 2 created

Duration: 6s

Notice the bucket's real name: my-bucket-d7c2fa0. You asked for my-bucket and Pulumi added a random suffix. That is auto-naming, and it is deliberate: S3 bucket names are global across all AWS accounts, so a fixed name like my-bucket would collide with someone else's. Auto-naming also lets Pulumi create a replacement before deleting the old one, which a fixed name would prevent. You can read the real name any time:

BASH
pulumi stack output bucketName
Skip the question in scripts pulumi up -y answers yes automatically, which is useful in CI and in throwaway experiments. Never use it on production interactively; the confirmation step exists so that you read the preview. pulumi up -m "add logging bucket" attaches a message to the update that shows in the history.

Run pulumi up again without changing anything. The preview says Resources: 2 unchanged. That is the declarative model working: Pulumi compared your program to its state, found no difference, and did nothing. Run your program a hundred times and you still have one bucket.

Try it
  1. Create the project with pulumi new aws-typescript (or aws-python), replace the program with the one above, and run pulumi preview, then pulumi up.
  2. Run pulumi up a second time.
  3. Open the AWS console, find the bucket, and compare its name to pulumi stack output bucketName.
a bucket whose name is your logical name plus a random suffix, and a second up that reports nothing to do. Leave the stack deployed; the next sections change it.

Reading a preview: the four symbols

The preview is the most important output Pulumi produces, and reading it fluently is the skill that separates people who use Pulumi safely from people who hit yes and hope. Every line carries a symbol.

Symbol Meaning Word in the plan
+ The resource will be created create
~ The resource will be updated in place update
- The resource will be deleted delete
+- The resource will be replaced: a new one is created, then the old one is deleted replace

There are two more you will meet, = for an import and > for a read of an existing object, but these four cover nearly everything.

Make a change and see them. Add tags to the bucket in index.ts:

index.ts
const bucket = new aws.s3.Bucket("my-bucket", {
  tags: { team: "data-platform", env: "dev" },
});

Run pulumi preview --diff. The --diff flag shows property-level changes instead of a summary:

TEXT
  pulumi:pulumi:Stack: (same)
    ~ aws:s3/bucket:Bucket: (update)
        [id=my-bucket-d7c2fa0]
      ~ tags: {
          + env : "dev"
          + team: "data-platform"
        }
Resources:
    ~ 1 to update
    1 unchanged

A ~ means the bucket can be changed in place and nothing is destroyed. The properties that changed are listed with their own symbols. Apply it with pulumi up.

Now the dangerous one. Some properties cannot be changed on an existing cloud object; the provider must destroy and recreate it. Pulumi calls this a replace, and the preview shows +- along with which property forced it. For example, changing the availability zone of a virtual machine or the name of a database forces a replacement. A replace of a stateless web server is fine. A replace of a database means data loss unless you have planned for it.

Read the preview for +- and - before you type yes A line that starts with - or +- on a database, disk, bucket with data, or DNS record deserves a pause. Pulumi's default order for a replace is create-new-then-delete-old, which keeps downtime low, but the old object's data does not come along. Ask "what happens to the data?" before confirming, and use protect (covered in a later section) on anything you could not recreate.

For a machine-readable version in scripts, pulumi preview --json and --output json print a structured summary. For human review in a pull request, most teams post the --diff text to the pull request. That habit, preview on every change, is the single most valuable thing Pulumi gives you over clicking in a console.

Try it
  1. Add the tags block above, run pulumi preview --diff, and read every line before applying.
  2. Run pulumi up and confirm.
  3. Now remove the tags block again and preview once more. Do not apply.
a ~ update with the tags appearing, then the same update in reverse with the tags disappearing. Removing a property is a change too, and Pulumi shows it.

Inputs and outputs: the idea that trips everyone

Every beginner hits the same wall within the first hour, and this section exists so you hit it gently. The wall is the Output type.

When you declare a resource, some of its values are known immediately because you wrote them: the tags above. Others are only known after the cloud creates the thing: the bucket's ARN, a virtual machine's IP address, a database's endpoint. Your program runs before the cloud object exists, so it cannot hold that value as a plain string. Pulumi wraps it in an Output: a container for a value that will arrive later, similar to a Promise or a Future in other languages.

index.ts
const bucket = new aws.s3.Bucket("my-bucket");

// bucket.arn is not a string. It is Output<string>.
export const arn = bucket.arn;

An Output carries three things: the eventual value, the list of resources it depends on, and a flag saying whether it is secret. During pulumi preview, outputs of resources that do not exist yet are unknown, and the engine knows that. This is how it draws the dependency graph: if resource B uses an output of resource A, Pulumi knows to create A first, without you writing anything.

Because the value is not available yet, you cannot use it like a string. The mistake looks innocent:

index.ts
// WRONG: building a string from an Output
const url = `https://${bucket.bucketDomainName}/index.html`;

Pulumi catches it and prints a message like this:

TEXT
Calling [toString] on an [Output<T>] is not supported.
To get the value of an Output<T> as an Output<string> consider either:
1: o.apply(v => `prefix${v}suffix`)
2: pulumi.interpolate `prefix${v}suffix`

In Python the same mistake reads Calling __str__ on an Output[T] is not supported. Read it as: "that value is not a string, it is a promise of one, and your template literal turned it into the text Calling toString... instead." The fix is one of three tools.

pulumi.interpolate (TypeScript) builds a string from Outputs, the way a template literal builds a string from values. It is the tool you will use most:

index.ts
export const url = pulumi.interpolate`https://${bucket.bucketDomainName}/index.html`;

apply runs a function when the value becomes known and returns a new Output. Use it when you need to transform a value rather than just embed it:

index.ts
export const upperName = bucket.bucket.apply(name => name.toUpperCase());

pulumi.all combines several Outputs so you can use them together:

index.ts
export const summary = pulumi
  .all([bucket.bucket, bucket.region])
  .apply(([name, region]) => `${name} lives in ${region}`);

In Python the same three tools are Output.format, .apply, and Output.all:

__main__.py
url = pulumi.Output.format("https://{0}/index.html", bucket.bucket_domain_name)
upper_name = bucket.bucket.apply(lambda name: name.upper())
summary = pulumi.Output.all(bucket.bucket, bucket.region).apply(
    lambda args: f"{args[0]} lives in {args[1]}"
)

Notice that the f-string in the last line is inside the apply callback, where args holds real strings. That is the rule: inside apply, the values are plain; outside it, they are Outputs.

A resource's inputs, by contrast, accept either a plain value or an Output. You can pass bucket.id directly as an argument to another resource, and Pulumi unwraps it for you. That is why you rarely need apply just to connect resources: you only need it when you want to compute something.

Do not create resources inside apply A resource declared inside an apply callback does not appear in pulumi preview, because the callback does not run until the value is known. You will deploy changes you never saw. Compute values in apply; declare resources at the top level of the program.
Catch the mistake early Set PULUMI_ERROR_OUTPUT_STRING=true in your shell and any accidental conversion of an Output to a string becomes a hard error instead of a quiet wrong value. The @pulumi/eslint-plugin package catches the same mistake in your editor.
Try it
  1. Export bucket.arn and bucket.bucketDomainName as stack outputs.
  2. Add a third export, website, using pulumi.interpolate to build http://<domain>/.
  3. Run pulumi up, then pulumi stack output --json.
all three values printed, with real strings, after deployment. During the preview they would have shown as unknown, because the values did not exist yet.

Names: logical, physical, and the URN

Pulumi tracks every resource by a URN (uniform resource name), a string that is unique within a stack:

TEXT
urn:pulumi:dev::hello-pulumi::aws:s3/bucket:Bucket::my-bucket

Read it left to right: the stack (dev), the project (hello-pulumi), the resource's type (aws:s3/bucket:Bucket, a package, a module, and a type), and finally the logical name you gave it (my-bucket). The logical name is what you wrote in code. The physical name is the name in the cloud (my-bucket-d7c2fa0). The URN is how the engine decides "this is the same resource as last time," and that has a consequence beginners hit early.

If you change a resource's logical name, Pulumi sees a deletion and a creation. The old URN is gone from your code, so the old resource is deleted; the new URN is new, so a new one is created. Rename "my-bucket" to "website-bucket" and the preview shows +-: a new bucket and the old bucket deleted, along with its contents. If you truly want to rename without recreating, add the aliases resource option telling Pulumi "this is the same thing as the old name," which is covered in the options section.

The same mechanism causes another classic error when you create resources in a loop and forget to make their names unique:

TEXT
error: Duplicate resource URN 'urn:pulumi:dev::hello-pulumi::aws:s3/bucket:Bucket::logs'; try giving it a unique name

Two resources of the same type and the same logical name have the same URN. Give each a distinct name, such as `logs-${i}` or `${name}-logs`.

Auto-naming, the random suffix, can be controlled. The default is the right choice for learning. When you need predictable names (a DNS name, a bucket referenced by another system), set the physical name yourself with the resource's name argument, for example bucket: "acme-dev-logs", and accept that a fixed name prevents Pulumi from creating a replacement before deleting the old one. A project-wide policy is also available through the pulumi:autonaming configuration setting, which is a topic for the mid-level guide.

Try it
  1. Run pulumi stack --show-urns and find the URN of your bucket.
  2. Rename the logical name in code from "my-bucket" to "site-bucket" and run pulumi preview. Do not apply.
  3. Change it back.
a +- or a create plus a delete for what felt like a cosmetic rename. That is the URN doing its job, and the reason you should never rename logical names casually.

Stacks: one program, several environments

A stack is how one program becomes several environments. You already have one: dev. List stacks with:

BASH
pulumi stack ls
TEXT
NAME  LAST UPDATE    RESOURCE COUNT  URL
dev*  2 minutes ago  2               https://app.pulumi.com/you/hello-pulumi/dev

The asterisk marks the currently selected stack, the one every command acts on. Create a second stack and switch to it:

BASH
pulumi stack init staging
pulumi stack select dev

stack init creates and selects the new stack; stack select switches between existing ones. Each stack has its own state, its own config file (Pulumi.staging.yaml), and its own set of real cloud resources. Running pulumi up while staging is selected creates a second bucket, entirely separate from the dev one.

Always know which stack is selected The most dangerous beginner mistake is running pulumi destroy or pulumi up against the wrong stack because you forgot which one was selected. Make it a habit to run pulumi stack (no arguments) before any change. You can also name the stack explicitly for a single command with --stack or -s, for example pulumi up -s dev, which does not change your selection.

Stacks are also where you look at outputs. The exports from your program belong to the stack:

BASH
pulumi stack output
pulumi stack output bucketName
pulumi stack output --json

Values that Pulumi knows are secret are masked unless you pass --show-secrets.

Other stack commands you will use in your first month:

Task Command
Create and select a stack pulumi stack init <name>
Switch stacks pulumi stack select <name>
Create if missing, then select (handy in CI) pulumi stack select --create <name>
See every stack in the project pulumi stack ls
See the resources in the current stack pulumi stack
Read an output pulumi stack output <name>
Rename a stack pulumi stack rename <new>
See the update history pulumi stack history
Remove an empty stack pulumi stack rm <name>

A stack can only be removed once it has no resources. Destroy first, then stack rm. Pulumi refuses otherwise, which protects you from orphaning real infrastructure. If you do want to remove everything in one go, pulumi destroy --remove destroys the resources and then removes the stack and its config file.

Try it
  1. Run pulumi stack init staging and then pulumi up. Notice that the bucket gets a different random suffix.
  2. Run pulumi stack ls and see both stacks, each with its own resource count.
  3. Switch back with pulumi stack select dev and confirm with pulumi stack.
two independent buckets from one program, one per stack. That is the multi-environment story in a nutshell.

Configuration: making one program behave differently per stack

If two stacks were identical there would be no point in two stacks. Config holds the values that differ: a region, an instance size, a feature flag, a domain. It lives in Pulumi.<stack>.yaml and is managed with pulumi config.

BASH
pulumi config set aws:region me-central-1
pulumi config set bucketPrefix acme-dev
pulumi config
TEXT
KEY               VALUE
aws:region        me-central-1
hello-pulumi:bucketPrefix  acme-dev

This edits Pulumi.dev.yaml, which you commit to git:

Pulumi.dev.yaml
config:
  aws:region: me-central-1
  hello-pulumi:bucketPrefix: acme-dev

Keys have a namespace: aws:region belongs to the AWS provider, and a bare key such as bucketPrefix is stored under your own project's name (hello-pulumi:bucketPrefix). Provider settings use the provider's name, and your own settings use the project's.

Read config in your program with the Config class:

index.ts
const config = new pulumi.Config();
const prefix = config.require("bucketPrefix");          // fails if unset
const retentionDays = config.getNumber("retentionDays") ?? 30; // optional, with a default

const bucket = new aws.s3.Bucket("my-bucket", {
  bucketPrefix: `${prefix}-`,
  tags: { retention: String(retentionDays) },
});
__main__.py
config = pulumi.Config()
prefix = config.require("bucketPrefix")
retention_days = config.get_int("retentionDays") or 30

The methods follow a pattern you can memorise. require fails with a helpful message if the key is missing; get returns nothing (undefined or None) if it is missing; and there are typed variants (getNumber, getBoolean, getObject in TypeScript; get_int, get_bool, get_object in Python). Use require for values the program cannot work without, and get with a default for optional ones.

When a required value is missing you see:

TEXT
error: Missing required configuration variable 'hello-pulumi:bucketPrefix'
	please set a value using the command `pulumi config set bucketPrefix <value>`

Pulumi even prints the command that fixes it. Set the value per stack; each stack has its own:

BASH
pulumi config set bucketPrefix acme-staging --stack staging
Config files are for config, not for code A good test: if changing a value should not need a code review, it belongs in config. A good beginner set is region, environment name, instance size, and feature flags. The resource shapes themselves stay in code.
Try it
  1. Add bucketPrefix config as above and use it in the program.
  2. Run pulumi config set bucketPrefix acme-dev on dev and a different value on staging.
  3. Delete the config value on one stack with pulumi config rm bucketPrefix and run pulumi preview to read the error.
the "Missing required configuration variable" message with the fix printed in it. Get used to reading it: it is the most common first error.

Secrets: passwords that never appear in plain text

Some config values are sensitive: database passwords, API tokens, private keys. Committing them to git in plain text is how credentials leak. Pulumi's answer is to encrypt them.

BASH
pulumi config set --secret dbPassword 'S3cr3t-Value!'

Look at what was written to Pulumi.dev.yaml:

Pulumi.dev.yaml
config:
  aws:region: me-central-1
  hello-pulumi:dbPassword:
    secure: v1:xYz1Abc...:Q8m2...

The value is ciphertext. The file is safe to commit, and only someone with access to the stack's encryption key can read it. With Pulumi Cloud, the key is managed per stack by the service. With a DIY backend the default is a passphrase you choose, which you must supply through the PULUMI_CONFIG_PASSPHRASE environment variable (or a file named in PULUMI_CONFIG_PASSPHRASE_FILE) in non-interactive contexts. The other options, cloud key management services such as AWS KMS, are for later levels.

Read a secret in your program with the Secret variants:

index.ts
const dbPassword = config.requireSecret("dbPassword"); // Output<string>, flagged secret
__main__.py
db_password = config.require_secret("dbPassword")

The result is an Output carrying a secret flag, and that flag is contagious: any value computed from a secret is also secret. When you pass it to a resource, Pulumi encrypts it in state as well, and in the CLI it prints [secret] instead of the value.

TEXT
Outputs:
    dbPassword: [secret]

To view it deliberately, pulumi config get dbPassword prints the plaintext of one config value, and pulumi stack output --show-secrets shows secret outputs. Use them knowing that your terminal history and screen-sharing software can see the result.

A secret is only a secret where you marked it If you read a value with plain config.require instead of requireSecret, the value is treated as ordinary text and may be printed or stored unencrypted. Use pulumi.secret(value) (or Output.secret in Python) to mark a computed value as secret. Do not log secrets inside apply callbacks, because the callback receives the plaintext.
Lose the passphrase, lose the secrets On a DIY backend using the passphrase provider, a forgotten passphrase means you cannot decrypt the stack's secrets. The error is incorrect passphrase. Store the passphrase in a password manager the moment you choose it. Also remember that an empty string is acceptable for learning but must still be set: a non-interactive run without the variable fails with passphrase must be set with PULUMI_CONFIG_PASSPHRASE or PULUMI_CONFIG_PASSPHRASE_FILE environment variables.
Try it
  1. Run pulumi config set --secret dbPassword hunter2, then open Pulumi.dev.yaml and look for the plaintext.
  2. Export the secret with export const pw = config.requireSecret("dbPassword"); and run pulumi up.
  3. Compare pulumi stack output pw with pulumi stack output pw --show-secrets.
ciphertext in the file, [secret] in the normal output, and the real value only when you explicitly ask for it.

Practising without a cloud account

You do not need an AWS account to learn Pulumi's workflow. Providers exist for things that are not clouds. The random provider, for instance, creates random values and records them in state, which is enough to watch preview, update, replace, and destroy without paying for anything.

Create a project with the local backend:

BASH
pulumi login --local
mkdir sandbox && cd sandbox
pulumi new typescript --yes
npm install @pulumi/random

The typescript template is a cloud-free starter, and --yes accepts the defaults. Because a local backend uses a passphrase, set PULUMI_CONFIG_PASSPHRASE in your shell first, or the command prompts for one. Replace index.ts:

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

const pet = new random.RandomPet("server-name", { length: 2 });
const id = new random.RandomId("deploy-id", { byteLength: 4 });

export const petName = pet.id;
export const deployId = id.hex;

Run pulumi up and you get a stack with two resources and two outputs, for example petName: "quiet-heron". Now change length: 2 to length: 3 and preview. You will see the replacement (+-) because the provider cannot change the length of an existing random value, only make a new one. That is a safe, free demonstration of the most important lesson in the preview section.

Everything in the rest of this guide, apart from the cloud-specific resources, works the same way in this sandbox.

Try it
  1. Build the sandbox above, run pulumi up, and record the pet name.
  2. Change length and run pulumi up --diff.
  3. Run pulumi destroy and then pulumi stack rm sandbox-dev (or the stack name shown by pulumi stack ls).
a replacement with a new name, then a clean destroy, all without an account or a bill.

The everyday commands, grouped by what you are trying to do

You have now met most of the day-to-day surface area. Here it is grouped by intent, because you will look things up by what you want, not by command name.

Starting and setting up

Goal Command
Create a project from a template pulumi new <template>
Install language dependencies and plugins pulumi install
Sign in to a backend pulumi login or pulumi login --local
Check who and where you are pulumi whoami -v
Inspect your environment pulumi about

Seeing what would happen, and making it happen

Goal Command
Preview a change pulumi preview
Preview with property-level detail pulumi preview --diff
Deploy, with confirmation pulumi up
Deploy, no confirmation pulumi up -y
Deploy with a message on the history pulumi up -m "message"
Sync state with the real cloud first pulumi up --refresh
Only reconcile state with reality pulumi refresh

Inspecting

Goal Command
List the stack's resources pulumi stack
Show URNs with them pulumi stack --show-urns
Read outputs pulumi stack output [name] [--json]
See the update history pulumi stack history
Open the console page pulumi console

Configuring

Goal Command
Set a value pulumi config set key value
Set a secret pulumi config set --secret key value
Read one value pulumi config get key
List all values pulumi config
Remove a value pulumi config rm key

Cleaning up

Goal Command
Delete every resource in the stack pulumi destroy
Delete and remove the stack pulumi destroy --remove
Remove an empty stack pulumi stack rm

Two habits make these commands pleasant. First, use --help liberally: pulumi up --help lists every flag. Second, notice that most commands accept --stack (short -s) and --cwd (short -C) so you can point them at a stack or folder without changing your shell's state.

Some names have aliases Newer Pulumi versions use list and remove as the canonical subcommand names, with ls and rm kept as aliases, so both pulumi stack ls and pulumi stack list work. pulumi new is an alias for pulumi project new. Older tutorials use either spelling, and both still run.
Try it
  1. Without looking above, list the commands for: see what a change would do, deploy it, read an output, and delete everything.
  2. Run pulumi up --help and find three flags you have not used yet.
preview, up, stack output, and destroy, plus a few flags such as --diff, --yes, and --refresh. Reading help text is part of the skill.

Resources and their options

So far you have created resources with a name and an arguments object. There is a third argument, the resource options, that changes how Pulumi treats the resource rather than what the cloud object looks like. You will use a handful of them early.

index.ts
const logs = new aws.s3.Bucket("logs");

const site = new aws.s3.Bucket("site", {
  tags: { purpose: "website" },
}, {
  dependsOn: [logs],   // create after logs
  protect: true,       // refuse to delete
});

The options you should know on day one:

Option What it does When you need it
dependsOn Forces creation order when there is no output connecting two resources A resource needs another to exist but does not use its values
protect Makes Pulumi refuse to delete or replace the resource Databases, buckets with data, anything you cannot recreate
parent Nests one resource under another Building components; grouping in the output tree
provider Uses a specific provider instance Creating resources in a second region or account
ignoreChanges Ignores differences in named properties A property is changed by something outside Pulumi
aliases Tells Pulumi a resource used to have a different name or parent Renaming or refactoring without a replace
retainOnDelete Leaves the cloud object in place when the resource is removed from state Letting go of a resource without destroying it
import Adopts an existing cloud object into Pulumi Bringing hand-made infrastructure under management
customTimeouts Sets how long create, update, and delete may take Slow resources such as databases

dependsOn is rarely needed. Pulumi builds the dependency graph automatically from Outputs, so if resource B uses a.id, A is created first. Reach for dependsOn only when the dependency is real but invisible in the values.

protect is worth adopting immediately for anything stateful. With it on, pulumi destroy or a removal from code fails with a message like this:

TEXT
error: resource "urn:pulumi:dev::hello-pulumi::aws:s3/bucket:Bucket::site" cannot be deleted because it is protected.
To unprotect the resource, either remove the `protect` flag from the resource in your Pulumi program and run `pulumi up`, or use the command: `pulumi state unprotect urn:pulumi:dev::hello-pulumi::aws:s3/bucket:Bucket::site`

That error is the feature working, not a bug. Resources you adopt with the pulumi import command are protected by default for the same reason.

provider becomes essential when you need two regions. The default provider reads the aws:region config; an explicit provider overrides it for chosen resources:

index.ts
const dubai = new aws.Provider("uae", { region: "me-central-1" });
const ireland = new aws.Provider("ireland", { region: "eu-west-1" });

const primary = new aws.s3.Bucket("primary", {}, { provider: dubai });
const backup = new aws.s3.Bucket("backup", {}, { provider: ireland });

aliases rescues a rename. After changing the logical name from "my-bucket" to "site-bucket", add the old name as an alias and Pulumi treats it as the same resource:

index.ts
const bucket = new aws.s3.Bucket("site-bucket", {}, {
  aliases: [{ name: "my-bucket" }],
});

Keep the alias in place until every stack has been updated once, then you may remove it.

The old name for transforms is deprecated You may see the resource option transformations in older examples. It is deprecated; the current option is transforms. You will not need either for a while, but do not copy the old one into new code.
Try it
  1. Add protect: true to your bucket and run pulumi up.
  2. Run pulumi destroy and read the error.
  3. Remove the option, run pulumi up, and destroy again later when you are finished.
the "cannot be deleted because it is protected" message and a clean recovery path. Remember the two ways out: remove the flag and deploy, or pulumi state unprotect.

Real-language superpowers: loops, functions, and components

Everything so far could be done in a template language. This is where Pulumi's choice to use real languages pays off, and also where beginners can tie themselves in knots, so the pattern here is small and careful.

Loops create several similar resources from data. One logical name per item, unique:

index.ts
const teams = ["data", "ml", "platform"];

const buckets = teams.map(team =>
  new aws.s3.Bucket(`${team}-artifacts`, {
    tags: { team },
  })
);

export const bucketNames = buckets.map(b => b.id);
__main__.py
teams = ["data", "ml", "platform"]

buckets = [
    aws.s3.Bucket(f"{team}-artifacts", tags={"team": team})
    for team in teams
]

pulumi.export("bucket_names", [b.id for b in buckets])

Adding "security" to the list adds one bucket on the next up; removing one deletes that bucket. The data drives the infrastructure.

Functions remove repetition:

index.ts
function managedBucket(name: string, team: string): aws.s3.Bucket {
  return new aws.s3.Bucket(name, {
    tags: { team, managedBy: "pulumi" },
  });
}

const raw = managedBucket("raw-data", "data");
const models = managedBucket("models", "ml");

Components are the next step up. A component resource groups related resources into one named unit, with its own inputs and outputs. It is a class that extends ComponentResource:

index.ts
interface TeamStorageArgs {
  team: string;
}

class TeamStorage extends pulumi.ComponentResource {
  public readonly artifactsBucket: pulumi.Output<string>;
  public readonly logsBucket: pulumi.Output<string>;

  constructor(name: string, args: TeamStorageArgs, opts?: pulumi.ComponentResourceOptions) {
    super("acme:storage:TeamStorage", name, {}, opts);

    const artifacts = new aws.s3.Bucket(`${name}-artifacts`, {
      tags: { team: args.team },
    }, { parent: this });

    const logs = new aws.s3.Bucket(`${name}-logs`, {
      tags: { team: args.team },
    }, { parent: this });

    this.artifactsBucket = artifacts.id;
    this.logsBucket = logs.id;

    this.registerOutputs({
      artifactsBucket: this.artifactsBucket,
      logsBucket: this.logsBucket,
    });
  }
}

const mlStorage = new TeamStorage("ml", { team: "ml" });
export const mlArtifacts = mlStorage.artifactsBucket;

Four details make a component correct. The super call takes a type token in the form package:module:Type (here acme:storage:TeamStorage; the package part is your own choice), the name, a properties object, and the options. Children pass { parent: this } so they nest under the component in the output tree. Child names include the component's name (`${name}-artifacts`) so two instances do not collide on URNs. And registerOutputs tells Pulumi the component has finished and which values it exposes.

In the preview, the tree makes the grouping visible:

TEXT
 +   pulumi:pulumi:Stack           hello-pulumi-dev  create
 +   └─ acme:storage:TeamStorage   ml                create
 +      ├─ aws:s3:Bucket           ml-artifacts      create
 +      └─ aws:s3:Bucket           ml-logs           create

Start with loops and functions. Reach for a component when you find yourself copying the same group of three resources and wanting one name for them. Deeper component patterns (packaging, sharing across languages, testing) are mid-level territory.

Keep the program simple enough to read A full programming language can hide a lot. A program that reads a file to decide what to deploy, calls an external service, or uses random numbers will produce a different preview each time and be unreviewable. Keep branching on config values, not on the environment your code runs in, and keep side effects out.
Try it
  1. Turn a list of three team names into three buckets with a loop.
  2. Run pulumi preview, then add a fourth name and preview again.
  3. Wrap two related buckets into a TeamStorage component and compare the preview trees.
a preview showing exactly one new bucket when you added one name, and a nested tree once components are involved. Infrastructure that responds to data is the payoff of a real language.

Destroying and cleaning up

Cloud resources cost money while they exist, so learning to remove them is half of learning to create them. The command that deletes everything a stack manages is pulumi destroy:

BASH
pulumi destroy

It shows a preview in which every line is a -, asks for confirmation, and deletes the resources in reverse dependency order: things that depend on others go first. When it finishes, the stack still exists but is empty, and its history remains. Remove the empty stack and its config file with:

BASH
pulumi stack rm dev

or do both at once with pulumi destroy --remove. Stack config files are not deleted by destroy alone; Pulumi.dev.yaml stays in your folder until the stack is removed.

Destroy can fail, and the reasons are instructive. An S3 bucket that still contains objects cannot be deleted by the cloud unless the bucket is marked forceDestroy: true; set that in code, run pulumi up to apply it, and then destroy. A virtual machine with termination protection on, or a database with deletion protection, behaves the same way: the provider refuses because the cloud refuses. The fix is always "change the attribute in code, apply it, then destroy," because Pulumi's record must match reality at each step.

If a destroy fails because your cloud credentials expired since the resources were created, Pulumi reuses the provider configuration it saved in state, which can be stale. Re-authenticate, then run pulumi destroy --run-program, which re-runs your program so the provider gets fresh settings.

Destroy is not undoable There is no recycle bin. Before destroying, run pulumi stack to confirm the stack name, read the list of resources in the preview, and make sure you are in the right cloud account. Tag practice resources with something like purpose: learning and set a calendar reminder so a forgotten database does not bill for a month.
Try it
  1. Run pulumi stack to confirm the selected stack.
  2. Run pulumi destroy, read the preview, and confirm.
  3. Run pulumi stack ls and note the resource count, then remove the stack.
a count of 0 after destroy, and the stack gone after stack rm. Check the cloud console to be sure nothing lingers.

Common errors and how to read them

Pulumi's error messages are usually good, and often include the fix. The skill is knowing which part to read. A failed update prints the resource that failed, the provider's message, and a summary. Read from the bottom up: the last error: line is the cause, and the lines above it are context.

Message What it means Fix
no Pulumi.yaml project file found (searching upwards from <dir>) You are in the wrong folder cd into the project, or use pulumi up -C <dir>
no stack selected; please use pulumi stack select or pulumi stack init No current stack in this workspace pulumi stack select <name>, or pass --stack
Missing required configuration variable 'proj:key' config.require for an unset key pulumi config set key value (add --secret if printed)
Calling [toString] on an [Output<T>] is not supported An Output used in a template string pulumi.interpolate or apply
Duplicate resource URN ...; try giving it a unique name Two resources with the same type and name Make logical names unique, for example in loops
resource ... cannot be deleted because it is protected protect: true (or an imported resource) Remove protect and up, or pulumi state unprotect <urn>
[409] Conflict: Another update is currently in progress. The stack is locked by another run, possibly a crashed one Check the console; if nothing is running, pulumi cancel
passphrase must be set with PULUMI_CONFIG_PASSPHRASE ... Passphrase secrets, non-interactive run Export PULUMI_CONFIG_PASSPHRASE
incorrect passphrase Wrong passphrase for this stack Use the right one; there is no recovery without it
warning: Attempting to deploy or update resources with 1 pending operations from previous deployment. An earlier run was interrupted pulumi refresh
could not find plugin or plugin download failures Offline, firewalled, or rate-limited pulumi install, then check proxy and network settings
Commands hang with no output A proxy intercepts local traffic Add 127.0.0.1 and localhost to NO_PROXY

Some deserve a paragraph.

Interrupted updates. If you press Ctrl-C during an up, or your laptop sleeps, or a CI job is killed, Pulumi may not know whether a create call finished. The next run warns about pending operations. Do not ignore the warning. Run pulumi refresh interactively: it asks about each pending operation and reconciles state with the cloud. If an interrupted create left a cloud object behind, refresh can adopt it or you can delete it by hand; either way state and reality agree again. In scripts, pulumi refresh --clear-pending-creates --yes clears the record when you know the resource was not created.

Locks and conflicts. Pulumi Cloud gives each update a lease on the stack so two runs cannot overlap. A message starting [409] Conflict means a lease is active. First check whether a teammate's or a CI job's update is really running; if it is, wait. If the lease belongs to a run that died, pulumi cancel releases it. A DIY backend uses a lock file and says the stack is currently locked by 1 lock(s), with the same remedy.

pulumi cancel can break someone else's deployment Cancelling revokes the lease held by the running update, and that update will fail immediately. Use it only when you are sure the holder is dead. On a team, ask in chat first.

Provider errors. When the cloud rejects a request, Pulumi prints the provider's message verbatim: AccessDenied, BucketAlreadyExists, InvalidParameterValue. These are not Pulumi bugs. Read the message, then read the resource's documentation in the Pulumi Registry (search for the resource type, such as aws.s3.Bucket) to check the argument. An AccessDenied almost always means your cloud identity lacks a permission, not that Pulumi is misconfigured. For a first diagnosis try aws sts get-caller-identity to confirm which identity Pulumi is using.

Node and TypeScript version errors. Since CLI 3.249 the Node.js SDK needs Node 22 or later. If an upgrade suddenly breaks a working project with an unexplained runtime error, check node --version first, then confirm TypeScript is version 6 or earlier.

When you are stuck, add -v=3 --logtostderr to a command for detailed logs, or --debug for engine debug output. Automatic logging is also on by default since 3.249, written (encrypted) under ~/.pulumi/logs, and the pulumi logs family of subcommands manages them. Share output with care: verbose logs can contain values you would rather not paste into a public issue.

Try it
  1. Cause two errors on purpose: delete a required config key, then rename a resource to the same logical name as another.
  2. For each, find the line of the message that names the cause and the line that suggests the fix.
you will have met "Missing required configuration variable" and "Duplicate resource URN" on your own terms. Errors you have caused deliberately are far less scary in production.

Putting it all together

Here is a small end-to-end project that uses every idea above: a project, two stacks, config, a secret, a loop, a component, protection, outputs, and a clean destroy. It creates storage buckets for a team's ML work. Budget about thirty minutes.

1. Create the project and two stacks.

BASH
mkdir ml-storage && cd ml-storage
pulumi new aws-typescript --name ml-storage --stack dev --yes
pulumi stack init staging
pulumi stack select dev

2. Set configuration per stack. The region, which environment this is, and a list of team names as a JSON array, plus a secret that a later application would use.

BASH
pulumi config set aws:region me-central-1
pulumi config set environment dev
pulumi config set --path 'teams[0]' data
pulumi config set --path 'teams[1]' ml
pulumi config set --secret apiToken 'replace-me-with-a-real-token'

The --path flag writes structured values; here it builds a list named teams. Do the same for staging, using --stack staging and only one team if you like.

3. Write the program.

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

const config = new pulumi.Config();
const environment = config.require("environment");
const teams = config.requireObject<string[]>("teams");
const apiToken = config.requireSecret("apiToken");

class TeamStorage extends pulumi.ComponentResource {
  public readonly artifactsBucket: pulumi.Output<string>;

  constructor(name: string, team: string, env: string, opts?: pulumi.ComponentResourceOptions) {
    super("acme:storage:TeamStorage", name, {}, opts);

    const artifacts = new aws.s3.Bucket(`${name}-artifacts`, {
      tags: { team, environment: env, managedBy: "pulumi" },
      forceDestroy: env !== "prod",
    }, { parent: this, protect: env === "prod" });

    this.artifactsBucket = artifacts.id;
    this.registerOutputs({ artifactsBucket: this.artifactsBucket });
  }
}

const storages = teams.map(team => new TeamStorage(`${environment}-${team}`, team, environment));

export const bucketNames = storages.map(s => s.artifactsBucket);
export const tokenPreview = apiToken.apply(t => t.length);

Read what each part does. Config supplies the environment and team list, so the same code serves both stacks. The secret is read with requireSecret, and the only thing exported is its length (still a secret output, because it derives from a secret). The component wraps one bucket per team with consistent tags. forceDestroy is on outside production so practice stacks can be cleaned up, and protect is on only in production. The loop turns the team list into components with unique names that include the environment.

4. Preview, read, and deploy.

BASH
pulumi preview --diff
pulumi up -m "first deployment of team storage"
pulumi stack output --json

The preview shows a stack, two components, and two buckets, all +. After up, the outputs list two real bucket names. The token length shows as [secret].

5. Change something and watch the diff. Add a third team, security:

BASH
pulumi config set --path 'teams[2]' security
pulumi preview

Exactly one new component and one bucket appear as +, and everything else is unchanged. Apply it.

6. Deploy the second stack and compare.

BASH
pulumi stack select staging
pulumi config set environment staging
pulumi config set aws:region me-central-1
pulumi config set --path 'teams[0]' data
pulumi config set --secret apiToken 'another-token'
pulumi up

The same program, a different set of buckets. pulumi stack ls now shows two stacks with their own resource counts.

7. Destroy both.

BASH
pulumi destroy --yes --remove
pulumi stack select dev
pulumi destroy --yes --remove

Afterwards pulumi stack ls is empty, the AWS console shows no buckets from the exercise, and the two Pulumi.<stack>.yaml files are gone with the stacks. To keep the project, commit Pulumi.yaml, index.ts, package.json, and the lock file to git, and commit each stack's config file too: secrets are encrypted in it, so it is safe. Do not commit node_modules, and do not commit any plaintext credentials.

Try it
  1. Build the project above from scratch, without copying the commands blindly: decide your own team names, tags, and region.
  2. Add a README.md that says which commands set up each stack and what the required config keys are.
  3. Ask a colleague (or your future self in a fresh folder) to deploy it using only the README, then destroy it.
infrastructure you can recreate from a repository in minutes. The README is what turns it from your experiment into something a team can use.

What you can now do, and what comes next

You can explain what Pulumi does and how it differs from scripts and template tools, install the CLI and check the setup, choose a backend, create a project from a template, read a preview and tell an update from a replacement, understand Outputs and use interpolate, apply, and all, create stacks and give each its own configuration, keep secrets encrypted, apply resource options such as protect and provider, build resources from loops and components, read Pulumi's common errors, and destroy cleanly. That is a working practitioner's toolkit, enough to own a small project's infrastructure.

Can you...
Say what a stack is and how it differs from a project? One deployed copy of a project's program
Explain why pulumi up twice does nothing the second time? Desired state matches recorded state
Read +, ~, -, and +-? Create, update, delete, replace
Explain why ${bucket.arn} is wrong? It is an Output, not a string; use interpolate
Say what happens if you rename a logical name? URN changes: delete and create, unless aliased
Keep a password out of git? pulumi config set --secret
Stop a database being deleted by accident? protect: true
Fix "Missing required configuration variable"? pulumi config set key value
Recover from an interrupted update? pulumi refresh
Clean up a practice stack? pulumi destroy --remove

Mid-level takes every one of those topics further: how the engine diffs, replaces, and orders resources, structured and typed configuration, stack references to share values between projects, Pulumi ESC for configuration and secrets, importing existing infrastructure, components packaged and shared across languages, testing with mocks, and running Pulumi from CI pipelines with OIDC instead of long-lived keys.

Senior then covers what you own when Pulumi is a platform for a team: state surgery and recovery, scaling large stacks, the trust model and supply chain of providers, policy as code, Pulumi Deployments, the Automation API and the Kubernetes operator, multi-tenancy, cost, and upgrades.

Natural neighbours to read next in this catalogue: the Terraform guide for the tool Pulumi is most often compared with, the Kubernetes guide for what many teams deploy onto the infrastructure Pulumi creates, and the Helm guide for packaging those Kubernetes workloads.

Sources