CoursesPulumiWhat Pulumi is & the model

What Pulumi is & the model

Real languages, resources, stacks.

Beginner12 min · lesson 1 of 12

Two ways to get a warehouse to fill an order. You can hand over a written list, or you can send in a person carrying the company card. The list is paper. Whatever is written on it is all that will ever happen. The person can read the shelves, do arithmetic, decide to take two of something instead of one, and step outside to make a phone call. Both come back with a full trolley. Only one of them can surprise you. Terraform hands the cloud a list. Pulumi sends a person, and that one difference explains most of what follows.

Pulumi is infrastructure as code, which means describing your servers, networks and storage buckets in reviewable, version-controlled text instead of clicking around a web console. The twist is that the text is a program in a general-purpose programming language: TypeScript, JavaScript, Python, Go, C#, Java, or a plain YAML dialect if you want no logic at all. Loops, functions, classes, if-statements, a package manager, a real test framework. All of it applies. There is no separate configuration language to learn, nothing like HCL (HashiCorp Configuration Language, the purpose-built syntax Terraform uses) with its own special constructs for counting and branching. To build ten buckets you write a for loop, because it is a for loop.

Underneath, Pulumi is more familiar than the sales pitch suggests. It runs a desired-state engine, it keeps state, and it drives the same cloud provider plugins everyone else drives. It can even wrap Terraform's providers. The difference is the front end. Terraform reads .tf files as data. Pulumi executes your program, and the resource objects your code constructs become the desired state that the engine reconciles against reality. Same destination, different vehicle.

The loop is the control

index.ts
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
const cfg = new pulumi.Config();
const retentionDays = cfg.requireNumber("logRetentionDays");
// An ordinary for loop. Nothing to learn but JavaScript.
for (const purpose of ["alb", "cloudtrail", "vpcflow"]) {
const bucket = new aws.s3.BucketV2(`logs-${purpose}`, {
tags: { Purpose: purpose, ManagedBy: "pulumi" },
});
// Every bucket gets this. There is no code path where one does not.
new aws.s3.BucketPublicAccessBlock(`logs-${purpose}-pab`, {
bucket: bucket.id,
blockPublicAcls: true,
blockPublicPolicy: true,
ignorePublicAcls: true,
restrictPublicBuckets: true,
});
new aws.s3.BucketLifecycleConfigurationV2(`logs-${purpose}-lifecycle`, {
bucket: bucket.id,
rules: [{
id: "expire",
status: "Enabled",
filter: {},
expiration: { days: retentionDays },
}],
});
}

Look at what the loop buys you as a defender. The public-access block is created in the same iteration as the bucket, so "we forgot to lock down the new one" stops being a possible outcome. A type checker catches a typo in blockPublicPolicy before anything reaches an API (application programming interface, the machine-facing front door of the cloud). A unit test can construct the program in memory and assert that every bucket has a block attached. Retention comes from stack configuration, so dev keeps 7 days and prod keeps 400 without a second copy of the file.

Four processes, not one

A building site has an architect's drawing, a foreman who reads it, and a tradesperson who holds the keys and does the drilling. Pulumi splits the work the same way, and it does it with separate operating system processes. Type pulumi up and four of them start, not one. Knowing which is which tells you where your credentials sit, where your code runs, and what to look at when a deploy hangs. Run ps while a deploy is in flight.

terminal
$ ps -eo pid,ppid,etime,comm | grep -E 'pulumi|node' | grep -v grep
output
21874 9312 00:24 pulumi
21902 21874 00:21 pulumi-language
21915 21902 00:20 node
21948 21874 00:18 pulumi-resource

Top to bottom. pulumi is the CLI (command-line tool) and the deployment engine, the part that decides what to create, update or delete. pulumi-language-nodejs is the language host, a small server that knows how to start a Node program and speak to the engine on its behalf; the kernel stores process names in 15 characters, which is why ps shows it cut short. node is your program. pulumi-resource-aws is the AWS provider plugin, a separate binary the engine downloads and launches. All four inherit the same environment, so all four can read AWS_SECRET_ACCESS_KEY out of it.

terminal
$ pulumi plugin ls
output
NAME KIND VERSION SIZE INSTALLED LAST USED
aws resource 6.66.2 536 MB 3 days ago 4 minutes ago
TOTAL plugin cache size: 536 MB

Providers are large versioned binaries cached under ~/.pulumi/plugins. Half a gigabyte for AWS is normal, because one binary covers every AWS service. They are also a supply-chain surface worth pinning. The version in that table is the code that will authenticate to your account and issue the API calls, so treat a provider bump like any other dependency bump: a diff, a reason, a reviewer.

