CoursesPulumiResources & the program

Resources & the program

Declaring infra in code.

Beginner12 min · lesson 3 of 12

Two ways to get a wall built. You can stand there shouting brick-by-brick instructions at the crew and hope you remember which bricks are already laid. Or you hand them a drawing of the finished wall, and let them walk the site, compare the drawing against what is standing, and lay only what is missing. A shell script is the shouting. A Pulumi program is the drawing, and the Pulumi engine (the part of Pulumi that does the comparing and the actual work) is the crew that reads it. Last lesson you scaffolded a project and ran your first up. This one is about what goes inside the program: the resources, the arguments that shape them, and the options that tell the engine how to look after them.

The Program Is the Entry Point

Pulumi.yaml is the project manifest, a short file that names the project and picks a runtime, meaning the language your program is written in. That runtime's main file is the entry point, and the engine runs it top to bottom like any other script. There is no separate infrastructure language to learn here. It is real TypeScript, Python, Go, C#, or Java, so imports, loops, functions, and unit tests behave the way they always do. One thing turns ordinary code into infrastructure: the resource constructor.

Pulumi.yaml
name: acme-infra
runtime: nodejs
description: Storage and logging for the web app

A new call on a provider's resource type registers exactly one resource with the engine, and it always takes the same three things in the same order. First a logical name, a string that has to be unique among resources of that type. Second an arguments object, describing what you want the cloud thing to look like. Third an options bag, which you can leave off. (Component resources, which wrap several resources behind a single constructor, are the exception, and they get their own lesson.) Running the program on its own changes nothing in your account. It builds a graph of the resources you want in memory and hands that graph to the engine, which does the comparing and makes the API calls (application programming interface calls, the remote requests a cloud service accepts over the network). The examples below use the AWS (Amazon Web Services) provider and S3 (Simple Storage Service, Amazon's object storage, where a bucket is a named container for files).

index.ts
import * as aws from "@pulumi/aws";
// new <Type>(logicalName, args, opts) registers exactly one resource
const assets = new aws.s3.BucketV2("assets", {
tags: { app: "web", env: "dev" },
});
// a stack output: a top-level value recorded in state and printed after `up`
export const bucketName = assets.bucket;
terminal
pulumi up --yes
output
Updating (dev):
Type Name Status
+ pulumi:pulumi:Stack acme-infra-dev created (3s)
+ └─ aws:s3:BucketV2 assets created (1s)
Outputs:
bucketName: "assets-7f3ab21"
Resources:
+ 2 created
Duration: 6s

Two resources, not one. Pulumi always creates a root pulumi:pulumi:Stack resource that owns everything else in the stack, which is why the count says 2. Look at the bucket's real name, too. You asked for assets and got assets-7f3ab21. More on that shortly. And bucketName is a stack output, a top-level value written into state (Pulumi's own ledger of every resource it has created and what that resource looks like) and readable later with pulumi stack output bucketName. Stack outputs sit in that ledger in the clear unless you wrap them in pulumi.secret(), so never export a connection string, token, or password without doing that.

Arguments Describe the Cloud, Options Steer the Engine

Ordering coffee is two conversations at once. What you want (large, oat milk, extra shot) goes into the cup. How the shop should handle the order (keep my tab open, do not clear the cup if I step outside for a call) is a note for the staff and never touches the drink. The second constructor argument is the drink. The third is the note to the staff.

Arguments are the resource's inputs: a bucket's tags, a virtual machine's AMI (Amazon Machine Image, the disk template it boots from), a security group's rules. They are different for every resource type in every provider, and a provider is the plugin that knows how to talk to one cloud's API. Options are engine behaviour. They are identical everywhere, on every provider, because the engine implements them and the cloud never sees them at all. Beginners mix the two up constantly, and it matters, because nearly every safety knob you have lives in the options bag.

index.ts
const logs = new aws.s3.BucketV2("logs", {
bucket: "acme-web-logs-prod",
tags: { app: "web", retention: "365d" },
}, {
protect: true, // refuse to delete this resource at all
dependsOn: [assets], // ordering, even though no value flows between them
ignoreChanges: ["tags", "tagsAll"], // stop correcting tags edited outside the program
deleteBeforeReplace: true, // when a replace is forced, delete first, then create
});

