CoursesPulumiInstall, backends & your first project

Install, backends & your first project

pulumi new, login, up.

Beginner12 min · lesson 2 of 12

Installing Pulumi looks like installing a build tool. It behaves more like setting up git on a fresh laptop. The binary on your machine is only the client. The decision that shapes everything afterwards is where the history lives. In git that place is the remote. In Pulumi it is the backend: the store that holds each stack's state, and state is the recorded list of every real resource Pulumi has built for you plus what its settings were the last time it looked.

Three moves get you running. Put the binary on the machine. Decide who holds the ledger. Stand up your first stack. Each one has a consequence you live with for the life of the project, so make all three on purpose rather than by copy and paste. What Pulumi is, and why it uses real programming languages, is the What-is lesson. How the engine compares desired state against reality is the State lesson. This one is the on-ramp.

Getting the Binary Safely

The Pulumi CLI (command line interface, the pulumi command you type) is one self-contained program written in Go. Nothing sits in the background. There is no daemon, and there is nothing to enable in systemd (the program on modern Linux that starts and supervises background services). You run it, it works, it exits. During a run it does start helper processes, one language host for your program and one plugin per cloud provider, but those die when the command finishes. So an attacker has no long-lived Pulumi process to attach to. What is worth guarding is the files it leaves in your home directory and the network calls it makes while it runs.

The binary carries no language runtime of its own. Pulumi programs are written in TypeScript, Python, Go, C#, Java, or YAML, so whichever you pick at scaffold time has to already be installed. Node.js 18 or newer for the TypeScript templates, though 18 is past its end-of-life date, so reach for 20 or 22. Python 3.9 or newer for the Python ones. Miss that and the scaffold gets as far as installing dependencies, then stops.

terminal
# macOS
brew install pulumi/tap/pulumi
# Windows
winget install Pulumi.Pulumi # or: choco install pulumi
# Linux, the one-liner every doc page shows
curl -fsSL https://get.pulumi.com | sh
output
=== Installing Pulumi v3.188.0 ===
+ Downloading https://get.pulumi.com/releases/sdk/pulumi-v3.188.0-linux-x64.tar.gz
+ Extracting to /home/ana/.pulumi/bin
+ Adding $HOME/.pulumi/bin to $PATH in /home/ana/.bashrc
=== Pulumi is now installed! ===
+ Please restart your shell or add /home/ana/.pulumi/bin to your $PATH
+ Get started with Pulumi: https://www.pulumi.com/docs/quickstart

Pause on that last line. It pulls a script off the network and feeds it straight into a shell running as you, with no chance to read it first. It works, and it is what the quickstart shows. It is also the exact shape of a supply chain attack: whoever controls that host, or anyone who can sit in the middle of the connection, gets to run code as your user. On a laptop that is annoying. On a build agent that already holds cloud credentials, that is the whole game. The careful path costs about twenty seconds. Download the release tarball and the published SHA-256 checksum file (a checksum is a fingerprint of the exact bytes, so changing one byte changes the fingerprint), check one against the other, and install only on a match.

