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.
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.
- Think of one piece of cloud infrastructure you or your team created by clicking in a console.
- Write down every setting you would need to recreate it exactly in a different account.
- Note how many of those settings you actually remember.
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.
- Open the Pulumi documentation's home page at
https://www.pulumi.com/docs/and find the list of supported languages. - Pick the one you know best. That is your language for the rest of this guide.
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.
Pulumi.yaml and your programA 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.
- Pretend you run an online shop with a website bucket, a database, and a DNS record, in development and in production.
- Write down which of these is a project, which is a stack, and which are resources.
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).
- The CLI and its deployment engine. This is the
pulumicommand you typed. The engine is the brain: it decides what to create, update, replace, or delete. - The language host. A helper program named like
pulumi-language-nodejsorpulumi-language-python. It starts your program using your installed Node.js or Python and watches it run. - 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.
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 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.
- Once you have completed the install section below, run
pulumi aboutin any folder. - Read the plugin and language sections of its output.
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:
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:
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:
winget install pulumi
choco install pulumi
Now verify it worked, and look at what you have:
pulumi version
v3.266.0
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.
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 upstarts;node --versiontells 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, oruvto manage dependencies, andpipwith 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:
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.
- Install Pulumi with the method above for your operating system.
- Run
pulumi versionandpulumi about. - Run
node --version(orpython3 --version,go version) and confirm it meets the requirement above.
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.
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:
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:
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.
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 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.
- Run
pulumi loginand complete the sign-in (orpulumi login --localif you prefer no account). - Run
pulumi whoami -v. - In the same terminal run
aws sts get-caller-identity(or the equivalent for your cloud) to confirm the cloud credentials work.
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:
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:
ls
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:
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:
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:
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:
pulumi install
Now ask Pulumi what it would do, without doing it:
pulumi preview
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:
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:
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:
pulumi stack output bucketName
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.
- Create the project with
pulumi new aws-typescript(oraws-python), replace the program with the one above, and runpulumi preview, thenpulumi up. - Run
pulumi upa second time. - Open the AWS console, find the bucket, and compare its name to
pulumi stack output bucketName.
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:
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:
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.
- 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.
- Add the
tagsblock above, runpulumi preview --diff, and read every line before applying. - Run
pulumi upand confirm. - Now remove the
tagsblock again and preview once more. Do not apply.
~ 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.
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:
// WRONG: building a string from an Output
const url = `https://${bucket.bucketDomainName}/index.html`;
Pulumi catches it and prints a message like this:
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:
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:
export const upperName = bucket.bucket.apply(name => name.toUpperCase());
pulumi.all combines several Outputs so you can use them together:
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:
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.
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.
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.
- Export
bucket.arnandbucket.bucketDomainNameas stack outputs. - Add a third export,
website, usingpulumi.interpolateto buildhttp://<domain>/. - Run
pulumi up, thenpulumi stack output --json.
Names: logical, physical, and the URN
Pulumi tracks every resource by a URN (uniform resource name), a string that is unique within a stack:
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:
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.
- Run
pulumi stack --show-urnsand find the URN of your bucket. - Rename the logical name in code from
"my-bucket"to"site-bucket"and runpulumi preview. Do not apply. - Change it back.
+- 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:
pulumi stack ls
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:
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.
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:
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.
- Run
pulumi stack init stagingand thenpulumi up. Notice that the bucket gets a different random suffix. - Run
pulumi stack lsand see both stacks, each with its own resource count. - Switch back with
pulumi stack select devand confirm withpulumi stack.
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.
pulumi config set aws:region me-central-1
pulumi config set bucketPrefix acme-dev
pulumi config
KEY VALUE
aws:region me-central-1
hello-pulumi:bucketPrefix acme-dev
This edits Pulumi.dev.yaml, which you commit to git:
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:
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) },
});
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:
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:
pulumi config set bucketPrefix acme-staging --stack staging
- Add
bucketPrefixconfig as above and use it in the program. - Run
pulumi config set bucketPrefix acme-devondevand a different value onstaging. - Delete the config value on one stack with
pulumi config rm bucketPrefixand runpulumi previewto read the 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.
pulumi config set --secret dbPassword 'S3cr3t-Value!'
Look at what was written to 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:
const dbPassword = config.requireSecret("dbPassword"); // Output<string>, flagged secret
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.
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.
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.
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.
- Run
pulumi config set --secret dbPassword hunter2, then openPulumi.dev.yamland look for the plaintext. - Export the secret with
export const pw = config.requireSecret("dbPassword");and runpulumi up. - Compare
pulumi stack output pwwithpulumi stack output pw --show-secrets.
[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:
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:
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.
- Build the sandbox above, run
pulumi up, and record the pet name. - Change
lengthand runpulumi up --diff. - Run
pulumi destroyand thenpulumi stack rm sandbox-dev(or the stack name shown bypulumi stack ls).
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.
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.
- Without looking above, list the commands for: see what a change would do, deploy it, read an output, and delete everything.
- Run
pulumi up --helpand find three flags you have not used yet.
--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.
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:
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:
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:
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.
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.
- Add
protect: trueto your bucket and runpulumi up. - Run
pulumi destroyand read the error. - Remove the option, run
pulumi up, and destroy again later when you are finished.
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:
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);
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:
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:
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:
+ 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.
- Turn a list of three team names into three buckets with a loop.
- Run
pulumi preview, then add a fourth name and preview again. - Wrap two related buckets into a
TeamStoragecomponent and compare the preview trees.
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:
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:
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.
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.
- Run
pulumi stackto confirm the selected stack. - Run
pulumi destroy, read the preview, and confirm. - Run
pulumi stack lsand note the resource count, then remove the stack.
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.
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.
- Cause two errors on purpose: delete a required config key, then rename a resource to the same logical name as another.
- For each, find the line of the message that names the cause and the line that suggests the fix.
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.
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.
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.
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.
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:
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.
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.
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.
- Build the project above from scratch, without copying the commands blindly: decide your own team names, tags, and region.
- Add a
README.mdthat says which commands set up each stack and what the required config keys are. - Ask a colleague (or your future self in a fresh folder) to deploy it using only the README, then destroy it.
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
- Pulumi documentation home
- Download and install Pulumi
- How Pulumi works
- State and backends
- Using a DIY backend
- Projects and the Pulumi.yaml file
- Stacks
- Configuration
- Secrets
- Inputs and outputs
- Resource names and auto-naming
- Resource options
- Resource providers
- Component resources
- Languages and SDKs
- CLI command reference
- CLI environment variables
- Troubleshooting
- Pulumi CLI changelog