protect refuses to delete the resource, full stop. retainOnDelete is the gentler cousin: Pulumi forgets the resource but leaves the real thing running, which is what you want for an audit-log bucket that has to outlive the stack. dependsOn forces ordering between two resources that are related but pass no data to each other. ignoreChanges tells Pulumi to stop correcting the fields you list, and on the AWS provider you usually have to name tagsAll alongside tags, because the provider keeps a merged copy of every tag under that second name. deleteBeforeReplace flips the default create-then-delete order when a replacement is forced, which you need for anything whose name has to be globally unique. parent nests one resource under another for grouping.

terminal
pulumi destroy --stack prod --yes
output
Previewing destroy (prod):
Type Name Plan Info
- pulumi:pulumi:Stack acme-infra-prod delete
- └─ aws:s3:BucketV2 logs delete 1 error
Diagnostics:
aws:s3/bucketV2:BucketV2 (logs):
error: unable to delete resource "urn:pulumi:prod::acme-infra::aws:s3/bucketV2:BucketV2::logs"
as it is currently marked for protection. 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:prod::acme-infra::aws:s3/bucketV2:BucketV2::logs'`
error: preview failed

That error is the whole point of the option. protect turns an accidental destroy, a bad merge, or a stack wiped by an over-eager pipeline into a loud failure instead of a missing database. Clearing it takes a deliberate act that leaves a trace behind: drop the flag in code and run up, where a reviewer sees the diff, or run pulumi state unprotect <urn>, which lands in shell history and CI logs. Put protect: true on databases, KMS keys (Key Management Service, the cloud service that holds the keys your data is encrypted with), log buckets, and anything holding evidence you would need after an incident.