Your program never calls AWS. That surprises people. When your code constructs new aws.s3.BucketV2(...), the SDK sends a RegisterResource message over gRPC (a fast request-and-response protocol, here bound only to the loopback interface, so the traffic never leaves the machine) to the engine, the way a kitchen order goes down an intercom rather than out the front door. The engine looks up what it recorded last time, decides whether this is a create, an update, a replace or a no-op, and asks the provider plugin to carry it out. The provider makes the real HTTPS call. Your program describes. It does not act.

What one pulumi up actually does
1CLI starts
engine plus language host
2program runs
node constructs resource objects
3RegisterResource
each object announced over local gRPC
4engine diffs
desired state vs last checkpoint
5provider acts
pulumi-resource-aws calls the AWS API
6checkpoint written
URN mapped to real ID, saved to the backend
Preview stops after the diff. Your program still runs end to end; only the mutating provider calls are skipped.

Watch what those processes talk to

That separation hands you a cheap and very sharp check. During a deploy the CLI talks to your state backend, the provider talks to the cloud, and your program talks only to localhost. So list the established connections to port 443 mid-deploy and see who shows up. Run it as the user that owns the deploy, or as root, otherwise the Process column comes back empty.

terminal
$ ss -tnp state established '( dport = :443 )'
output
Recv-Q Send-Q Local Address:Port Peer Address:Port Process
0 0 10.0.4.17:52918 52.95.128.44:443 users:(("pulumi-resource",pid=21948,fd=14))
0 0 10.0.4.17:41022 18.65.229.71:443 users:(("pulumi",pid=21874,fd=9))

Two processes, both expected: the AWS provider reaching S3, the CLI reaching the state backend. node is absent, and that absence is the whole point of the check. If your program shows an outbound connection during a deploy, something in your dependency tree is doing network work at deploy time. Maybe a helper package fetching an AMI (Amazon Machine Image, the disk image a virtual machine boots from) list. Maybe a compromised transitive dependency posting your environment variables to a stranger. You want to know which, and you want to know before somebody else tells you.

Project, stack, resource

Three nouns hold the whole model, and a stage play is a fair picture of them. The project is the script: your program plus a Pulumi.yaml that names it and says which language runtime to start. A resource is one thing you want to exist, one object your program constructs. A stack is one production of that script in one theatre. Dev, staging and prod are three productions of the same script, each with its own configuration file, its own state, and its own real cloud resources. One project, many stacks, and no copy of the code per environment.

Pulumi.yaml
name: payments-infra
runtime:
name: nodejs
options:
packagemanager: npm
description: Networking and log storage for the payments platform
Pulumi.dev.yaml
config:
aws:region: eu-west-1
payments-infra:logRetentionDays: "7"
payments-infra:alertEmail: [email protected]
payments-infra:dbPassword:
secure: AAABANa5m3+kSSuKM3vJhF0nkkFhOZ1oXWQeGHhLKz9m2Q==

Config keys are namespaced. aws:region configures the AWS provider; payments-infra:logRetentionDays is your project's own setting, the one cfg.requireNumber reads. Anything set with pulumi config set --secret is written as a secure: blob, sealed like an envelope before the file ever touches disk, which is what makes the file safe to commit. Safe to commit is not the same as safe to forget about: anybody who can reach the stack can open that envelope. Notice what the file means for review, too. Someone can raise retention or repoint alertEmail on prod without changing a line of program code, so your pull request rules have to cover Pulumi.prod.yaml as tightly as they cover index.ts.

terminal
$ pulumi stack ls
$ pulumi about
output
NAME LAST UPDATE RESOURCE COUNT URL
dev* 4 minutes ago 11 https://app.pulumi.com/acme/payments-infra/dev
staging 2 days ago 11 https://app.pulumi.com/acme/payments-infra/staging
prod 9 days ago 13 https://app.pulumi.com/acme/payments-infra/prod
CLI
Version 3.145.0
Go Version go1.23.4
Go Compiler gc
Plugins
KIND NAME VERSION
resource aws 6.66.2
Host
OS ubuntu
Version 22.04
Arch x86_64
This project is written in nodejs: executable='/usr/bin/node' version='v20.19.0'
Backend
Name pulumi.com
URL https://app.pulumi.com/acme
User r.mensah
Organizations acme
Token type personal

Read that last block carefully, because it is a finding. Token type: personal on a shared build runner means one engineer's individual account is your production deploy identity. Their laptop getting compromised, or their leaving the company, becomes a production event. On Pulumi Cloud you want an organization access token scoped to the stacks it needs. For the cloud credentials themselves, you want a role assumed through OIDC (OpenID Connect, where the CI system proves who it is to AWS and receives credentials that expire in minutes) rather than a long-lived access key parked in a secret store. CI here means continuous integration, the automated service that builds and tests every change.