terminal
V=3.188.0
curl -fsSLO https://github.com/pulumi/pulumi/releases/download/v$V/pulumi-v$V-linux-x64.tar.gz
curl -fsSLO https://github.com/pulumi/pulumi/releases/download/v$V/pulumi-$V-checksums.txt
sha256sum --ignore-missing -c pulumi-$V-checksums.txt
tar -xzf pulumi-v$V-linux-x64.tar.gz # unpacks into ./pulumi/
sudo install -m 0755 pulumi/* /usr/local/bin/
pulumi version
node --version
output
pulumi-v3.188.0-linux-x64.tar.gz: OK
v3.188.0
v20.19.2

That one checksum file lists every platform build of the release, so --ignore-missing tells sha256sum to check the file you actually downloaded and stay quiet about the dozen you did not. Leave the flag off and you get a wall of "No such file" lines and a failing exit code even when your download is perfect. Installing into /usr/local/bin also puts the binary on PATH (the list of directories your shell searches for commands) for every user on the box, which is what you want on a shared runner. The install script does it differently. It drops the binary in ~/.pulumi/bin and appends a PATH line to ~/.bashrc. Fine on a laptop. A nuisance in a non-interactive CI (continuous integration, the automation that builds and tests every commit) shell, because that shell never reads .bashrc and the command comes back "not found".

Where the State Lives

State is a ledger at a hotel front desk. It lists which rooms exist, which key opens each one, and what condition each room was in the last time anybody looked. Pulumi reads that ledger before every change, compares it against what your program says should exist, and writes it back when the run finishes. Delete the ledger and Pulumi has no idea it ever built anything. Edit the ledger and you can make it believe things that are not true.

pulumi login picks which desk holds it. There are two families. Pulumi Cloud is the hosted one, run by Pulumi themselves: type pulumi login with no arguments and it opens a browser to collect an access token. It is free for individual use, and you get a web console, locking so two people cannot deploy over each other, and managed encryption for secrets. The self-managed family points the same CLI at storage you already own: an Amazon S3 bucket (Simple Storage Service, Amazon's object store), an Azure Blob container, a Google Cloud Storage bucket, or plain files on a disk. Same commands, same behaviour afterwards. Only the URL changes.

terminal
pulumi login # Pulumi Cloud, the hosted service (the default)
pulumi login s3://sol-pulumi-state-euw1 # storage you own
pulumi login azblob://pulumi-state # needs AZURE_STORAGE_ACCOUNT set
pulumi login gs://sol-pulumi-state
pulumi login file://~/pulumi-state # plain files on this machine
pulumi login --local # same idea, stored under ~/.pulumi
pulumi logout # sign out of the current backend
output
Manage your Pulumi stacks by logging in.
Run `pulumi login --help` for alternative login options.
Enter your access token from https://app.pulumi.com/account/tokens
or hit <ENTER> to log in using your browser :
Logged in to pulumi.com as ana (https://app.pulumi.com/ana)

Each self-managed URL borrows that cloud's ordinary credentials. The s3:// backend reads your AWS profile or the usual environment variables, azblob:// wants AZURE_STORAGE_ACCOUNT pointing at the storage account, gs:// uses your Google application default credentials. Pulumi adds no login of its own on top, which is the point: whoever can write to the bucket can write to your state.

terminal
pulumi login s3://sol-pulumi-state-euw1
pulumi whoami -v
output
Logged in to ip-10-0-4-21 as ana (s3://sol-pulumi-state-euw1)
User: ana
Backend URL: s3://sol-pulumi-state-euw1
Which backend should hold your state?
Where does the ledger live?
pulumi login decides, per machine
you want it managed
Pulumi Cloud
login with no arguments; locking, secret encryption and history handled for you
you must own the data
S3 / Azure Blob / GCS bucket
you own the bucket, the encryption key, the backups, and locking is opt-in
you are only trying it
file:// or --local
single machine, no shared locking, easy to lose; fine for a first look only

Read access to that ledger is worth more than people expect. It is a map of your estate: resource names, identifiers, ARNs (Amazon Resource Names, the unique IDs AWS gives every object), security group rules, database endpoints, and every non-secret attribute the provider handed back. Only values you explicitly marked as secrets are encrypted. Write access is worse. Someone who can edit state can delete an entry, so the next pulumi up happily builds a second copy of that resource, or add a fake entry so up believes a control (a logging rule, an encryption setting) already exists and never creates it. That is drift nobody sees, because the tool you would use to spot drift is reading the tampered ledger.

So treat a self-managed state bucket the way you would treat a production password vault, not a scratch directory. Private, encrypted with a key you control, versioned, and logged.

terminal
aws s3api create-bucket --bucket sol-pulumi-state-euw1 --region eu-west-1 \
--create-bucket-configuration LocationConstraint=eu-west-1
aws s3api put-public-access-block --bucket sol-pulumi-state-euw1 \
--public-access-block-configuration "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
aws s3api put-bucket-versioning --bucket sol-pulumi-state-euw1 \
--versioning-configuration Status=Enabled
aws s3api put-bucket-encryption --bucket sol-pulumi-state-euw1 \
--server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":"alias/pulumi-state"},"BucketKeyEnabled":true}]}'
aws s3api get-bucket-versioning --bucket sol-pulumi-state-euw1
output
{
"Location": "http://sol-pulumi-state-euw1.s3.amazonaws.com/"
}
{
"Status": "Enabled"
}

Versioning is the setting that saves your weekend. A half-written checkpoint, or a bad state import, turns into a restore of the previous object version instead of an afternoon of hand-editing JSON (JavaScript Object Notation, the plain-text format state is stored in). Logging gives you the other half. Turn on S3 server access logging, or CloudTrail data events (CloudTrail is the audit log of API calls in your AWS account), and write them to a different bucket. That record of who read the ledger and when is exactly what you will want the day someone asks whether the map of your estate leaked.

Locking is the part people get wrong. Pulumi Cloud takes a lock on the stack for you, so a second deploy waits its turn instead of writing over the first. Self-managed backends do not do that out of the box. Two runs against the same bucket can interleave and leave a mangled checkpoint. You switch it on with an environment variable, PULUMI_SELF_MANAGED_STATE_LOCKING=1, and Pulumi then drops a lock file under .pulumi/locks/ in the bucket before a write and removes it when the run ends. Set it on every machine or on none, because a machine without it ignores the locks everyone else took. And since the lock is a file rather than a lease that expires, a run that dies halfway (a CI job that times out, a laptop that sleeps) leaves the file behind and the next deploy refuses to start until you clear it with pulumi cancel.

The other thing you take on with a self-managed backend is the key. Pulumi Cloud encrypts stack secrets with a key it manages for you. Point the CLI at a bucket or at local files and the default secrets provider becomes a passphrase instead, so pulumi new and pulumi up ask for PULUMI_CONFIG_PASSPHRASE or read it from the environment. That single passphrase encrypts every secret in the stack's config and state, the ciphertext lives inside the state file itself, and there is no escrow, no reset link, and no support ticket that brings it back. Lose it and those values are gone. In automation, feed it from a real secrets manager, or skip passphrases and hand the stack a managed key at scaffold time so the key is central, auditable and rotatable.

terminal
# a managed key instead of a passphrase, chosen when the stack is created
pulumi new aws-typescript \
--secrets-provider="awskms://alias/pulumi-state?region=eu-west-1"
# the same idea on the other two clouds
pulumi stack init prod \
--secrets-provider="azurekeyvault://sol-kv.vault.azure.net/keys/pulumi-state"
pulumi stack init prod \
--secrets-provider="gcpkms://projects/sol/locations/global/keyRings/pulumi/cryptoKeys/state"
The backend decides what up thinks exists
State lives in the backend, not in your code. Log in somewhere else, create a stack with the same name, and Pulumi reads an empty ledger: pulumi up will build a second full copy of everything while the first copy sits there unmanaged, still billing and still exposed. The reverse is worse. Point at a backend that holds real state, run pulumi destroy in the wrong terminal, and it deletes production, because as far as that ledger is concerned it is what you asked for. Run pulumi whoami -v and pulumi stack ls before any write command on a shell you did not set up yourself.

The Token in Your Home Directory

Logging in to Pulumi Cloud writes your access token to disk. Not hashed, not encrypted, plain text, in a JSON file under your home directory. Here it is, with jq (a command line tool for slicing and rewriting JSON) blanking the token so the lesson does not print one.

terminal
ls -l ~/.pulumi/credentials.json
jq '(.accessTokens[], .accounts[].accessToken) |= "pul-REDACTED"' ~/.pulumi/credentials.json
output
-rw------- 1 ana ana 486 Jul 21 09:14 /home/ana/.pulumi/credentials.json
{
"current": "https://api.pulumi.com",
"accessTokens": {
"https://api.pulumi.com": "pul-REDACTED"
},
"accounts": {
"https://api.pulumi.com": {
"accessToken": "pul-REDACTED",
"username": "ana",
"organizations": [
"ana",
"secopslog"
],
"lastValidatedAt": "2026-07-21T09:14:02.117146Z"
}
}
}

Mode 0600 means only your user can read it, which sounds fine until you remember what else runs as your user. Scaffolding a TypeScript project runs npm install (npm is the Node package manager), and npm packages are allowed to run lifecycle scripts, which are install-time commands the package author wrote. One compromised dependency, anywhere in a tree of a couple of hundred packages, is a process running as you with that token one cat away. A personal Pulumi Cloud token carries your permissions, so on most teams that means listing every stack you can see and decrypting the secrets inside them.

Three habits close most of that gap. In automation, never bake credentials.json into an image; set PULUMI_ACCESS_TOKEN from your secrets manager so the token exists in memory for the run and nowhere else. Give pipelines their own token from a service account rather than your personal one, so revoking it does not lock you out and the audit log names the pipeline instead of a human. And run pulumi logout when you finish on a shared machine, which strips the entry out of that file instead of leaving it for the next person who sits down.

Scaffolding the Project

pulumi new takes a template that pairs a cloud with a language: aws-typescript, gcp-python, azure-go, kubernetes-python, or a bare typescript or python starter with no cloud attached. Run it in an empty directory. It asks for a project name, a description, a first stack name (dev is the usual answer), then whatever config the template needs, such as the AWS region to deploy into, and it finishes by installing dependencies. If you would rather read the package tree before anything executes, pass --generate-only, which writes the files and skips both the stack and the install, then run npm install --ignore-scripts yourself. The walkthrough below is on Pulumi Cloud, since that is the default and it hands you a link to click.

terminal
mkdir -p ~/work/first-stack && cd ~/work/first-stack
pulumi login # back on Pulumi Cloud for this walkthrough
pulumi new aws-typescript
output
Logged in to pulumi.com as ana (https://app.pulumi.com/ana)
This command will walk you through creating a new Pulumi project.
Enter a value or leave blank to accept the (default), and press <ENTER>.
Press ^C at any time to quit.
project name (first-stack): first-stack
project description (A minimal AWS TypeScript Pulumi program): install lesson demo
Created project 'first-stack'
Please enter your desired stack name.
To create a stack in an organization, use the format <org-name>/<stack-name>
stack name (dev): dev
Created stack 'dev'
aws:region: The AWS region to deploy into (us-east-1): eu-west-1
Saved config
Installing dependencies...
added 178 packages, and audited 179 packages in 12s
found 0 vulnerabilities
Finished installing dependencies
Your new project is ready to go!
To perform an initial deployment, run `pulumi up`

Look at what it wrote. Pulumi.yaml describes the project: its name, the language runtime, and defaults shared by every stack. Pulumi.dev.yaml holds configuration for the single stack called dev. That split is the whole environment model. One program, many stacks, each with its own config file and its own slice of state. Adding staging means adding a file, not copying the code.

Pulumi.yaml
name: first-stack
description: install lesson demo
runtime:
name: nodejs
options:
packagemanager: npm
config:
pulumi:tags:
value:
pulumi:template: aws-typescript
Pulumi.dev.yaml
config:
aws:region: eu-west-1

Both of those belong in git. Stack config files are safe to commit because secret values in them are stored as ciphertext, so a password you set with pulumi config set dbPassword --secret lands in Pulumi.dev.yaml as an encrypted blob rather than the real string. What must never be committed is credentials.json or the passphrase that decrypts those blobs. The generated .gitignore already keeps node_modules and build output out of your way.

The program itself is a few lines. Import the provider package, construct one resource, export one value. Constructing the object is the declaration; there is no separate apply step inside the code. Editing this file to declare real infrastructure is the Resources lesson.

index.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
// Create an AWS resource (S3 bucket)
const bucket = new aws.s3.BucketV2("my-bucket");
// Export the name of the bucket
export const bucketName = bucket.id;

Preview, Then Up

pulumi preview is a dry run. It executes your program, asks each provider what the resource would look like, compares that against state, and prints the plan. Nothing is created, changed or deleted. Providers do make read-only calls while they check your config, so this is not an offline operation, but nothing in your account moves. Learn to read that plan on day one. A plus sign is a create, a minus is a delete, a tilde is an in-place update, and a plus-minus pair is a replace. Replace is the one to slow down for. It means the resource cannot be changed where it stands, so Pulumi builds a new one and deletes the old, in that order by default. Some resources force the reverse, deleting before creating, which adds an outage window. Either way nothing carries the contents across, and on a database or a bucket with data in it that ruins a Tuesday.

terminal
pulumi preview
output
Previewing update (dev)
View in Browser (Ctrl+O): https://app.pulumi.com/ana/first-stack/dev/previews/0198c2f1
Type Name Plan
+ pulumi:pulumi:Stack first-stack-dev create
+ └─ aws:s3:BucketV2 my-bucket create
Outputs:
bucketName: output<string>
Resources:
+ 2 to create

pulumi up shows that same plan and then waits. Nothing reaches your cloud account until you pick yes. Choose details at the prompt and you get the full property-level diff, which is where you catch the replace you did not intend.

terminal
pulumi up
output
Previewing update (dev)
Type Name Plan
+ pulumi:pulumi:Stack first-stack-dev create
+ └─ aws:s3:BucketV2 my-bucket create
Resources:
+ 2 to create
Do you want to perform this update? [Use arrows to move, type to filter]
> yes
no
details
Updating (dev)
View in Browser (Ctrl+O): https://app.pulumi.com/ana/first-stack/dev/updates/1
Type Name Status
+ pulumi:pulumi:Stack first-stack-dev created (6s)
+ └─ aws:s3:BucketV2 my-bucket created (3s)
Outputs:
bucketName: "my-bucket-1a2b3c4"
Resources:
+ 2 created
Duration: 8s

In a pipeline you pass --yes to skip the prompt, which is fine as long as something else is doing the reading. The usual pattern is pulumi preview on the pull request with its output posted as a comment for a human reviewer, then pulumi up --yes once the branch merges. Drop the preview step entirely and a replace gets applied at three in the morning with nobody watching.

Verify, Then Tear It Down

Two commands tell you what actually exists, and a third hands you the raw ledger.

terminal
pulumi stack
pulumi stack export --file dev-state.json
jq -r '.deployment.resources[].type' dev-state.json
output
Current stack is dev:
Owner: ana
Last updated: 2 minutes ago (2026-07-21 09:41:55.402 +0000 UTC)
Pulumi version used: v3.188.0
Current stack resources (3):
TYPE NAME
pulumi:pulumi:Stack first-stack-dev
pulumi:providers:aws default_6_82_0
aws:s3/bucketV2:BucketV2 my-bucket
Current stack outputs (1):
OUTPUT VALUE
bucketName my-bucket-1a2b3c4
More information at: https://app.pulumi.com/ana/first-stack/dev
pulumi:pulumi:Stack
pulumi:providers:aws
aws:s3/bucketV2:BucketV2

State holds one entry more than the update reported. The extra one is the default provider Pulumi created on your behalf, and its name pins the provider version this stack was built with. That exported file is also the honest answer when an auditor asks what a stack contains, because it is the same JSON the engine reads, every resource with its inputs. Snapshot it before a risky change. Then delete it when you are done, because it carries the same map as the bucket does and it is now sitting unencrypted in your working directory.

terminal
rm -f dev-state.json
pulumi destroy --yes
pulumi stack rm dev
output
Destroying (dev)
View in Browser (Ctrl+O): https://app.pulumi.com/ana/first-stack/dev/updates/2
Type Name Status
- pulumi:pulumi:Stack first-stack-dev deleted (0.15s)
- └─ aws:s3:BucketV2 my-bucket deleted (1s)
Outputs:
- bucketName: "my-bucket-1a2b3c4"
Resources:
- 2 deleted
Duration: 4s
The resources in the stack have been deleted, but the history and configuration
associated with the stack are still maintained.
If you want to remove the stack completely, run `pulumi stack rm dev`.
This will permanently remove the 'dev' stack!
Please confirm that this is what you'd like to do by typing ("dev"): dev
Stack 'dev' has been removed!
Quick check
01You deployed a stack last month with the backend set to s3://sol-pulumi-state-euw1. On a new laptop you run pulumi login (no arguments), clone the same repo, run pulumi stack init dev, and run pulumi up. What happens?
Incorrect — the code says what should exist, but state records what does exist, and state lives in whichever backend you are logged into.
Incorrect — backends have no knowledge of each other, so there is nothing there that could spot the clash.
Correct — an empty ledger means Pulumi believes nothing exists, so every resource in the program is planned as a create.
Incorrect — bringing existing resources under management is an explicit import operation, never something Pulumi does on its own.
02You point Pulumi at a self-managed s3:// (Amazon Simple Storage Service) backend shared by your team. Compared with Pulumi Cloud, what must you arrange yourself so two simultaneous pulumi up runs do not corrupt the checkpoint?
Incorrect — self-managed backends do not lock out of the box; only Pulumi Cloud does.
Correct — the variable turns on lock files under .pulumi/locks/, and a machine without it ignores everyone else's locks.
Incorrect — --local merely selects a local file backend and adds no locking.
Incorrect — Pulumi's locking is its own lock-file mechanism, not S3 object lock, and it stays off until you set the environment variable.
03A departing engineer set up a file:// backend whose secrets use a passphrase provider, keeping the passphrase only in their personal vault. After they leave, nobody has it. What happens to the encrypted secrets in that stack's state?
Incorrect — login selects a backend and has nothing to do with the secrets passphrase.
Incorrect — a self-managed passphrase has no reset path; owning the key means owning that risk.
Correct — the lesson is explicit that losing the passphrase means those values are gone for good.
Incorrect — a passphrase provider encrypts with that passphrase alone; only a managed --secrets-provider key would place them in KMS.

One habit is worth building today. On any machine you did not set up yourself, ask two questions before you run anything that writes: which ledger am I about to change, and what does that ledger already know about? Four lines of output answer both, and reading them takes about as long as it takes to type them.

terminal
pulumi whoami -v
pulumi stack ls
output
User: ana
Backend URL: s3://sol-pulumi-state-euw1
NAME LAST UPDATE RESOURCE COUNT
prod* 3 hours ago 147
staging 2 days ago 141

Try this

Run brew install pulumi/tap/pulumi on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: the backend decides what up thinks exists. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related