Two more options earn their keep. import: "<existing-id>" adopts a resource that already exists in the account instead of creating a fresh one, which is how you bring click-ops infrastructure (things somebody built by hand in the web console) under management with no downtime. If your arguments do not match what is really out there, that first up fails and prints the properties that disagree, so you edit the code until it describes reality. And additionalSecretOutputs marks properties the provider never considered sensitive. Putting additionalSecretOutputs: ["environment"] on a Lambda function (Amazon's service for running small pieces of code on demand) encrypts its environment variables in state, instead of leaving them in plaintext for anyone with read access to the backend.

ignoreChanges deserves a hard look before you reach for it. It gets sold as a convenience, so ops can retag things in the console without Pulumi undoing them overnight. What it really does is remove those fields from the comparison. Keep in mind that pulumi preview diffs your program against the recorded state, not against the live account (going out and reading reality is what pulumi refresh does), and an ignored field is dropped from that diff either way. So if somebody widens a bucket policy you have listed in ignoreChanges, preview reports no changes. Quietly. Forever. Keep the list short, never put a policy, rule, or permission field in it, and fix the process that lets people edit resources by hand.

The three arguments to every resource constructor
1. Logical name
"assets"
unique per resource type
becomes the URN
identity in state
change it, replace it
unless you add an alias
2. Arguments
tags, bucket, policy
the cloud object's settings
different per type
provider-specific
references make edges
one output into another input
3. Options
protect, retainOnDelete
delete safety
dependsOn, parent
ordering and nesting
ignoreChanges, aliases
what the engine compares
Same shape in every language and every provider: name, args, options.

Logical Names, Physical Names, and the URN

The first string is not the name AWS sees. It is a logical name, and Pulumi bakes it into a URN (Uniform Resource Name, one long string that identifies this resource for the rest of its life). Think of the logical name as the label on a filing-cabinet folder, and the physical name as the account number printed on the paperwork inside. Relabel the folder and the paperwork does not change.

terminal
pulumi stack --show-urns
output
Current stack is dev:
Last updated: 2 minutes ago (2026-07-21 09:14:02.118 +0000 UTC)
Pulumi version used: v3.190.0
Current stack resources (3):
TYPE NAME
pulumi:pulumi:Stack acme-infra-dev
URN: urn:pulumi:dev::acme-infra::pulumi:pulumi:Stack::acme-infra-dev
pulumi:providers:aws default_6_82_2
URN: urn:pulumi:dev::acme-infra::pulumi:providers:aws::default_6_82_2
aws:s3/bucketV2:BucketV2 assets
URN: urn:pulumi:dev::acme-infra::aws:s3/bucketV2:BucketV2::assets
Current stack outputs (1):
OUTPUT VALUE
bucketName assets-7f3ab21

Read a URN left to right: the urn:pulumi: prefix, then the stack, the project, the resource type, and finally the logical name, with double colons between the parts. Every one of those is identity. Three resources are listed here but up reported two created, because the engine also records a default provider instance for you and keeps it out of the update tree. The physical name is a separate thing. By default Pulumi builds it from your logical name plus a hyphen and a short random hex suffix, which is where assets-7f3ab21 came from. That auto-naming is deliberate. It lets you run dev, staging, and a throwaway per-branch stack from one program without them fighting over names, and it makes life harder for anyone reading your repository who fancies squatting a name before you create it. Pin the physical name (the bucket argument in the code above) only when something outside your control needs a fixed string, such as a DNS record (Domain Name System, the internet's address book, which turns names into addresses) or a hardcoded log destination. The cost is real: you have given up running two copies of that stack side by side.

References Are What Build the Graph

You never tell Pulumi what order to do things in. A recipe does not number every step for you either. It says the sauce needs the stock, and the ordering falls out of that. Same here: you wire one resource's output into another's input, and the order follows. Passing logBucket.id into a bucket policy's arguments records an edge, and the policy cannot be created until the bucket exists and has an ID (identifier, the string the cloud hands back once the thing is real).

index.ts
// logical name "web-logs"; physical name pinned because a log delivery
// service outside this program writes to it
const logBucket = new aws.s3.BucketV2("web-logs", {
bucket: "acme-web-logs-prod",
});
// referencing logBucket.id creates a dependency edge in the graph
const policy = new aws.s3.BucketPolicy("web-logs-policy", {
bucket: logBucket.id,
policy: JSON.stringify({ Version: "2012-10-17", Statement: [] }),
});

You can see the edges for yourself. pulumi stack export prints the raw state as JSON (JavaScript Object Notation, a plain-text data format), and jq is the command-line tool for pulling fields back out of it.

terminal
pulumi stack export | jq '.deployment.resources[]
| select(.type == "aws:s3/bucketPolicy:BucketPolicy")
| {urn, dependencies, propertyDependencies}'
output
{
"urn": "urn:pulumi:dev::acme-infra::aws:s3/bucketPolicy:BucketPolicy::web-logs-policy",
"dependencies": [
"urn:pulumi:dev::acme-infra::aws:s3/bucketV2:BucketV2::web-logs"
],
"propertyDependencies": {
"bucket": [
"urn:pulumi:dev::acme-infra::aws:s3/bucketV2:BucketV2::web-logs"
],
"policy": []
}
}

Those edges are not guesswork. They are written into state property by property. dependencies is the resource-level list. propertyDependencies shows which specific input drew each edge, so bucket points at the log bucket while the hand-written policy string points at nothing. Resources with no edge between them go up in parallel, so thirty unrelated things take roughly as long as the slowest one rather than the sum of all thirty. On the way down the graph runs backwards: dependents are removed before the things they depend on. dependsOn covers the cases where a real ordering requirement exists but no value flows between the two, and the classic one is an IAM (Identity and Access Management, the permissions system that decides who can do what inside a cloud account) policy attachment that has to land before a compute resource boots and tries to use the permission.

Renaming the First Argument Deletes the Real Resource

Tidying assets into asset-bucket looks like a cosmetic edit. It is nothing of the kind. The logical name is baked into the URN, and the URN is the resource's identity in state, so the next run sees one URN vanish and a different one appear.

terminal
pulumi preview
output
Previewing update (dev):
Type Name Plan
pulumi:pulumi:Stack acme-infra-dev
+ ├─ aws:s3:BucketV2 asset-bucket create
- └─ aws:s3:BucketV2 assets delete
Outputs:
~ bucketName: "assets-7f3ab21" => output<string>
Resources:
+ 1 to create
- 1 to delete
2 changes. 1 unchanged
A rename is a delete plus a create
Nothing in that plan says the word rename. Pulumi will destroy the live bucket and build an empty one with a new physical name, and for a bucket that means the objects are gone. For a database it means the data is gone. This is the most common way people lose production data with Pulumi, and it usually arrives disguised as a tidy-up commit. Read every - delete line in a preview before you approve it, and treat a delete on anything that stores state as a stop sign rather than a formality.

The fix is the aliases option, which tells the engine that this new URN used to be that old one. Add it, run up (the resource comes back unchanged, because it gets adopted rather than rebuilt), then drop the alias in a later commit once state has been rewritten. The CLI (command line interface, the pulumi command you type in a terminal) can make the same edit directly with pulumi state rename, which changes the name in state and never touches the cloud. The identical trap springs when you add or change a parent, or move a resource into a component, because the parent's type becomes part of the child's URN. Alias those moves too.

index.ts
const assets = new aws.s3.BucketV2("asset-bucket", {
tags: { app: "web", env: "dev" },
}, {
aliases: [{ name: "assets" }], // adopt the existing resource, do not replace it
});

Your Program Runs Before Anything Touches the Cloud

Here is the part that should change how you review pull requests. Because the program is real code, pulumi preview has to execute it to discover what you want. Every import, every top-level statement, every helper function runs on the machine that invoked the command, with that machine's cloud credentials sitting in its environment, before a single API call has even been planned. Add one line to the program and you can watch it happen.

index.ts
// top-level code runs during preview, not only during up
console.log("the language host is executing this file");
terminal
pulumi preview
output
Previewing update (dev):
Type Name Plan
pulumi:pulumi:Stack acme-infra-dev
Diagnostics:
pulumi:pulumi:Stack (acme-infra-dev):
the language host is executing this file
Resources:
2 unchanged

Now think about a CI job (continuous integration, the pipeline that runs automatically on every code change) that previews pull requests from forks. It is running a stranger's code on a runner that usually holds deploy credentials. HCL (HashiCorp Configuration Language, Terraform's config format) gets parsed. A Pulumi program gets run. Those are different verbs. A line like require('child_process').execSync(...) in index.ts, or an install script in a dependency freshly added to package.json, executes with everything that runner can reach.

So gate the preview job behind a maintainer approval before it runs anything from a fork. Give it short-lived read-only credentials through OIDC (OpenID Connect, a way for a pipeline to obtain temporary cloud credentials without any stored key) instead of a long-lived access key. Commit your lockfile, pin provider versions, and run previews in an account separate from the one that can deploy. None of that is paranoia. It is the same reasoning you already apply to any build step that executes untrusted code.

Quick check
01Your team adds ignoreChanges: ["policy"] to a production S3 bucket policy so an on-call engineer can hand-edit it in the console during incidents. Six weeks later someone widens that policy to allow public reads. What does pulumi preview show on the next pull request?
Incorrect — Wrong twice over: preview compares your program against recorded state rather than the live account, and an ignored property is dropped from the comparison anyway.
Correct — ignoreChanges switches off correction for that field, so the widened policy stays invisible in every preview until someone removes the option or inspects the bucket directly.
Incorrect — Bucket policies update in place, and nothing about ignoreChanges turns an update into a replacement.
Incorrect — A gap between state and reality is drift, not a failure; Pulumi normally reports it as a change to make, and here it has been told not to.
02In new aws.s3.BucketV2(name, args, opts), which statement about the third argument — the options bag — is correct?
Incorrect — that describes arguments; options are identical on every provider because the engine implements them.
Correct — options steer the engine, are the same everywhere, and never reach the cloud API.
Incorrect — those are arguments describing the cloud object, not options.
Incorrect — nearly every safety knob (protect, retainOnDelete, aliases) lives in the options bag.
03You are about to pulumi destroy a stack, but one bucket holds audit logs that must survive the teardown and keep receiving writes from another system. Which resource option does that, and what does it do?
Incorrect — protect refuses deletion outright, so destroy would fail rather than hand the bucket off.
Incorrect — ignoreChanges only drops fields from the diff and does nothing to prevent deletion.
Incorrect — that only flips create/delete order during a replacement; on destroy the bucket still goes.
Correct — that is exactly the 'outlive the stack' case the lesson calls out.

When you inherit somebody else's Pulumi program, three commands give you the real picture fast. pulumi stack --show-urns is your inventory, URN by URN. grep -n 'protect\|ignoreChanges\|retainOnDelete\|aliases' *.ts shows every place someone changed how the engine treats a resource, and git log -p on those lines tells you who did it and what the commit message claimed at the time. Then wire pulumi preview --diff into CI so reviewers see property-level changes instead of a resource count. A stack whose databases are unprotected and whose permission fields sit in ignoreChanges will look completely healthy in every preview, right up to the run that deletes something.

Try this

Run pulumi up --yes 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: a rename is a delete plus a create. 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