The URN is the barcode

Every crate in a warehouse gets a barcode so the system can tell one box of the same product from another. Pulumi needs exactly that: a stable way to say "this bucket in the code is that bucket in AWS". The identifier is the URN (Uniform Resource Name), built from the stack, the project, the resource type and the logical name you gave the object in your program. State is a table of URNs mapped to real cloud IDs, plus the last known value of every property. Export it and read the mapping yourself.

terminal
$ pulumi stack export --stack dev \
| jq -r '.deployment.resources[]
| select(.type == "aws:s3/bucketV2:BucketV2")
| "\(.id)\t\(.urn)"'
output
logs-alb-8f3a1c2 urn:pulumi:dev::payments-infra::aws:s3/bucketV2:BucketV2::logs-alb
logs-cloudtrail-4b7e0d9 urn:pulumi:dev::payments-infra::aws:s3/bucketV2:BucketV2::logs-cloudtrail
logs-vpcflow-c19a5f3 urn:pulumi:dev::payments-infra::aws:s3/bucketV2:BucketV2::logs-vpcflow

Two things worth noticing. The logical name in your code is logs-alb; the real bucket is logs-alb-8f3a1c2. Pulumi auto-names physical resources by appending a random suffix, so dev and prod can deploy the same program without colliding, and so a replacement can be built before the old one is torn down. Handy, with a catch for defenders. Your detection rules, bucket policies and IAM (Identity and Access Management, the AWS permission system) conditions cannot match on a name you predicted. Match on tags or a name prefix, or pin the physical name yourself with the bucket property whenever a policy depends on it.

Renaming a variable can delete production
The logical name is part of the URN, and the URN is the identity. Rename logs-alb to alb-logs in your code and Pulumi does not see a rename. It sees one resource gone and a different one requested, so the plan says delete and create. On a bucket holding audit logs, or on a database, that is data loss dressed up as a tidy-up commit. Read the preview for delete and replace markers before every apply, and when you genuinely need to rename something, carry the identity across with pulumi state rename or the aliases resource option instead of editing the name in place.

Preview is the gate, and it is not a sandbox

pulumi preview computes the plan and changes nothing in the cloud. pulumi up computes the same plan and then carries it out. Add --diff and you get property-level detail instead of a one-word verdict, which is the difference between a review that means something and a rubber stamp.

terminal
$ pulumi preview --diff
output
Previewing update (dev):
Type Name Plan Info
pulumi:pulumi:Stack payments-infra-dev
~ └─ aws:s3:BucketPublicAccessBlock logs-alb-pab update [diff: ~blockPublicPolicy]
~ blockPublicPolicy: true => false
Resources:
~ 1 to update
10 unchanged

That output is a security artifact, not a progress bar. blockPublicPolicy: true => false is one line, and it is the entire incident. Post the --diff on the pull request, make a human read it, and require a second reviewer for any change touching a public-access block, a security group, an IAM policy or a KMS (Key Management Service, where AWS keeps encryption keys) key policy. The plan is the last moment where a bad change is still free to undo.

Preview runs the code, it does not sandbox it
Preview is read-only toward your cloud provider: it still issues read calls, because data-source lookups have to resolve, but it writes nothing. It is not read-only toward your build machine. To produce a plan, Pulumi runs your program in full, including every package it imports and, depending on your package manager settings, their install scripts. A preview job on a pull request from a fork therefore executes a stranger's code on your runner with whatever credentials that runner holds. On GitHub the plain pull_request event withholds secrets from fork pull requests, which is your safety net; pull_request_target and self-hosted runners take that net away. Require maintainer approval before CI runs on outside contributions, install with npm ci against a committed lockfile, check whether --ignore-scripts works for your project, and give the preview job a short-lived read-only identity rather than the deploy role.

A drift alarm you can leave running

Preview compares your program against the last state Pulumi recorded, not against reality. If somebody flipped a setting in the console at 3am, a plain preview will not notice, because state still believes the old value. --refresh fixes that: it reads current values from the cloud API first and diffs against those. Pair it with --expect-no-changes, which makes the command exit non-zero when the plan is not empty, and you have a night watchman walking the building every hour. Previews never write the checkpoint, so the whole thing runs safely with read-only cloud credentials.

