Install, backends & your first project
pulumi new, login, up.
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.
# macOSbrew install pulumi/tap/pulumi# Windowswinget install Pulumi.Pulumi # or: choco install pulumi# Linux, the one-liner every doc page showscurl -fsSL https://get.pulumi.com | sh
=== 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.
V=3.188.0curl -fsSLO https://github.com/pulumi/pulumi/releases/download/v$V/pulumi-v$V-linux-x64.tar.gzcurl -fsSLO https://github.com/pulumi/pulumi/releases/download/v$V/pulumi-$V-checksums.txtsha256sum --ignore-missing -c pulumi-$V-checksums.txttar -xzf pulumi-v$V-linux-x64.tar.gz # unpacks into ./pulumi/sudo install -m 0755 pulumi/* /usr/local/bin/pulumi versionnode --version
pulumi-v3.188.0-linux-x64.tar.gz: OKv3.188.0v20.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.
pulumi login # Pulumi Cloud, the hosted service (the default)pulumi login s3://sol-pulumi-state-euw1 # storage you ownpulumi login azblob://pulumi-state # needs AZURE_STORAGE_ACCOUNT setpulumi login gs://sol-pulumi-statepulumi login file://~/pulumi-state # plain files on this machinepulumi login --local # same idea, stored under ~/.pulumipulumi logout # sign out of the current backend
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/tokensor 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.
pulumi login s3://sol-pulumi-state-euw1pulumi whoami -v
Logged in to ip-10-0-4-21 as ana (s3://sol-pulumi-state-euw1)User: anaBackend URL: s3://sol-pulumi-state-euw1
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.
aws s3api create-bucket --bucket sol-pulumi-state-euw1 --region eu-west-1 \--create-bucket-configuration LocationConstraint=eu-west-1aws 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=Enabledaws 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
{"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.
# a managed key instead of a passphrase, chosen when the stack is createdpulumi new aws-typescript \--secrets-provider="awskms://alias/pulumi-state?region=eu-west-1"# the same idea on the other two cloudspulumi 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 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.
ls -l ~/.pulumi/credentials.jsonjq '(.accessTokens[], .accounts[].accessToken) |= "pul-REDACTED"' ~/.pulumi/credentials.json
-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.
mkdir -p ~/work/first-stack && cd ~/work/first-stackpulumi login # back on Pulumi Cloud for this walkthroughpulumi new aws-typescript
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-stackproject description (A minimal AWS TypeScript Pulumi program): install lesson demoCreated 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): devCreated stack 'dev'aws:region: The AWS region to deploy into (us-east-1): eu-west-1Saved configInstalling dependencies...added 178 packages, and audited 179 packages in 12sfound 0 vulnerabilitiesFinished installing dependenciesYour 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.
name: first-stackdescription: install lesson demoruntime:name: nodejsoptions:packagemanager: npmconfig:pulumi:tags:value:pulumi:template: aws-typescript
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.
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 bucketexport 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.
pulumi preview
Previewing update (dev)View in Browser (Ctrl+O): https://app.pulumi.com/ana/first-stack/dev/previews/0198c2f1Type Name Plan+ pulumi:pulumi:Stack first-stack-dev create+ └─ aws:s3:BucketV2 my-bucket createOutputs: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.
pulumi up
Previewing update (dev)Type Name Plan+ pulumi:pulumi:Stack first-stack-dev create+ └─ aws:s3:BucketV2 my-bucket createResources:+ 2 to createDo you want to perform this update? [Use arrows to move, type to filter]> yesnodetailsUpdating (dev)View in Browser (Ctrl+O): https://app.pulumi.com/ana/first-stack/dev/updates/1Type Name Status+ pulumi:pulumi:Stack first-stack-dev created (6s)+ └─ aws:s3:BucketV2 my-bucket created (3s)Outputs:bucketName: "my-bucket-1a2b3c4"Resources:+ 2 createdDuration: 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.
pulumi stackpulumi stack export --file dev-state.jsonjq -r '.deployment.resources[].type' dev-state.json
Current stack is dev:Owner: anaLast updated: 2 minutes ago (2026-07-21 09:41:55.402 +0000 UTC)Pulumi version used: v3.188.0Current stack resources (3):TYPE NAMEpulumi:pulumi:Stack first-stack-devpulumi:providers:aws default_6_82_0aws:s3/bucketV2:BucketV2 my-bucketCurrent stack outputs (1):OUTPUT VALUEbucketName my-bucket-1a2b3c4More information at: https://app.pulumi.com/ana/first-stack/devpulumi:pulumi:Stackpulumi:providers:awsaws: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.
rm -f dev-state.jsonpulumi destroy --yespulumi stack rm dev
Destroying (dev)View in Browser (Ctrl+O): https://app.pulumi.com/ana/first-stack/dev/updates/2Type 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 deletedDuration: 4sThe resources in the stack have been deleted, but the history and configurationassociated 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"): devStack 'dev' has been removed!
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?.pulumi/locks/, and a machine without it ignores everyone else's locks.--local merely selects a local file backend and adds no locking.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?--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.
pulumi whoami -vpulumi stack ls
User: anaBackend URL: s3://sol-pulumi-state-euw1NAME LAST UPDATE RESOURCE COUNTprod* 3 hours ago 147staging 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.