Component resources & reuse
Package infra into classes.
A fire door is a kit, not a slab of wood. Door leaf, steel frame, self-closing hinge, and a thin strip of intumescent seal (a plastic band that swells up and plugs the gap around the door once it gets hot). Those parts are tested and certified together, as one assembly, and the certificate covers the assembly rather than any single piece of it. Order the assembly and everything arrives on the pallet. Order a door, tell yourself you will add the seal next week, and a building ends up with something that looks right and does nothing on the night it matters. A component resource in Pulumi is that assembly. Pulumi builds cloud infrastructure from code you write in a normal programming language, TypeScript or Python or Go, and a component is a class that wraps several cloud resources into one thing your teammates order by name.
Mechanically it is a subclass of pulumi.ComponentResource. You build the child resources inside its constructor, attach each one to the component, and publish a few outputs. What comes back is one node in the resource tree with everything else hanging underneath it.
Here is the part people miss. The component has no cloud API behind it (an API, application programming interface, being the endpoint a tool calls to make real infrastructure appear). Nobody at Amazon implements CreateSecureBucket. No plugin runs create, update, or delete on the component's behalf. Pulumi records it in state as a label and a place to hang children from. It shows up in a deployment plan with the word create next to it and provisions exactly nothing. Everything that costs money or holds data is one of its children.
Three Lines Of Wiring, Then Ordinary Code
Three lines turn a plain class into a component. First, the constructor calls super() with four things: a type token, the instance name, an object of registered inputs (an empty object is fine for a component that lives inside one program), and the options the caller handed you. Forward those options. Drop them and your callers lose the ability to set a provider or a protect flag on your component at all, with nothing printed to tell them so.
The type token is the part number stamped on the assembly, and it gets printed on every part in the box. Three colon-separated pieces, package:module:Type. Pulumi writes it into state on the component and into the unique address of every resource nested underneath it. If you later package the component so Python and Go programs can consume it, the middle piece has to be the literal word index, as in secopslog:index:SecureBucket. Pulumi's own docs call that a required implementation detail, and the packaging tooling matches on it exactly.
Second, every child resource is created with { parent: this }. Third, the constructor ends with this.registerOutputs(), which publishes the values callers read and signals to the engine that this component has finished registering children. Between those three lines you write ordinary code: loops, conditionals, helper functions, whatever the language gives you. The component below builds one bucket in S3 (Simple Storage Service, Amazon's object storage) plus the settings that decide whether that bucket is safe.
import * as pulumi from "@pulumi/pulumi";import * as aws from "@pulumi/aws";// The only knobs a caller gets. Everything else is decided in here.export interface SecureBucketArgs {kmsKeyArn?: pulumi.Input<string>; // omit it and you get the S3-managed keytags?: pulumi.Input<{ [k: string]: pulumi.Input<string> }>;}export class SecureBucket extends pulumi.ComponentResource {public readonly bucket: aws.s3.BucketV2;public readonly bucketName: pulumi.Output<string>;public readonly arn: pulumi.Output<string>;constructor(name: string, args: SecureBucketArgs = {},opts?: pulumi.ComponentResourceOptions) {// package:module:Type. Stamped into every child address below.super("secopslog:storage:SecureBucket", name, {}, opts);const child = { parent: this }; // every child gets this, no exceptionsconst useKms = args.kmsKeyArn !== undefined;this.bucket = new aws.s3.BucketV2(`${name}-bucket`, {tags: args.tags,}, child);new aws.s3.BucketVersioningV2(`${name}-versioning`, {bucket: this.bucket.id,versioningConfiguration: { status: "Enabled" },}, child);new aws.s3.BucketServerSideEncryptionConfigurationV2(`${name}-sse`, {bucket: this.bucket.id,rules: [{applyServerSideEncryptionByDefault: useKms? { sseAlgorithm: "aws:kms", kmsMasterKeyId: args.kmsKeyArn }: { sseAlgorithm: "AES256" },// S3 Bucket Keys only apply to KMS. Set it under AES256 and the API// hands back false, which reads as a permanent diff on every preview.bucketKeyEnabled: useKms,}],}, child);// The four public-access switches, written down so drift is visible and// gets corrected on the next update. No argument turns them off.new aws.s3.BucketPublicAccessBlock(`${name}-block`, {bucket: this.bucket.id,blockPublicAcls: true,blockPublicPolicy: true,ignorePublicAcls: true,restrictPublicBuckets: true,}, child);this.bucketName = this.bucket.id;this.arn = this.bucket.arn;// Publishes the outputs AND tells the engine construction is finishedthis.registerOutputs({ bucketName: this.bucketName, arn: this.arn });}}
Read the argument interface before you read anything else, because that is the security surface of the whole thing. A caller can set a KMS key (Key Management Service, the AWS service that stores encryption keys and logs every use of them) and some tags. A caller cannot set blockPublicAcls, cannot skip versioning, and cannot choose no encryption, because none of those are arguments. That is the fire door. The seal is in the box whether or not the person ordering remembers it exists.
Notice as well that every child name is built from the component's own name. That is not a style preference. Two sections down it becomes a hard error.
What The Parent Link Carries
The { parent: this } on each child looks like bookkeeping. It behaves more like plugging a lamp into a power strip. Whatever the strip is connected to, the lamp inherits: same circuit, same switch, same fuse.
Specific things travel down that link. A child inherits its parent's provider, the object that decides which cloud account, which region, and which credentials get used, so you can hand the component one provider pinned to a second AWS account in eu-west-1 and every child lands there without repeating the option nine times. It inherits protect, so protecting the component protects the whole subtree from deletion. It inherits aliases, which is what lets you rename a component later without Pulumi treating its children as brand new resources. Transforms (functions that rewrite resource properties as each one is registered) run on the parent and on every descendant. deletedWith is inherited too: set it on the component and every resource beneath it skips its own delete call when the named resource is going away anyway.
Other options stay where you put them. dependsOn is a fact about one resource's ordering. ignoreChanges is a fact about one property you have decided to stop fighting over. additionalSecretOutputs and import are typed onto custom resources and do not exist on a component. Set those on the individual child that needs them.
One naming detail is worth getting right, because half the internet has it stale. Pass provider, singular, to a component and current Pulumi honors it: children of that same package take it as their default. Pass providers, plural, and you hand the component a bag of providers keyed by package name, which is what you want when the subtree spans two packages, say AWS and Kubernetes under one component. Older Pulumi releases ignored the singular form on components and printed nothing, which is why plenty of blog posts still tell you it does nothing at all. Check the behavior of the SDK version you actually run, and reach for the plural form on components when you want the intent obvious to the next reader.
Put two instances of the class in one program and the plan explains itself. Eight real S3 resources, two components, one stack.
$ pulumi preview
Previewing update (dev):Type Name Plan+ pulumi:pulumi:Stack storage-dev create+ ├─ secopslog:storage:SecureBucket audit create+ │ ├─ aws:s3:BucketV2 audit-bucket create+ │ ├─ aws:s3:BucketVersioningV2 audit-versioning create+ │ ├─ aws:s3:BucketServerSideEncryptionConfigurationV2 audit-sse create+ │ └─ aws:s3:BucketPublicAccessBlock audit-block create+ └─ secopslog:storage:SecureBucket artifacts create+ ├─ aws:s3:BucketV2 artifacts-bucket create+ ├─ aws:s3:BucketVersioningV2 artifacts-versioning create+ ├─ aws:s3:BucketServerSideEncryptionConfigurationV2 artifacts-sse create+ └─ aws:s3:BucketPublicAccessBlock artifacts-block createResources:+ 11 to create
Audit The Tree, Not The Code
Confirming by eye that every child got a parent works for about one repository and does not survive the next pull request. Read the state instead. It is the difference between auditing a warehouse by reading purchase orders and walking the aisles counting boxes.
Every resource Pulumi manages carries a URN (uniform resource name), the address the engine assigns it. Like a postal address it points at exactly one thing, and it is assembled from parts: the stack, the project, the chain of parent types, the resource type, and the logical name you wrote in code. The parent chain is the interesting part, and a dollar sign separates it from the resource type. A bucket built inside SecureBucket carries secopslog:storage:SecureBucket$ in the middle of its URN. A bucket built at the top of your program does not. One character is the entire audit. Pull the state out and slice it with jq, the command line tool for picking fields out of JSON.
$ pulumi stack export \| jq -r '.deployment.resources[]| select(.type == "aws:s3/bucketV2:BucketV2")| .urn'
urn:pulumi:dev::storage::secopslog:storage:SecureBucket$aws:s3/bucketV2:BucketV2::audit-bucketurn:pulumi:dev::storage::secopslog:storage:SecureBucket$aws:s3/bucketV2:BucketV2::artifacts-bucketurn:pulumi:dev::storage::aws:s3/bucketV2:BucketV2::scratch
Three buckets, and the third one is wearing no uniform. scratch was created directly in index.ts, so nothing in your code says anything about it beyond the fact that it exists.
Be precise about what that means, because the frightening version of this sentence is out of date. Since April 2023 AWS creates new buckets with all four Block Public Access settings on and ACLs disabled, and since January 2023 every new object is encrypted with an S3-managed key by default. scratch is not born open to the world. What it is, is unmanaged. Versioning is off, because versioning is off by default and nobody asked for it. Objects are encrypted with the account's default S3 key rather than the KMS key your compliance folder specifies. And the public access block is on only because AWS put it there. Your code never declares it, so if somebody flips it off in the console next quarter, no preview shows a diff, no update puts it back, and no reviewer sees a changed line. The bucket inside the component differs in exactly one way that counts: its settings are written down, so drift becomes a diff and the next pulumi up corrects it.
That is the shape of the finding you actually want. Rarely an attacker defeating your component. Almost always a resource that never went through one, created by a hurried engineer or by a pull request nobody read closely. Widen the query into a sweep by listing everything parented straight to the stack root.
$ pulumi stack export \| jq -r '.deployment.resources[]| select((.parent // "") | endswith("::pulumi:pulumi:Stack::storage-dev"))| "\(.type) \(.urn | split("::")[3])"'
pulumi:providers:aws default_6_54_0secopslog:storage:SecureBucket auditsecopslog:storage:SecureBucket artifactsaws:s3/bucketV2:BucketV2 scratch
Anything in that list other than default providers and your top-level components deserves a sentence of explanation in the pull request. Wire the query into CI (continuous integration, the automation that runs on every change) straight after a deploy and you have a standing check that new infrastructure came off the paved road. The interactive equivalent is pulumi stack --show-urns, which prints the same tree with each resource's URN underneath it.
One Class, Two Instances, One Ugly Error
Because a component is a class, reuse is ordinary code reuse. Export it from a shared module, or publish it to your internal npm registry (npm, the Node package manager, the tool that installs JavaScript dependencies), and every project imports the same reviewed bundle. Create as many instances as you want. There is one condition, and it is precisely what those templated child names were protecting you from. Hard-code a child name and the second instance dies.
// WRONG: the child name does not depend on the component's namethis.bucket = new aws.s3.BucketV2("bucket", {}, child);
$ pulumi up
Previewing update (dev):Type Name Plan Info+ pulumi:pulumi:Stack storage-dev create 1 error+ ├─ secopslog:storage:SecureBucket audit create+ └─ secopslog:storage:SecureBucket artifacts createDiagnostics:pulumi:pulumi:Stack (storage-dev):error: Duplicate resource URN 'urn:pulumi:dev::storage::secopslog:storage:SecureBucket$aws:s3/bucketV2:BucketV2::bucket'; try giving it a unique name
Look at what that URN does not contain. It has the parent's type, secopslog:storage:SecureBucket, and nowhere does it carry the parent's name. audit and artifacts are two components with different names and one identical type path, so both children compute the same address and the engine refuses to go on. Templating the name is what turns them into audit-bucket and artifacts-bucket.
There is a second payoff, and it is quiet. When you rename a component and give it an alias, Pulumi has to work out the old address of each child as well. It does that by assuming children are named after their parent, so it swaps the old parent name back into the child name whenever the child name starts with the parent name. Write ${name}-bucket and that assumption holds, so a rename carries the whole subtree with it. The convention in the SDK and the convention in your constructor have to be the same convention.
A Paved Road Is Not A Fence
Now the uncomfortable part. A week after you ship SecureBucket, somebody needs one that serves a static website, and the tempting fix is a boolean.
export interface SecureBucketArgs {kmsKeyArn?: pulumi.Input<string>;tags?: pulumi.Input<{ [k: string]: pulumi.Input<string> }>;publicWebsite?: boolean; // the escape hatchindexDocument?: string;}// public readonly websiteEndpoint?: pulumi.Output<string>; // declared on the class// ...inside the constructor, replacing the unconditional block:if (args.publicWebsite) {const web = new aws.s3.BucketWebsiteConfigurationV2(`${name}-web`, {bucket: this.bucket.id,indexDocument: { suffix: args.indexDocument ?? "index.html" },}, child);this.websiteEndpoint = web.websiteEndpoint;// and the four blockPublic* switches have to come off for anonymous reads} else {new aws.s3.BucketPublicAccessBlock(`${name}-block`, {bucket: this.bucket.id,blockPublicAcls: true, blockPublicPolicy: true,ignorePublicAcls: true, restrictPublicBuckets: true,}, child);}
One flag, and now there are two populations of SecureBucket carrying the same type token, the same shape in the tree, and no way to tell them apart by name. One population is readable by anyone on the internet. A component makes the safe thing the cheap thing, which is worth a great deal, and it still cannot make the unsafe thing impossible, because any caller can ignore your class and write raw resources beside it.
Enforcement is a separate job, done by a policy pack: a set of rules the engine checks the plan against, resource by resource, no matter who created them. Run pulumi preview --policy-pack ./policy and pulumi up --policy-pack ./policy and the run fails when a rule is broken, whether the resource came out of your component or out of somebody's afternoon. A stack-level rule saying every bucket in this stack must sit under secopslog:storage:SecureBucket is exactly the sentence a policy pack can say and a component cannot.
One more thing to stay awake to. A Pulumi program is a real program, and a deploy runs it with whatever cloud credentials the shell or the CI runner is holding. Importing a component package means executing that maintainer's code with those credentials. A compromised version can register an extra IAM role (Identity and Access Management, the AWS service that decides who is allowed to do what) inside its own subtree, and it will look like a normal part of the assembly, because registering resources is what a component is supposed to do. Pin versions. Install from a lockfile with npm ci rather than npm install. Mirror the components you depend on into a registry you control. Read the preview for resources nobody asked for.
Renaming The Type Token Is A Replace
Rename the type token after deployment, say from secopslog:storage:SecureBucket to acme:storage:SecureBucket, and you rewrite the address of the component and of every child under it, because the parent type chain is baked into each child URN. Pulumi does not see a rename. It sees one set of resources deleted and a different set created. A change you expected to be cosmetic becomes a destroy and recreate of live buckets, databases, or volumes.
If you have to change it, give the component an alias so the engine knows the old identity maps to the new one: aliases: [{ type: "secopslog:storage:SecureBucket" }]. Children inherit the parent's aliases, so the whole subtree comes along. Then preview it and count the replaces before you confirm.
Make the check routine rather than heroic. After any change that touches a component, a new child, a moved parent, a renamed token, run pulumi preview and read the Plan column for the word replace before you answer yes. Once the update lands, run the URN sweep against the deployed stack and have the CI job fail if the list holds anything but default providers and your top-level components. That is a five-line jq filter, and it is the only check that notices the bucket somebody created beside your paved road at six on a Friday.
Try this
Run pulumi preview 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 missing parent fails silently. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.