/etc/systemd/system/[email protected]
[Unit]
Description=Pulumi drift check for stack %i
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=pulumi
WorkingDirectory=/srv/payments-infra
Environment=PULUMI_SKIP_UPDATE_CHECK=true
EnvironmentFile=/etc/pulumi/drift.env
ExecStart=/usr/local/bin/pulumi preview --stack %i \
--refresh --expect-no-changes --diff --non-interactive
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=full
ProtectControlGroups=yes
/etc/systemd/system/[email protected]
[Unit]
Description=Hourly Pulumi drift check for stack %i
[Timer]
OnCalendar=hourly
RandomizedDelaySec=5m
Persistent=true
[Install]
WantedBy=timers.target
terminal
$ sudo systemctl enable --now [email protected]
output
Created symlink /etc/systemd/system/timers.target.wants/[email protected] → /etc/systemd/system/[email protected].
terminal
# an hour later
$ journalctl -u [email protected] -n 4 --no-pager
output
Jul 21 11:00:52 ops-runner pulumi[3141]: ~ blockPublicPolicy: false => true
Jul 21 11:00:52 ops-runner pulumi[3141]: error: no changes were expected but changes occurred
Jul 21 11:00:52 ops-runner systemd[1]: [email protected]: Main process exited, code=exited, status=255/n/a
Jul 21 11:00:52 ops-runner systemd[1]: [email protected]: Failed with result 'exit-code'.

Read the direction of that diff. After the refresh, state says false, because false is what AWS actually reports, and the program still asks for true. Somebody turned off a public-access block by hand, and an hourly read-only check caught it. The unit runs as an unprivileged pulumi user and gets PrivateTmp=yes because Pulumi drops debug logs under /tmp. Put the access token in EnvironmentFile rather than an Environment= line: systemd reads that file as root before dropping privileges, so 0400 root-owned is enough, and unlike Environment= values the contents never show up in systemctl show for whoever asks. Your existing alerting on failed systemd units picks the failure up with no new plumbing.

Keep the program boring

The power cuts both ways. Because a Pulumi program is code, you can write things that make no sense as infrastructure: a resource name built from the current timestamp, a fresh Math.random() on every run, an HTTP call to a service that answers differently on Tuesdays. Each of those makes the plan change when nothing real changed. Keep programs deterministic. If you need a random password or a unique suffix, take it from a resource in the random provider, so the value is generated once, stored in state, and stable on every run after that.

The reason is defensive, not aesthetic. A preview that always shows churn is a preview nobody reads line by line. Diff fatigue is exactly how a quietly added ingress rule or a flipped public-access block gets waved through. A boring, empty plan is what makes one unexpected line impossible to miss.

Quick check
01Your CI runs pulumi preview --diff automatically on every pull request, including pull requests from forks, on a self-hosted runner that holds the same cloud role it deploys with. Preview never creates or changes a cloud resource. Why is this still dangerous?
Incorrect — Wrong on two counts: preview does not refresh unless you pass --refresh, and a preview never writes the checkpoint at all.
Correct — Read-only toward the cloud is not the same as read-only toward the runner. Building the plan requires running the program.
Incorrect — Diff output can leak naming and topology, and that is worth controlling, but it is disclosure. The stranger's code already ran, which is the larger problem.
Incorrect — Billing counts resources under management, not previews. This is not the exposure.
02During a pulumi up, your TypeScript program runs new aws.s3.BucketV2(...). Of the processes Pulumi starts, which one actually issues the HTTPS call that creates the bucket in AWS (Amazon Web Services)?
Incorrect — your program only sends a RegisterResource message over a local channel to the engine; it never calls AWS itself.
Incorrect — the engine diffs desired state against the checkpoint and delegates the call rather than making the API call.
Correct — the provider plugin is the only one of the four processes that makes the real cloud API call.
Incorrect — the language host just starts your program and relays messages; it issues no cloud calls.
03Mid-deploy you run ss -tnp state established '( dport = :443 )' and, besides the expected provider and CLI connections, the node process is holding an outbound HTTPS connection. What is the most reasonable read?
Correct — node making an outbound 443 connection means code in your program's tree opened it, which deserves investigation before you approve the deploy.
Incorrect — your program talks only to localhost, and its absence from that connection list is the whole point of the check.
Incorrect — the provider and your program are separate processes, so node cannot inherit the provider's job.
Incorrect — the CLI, not node, talks to the state backend.

So the first move on a Pulumi repo you inherited is not to open the code. Run pulumi stack ls to find out how many environments the program really has, because there is usually one nobody mentioned. Run pulumi about to see the backend, the provider versions and whose identity is doing the deploying. Then run pulumi preview --refresh --diff against each stack with a read-only cloud role. Three commands that change nothing, and afterwards you know what the code claims, what the cloud actually has, and how far apart the two have drifted.

Try this

Run ps -eo pid,ppid,etime,comm | grep -E 'pulumi|node' | grep -v grep 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: renaming a variable can delete production. 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