Pulumi IaC skill for Claude Code
Real code for infra, with previews you actually read. Claude structures Pulumi projects by lifecycle, keeps secrets in encrypted config or ESC, and wraps reusable infrastructure in ComponentResources with stable type tokens. It handles Outputs without string bugs, adds aliases before renaming anything, protects stateful resources, and reads every preview for replaces and deletes before asking you to approve an update.
- name
pulumi-iac - size230 lines · ≈2,300 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes and reviews Pulumi infrastructure code in TypeScript and Python - project and stack layout, stack config and encrypted secrets, Pulumi ESC environments, ComponentResource abstractions, Output and apply handling, stack references, previews with diff review, refresh and drift, import, aliases for safe refactors, protect and retainOnDelete, CrossGuard policy packs, and CI with OIDC. Use when editing Pulumi.yaml or per-stack Pulumi.dev.yaml files, writing a Pulumi program, reviewing a pulumi preview, or migrating from Terraform or CDK. Try asking
- Refactor these Pulumi resources into a reusable component without replacing anything
- Our pulumi preview wants to replace the production database. Explain why and stop it
- Set up Pulumi stacks for dev and prod with secrets and a GitHub Actions preview on PRs
Use it when
- Writing or reviewing a Pulumi program in TypeScript or Python
- A preview shows unexpected replacements or drift
- Organizing stacks, config, secrets or ESC environments
- Importing existing resources or migrating from Terraform or CDK
Skip it when
- The codebase uses HCL — use terraform-iac
- The infrastructure is defined with AWS CDK — use aws-cdk
How to check the result
pulumi preview --diff shows only intended changes with zero unexplained replaces or deletes, policy packs pass, and stack outputs match expected values after the update.
The common mistake it prevents
Renaming a resource or moving it under a component without adding aliases, which changes its URN and makes Pulumi delete and recreate it.
Pulumi work is done when pulumi preview --diff shows only the intended changes with
no unexplained replaces or deletes, secrets are encrypted in config or pulled from ESC,
reusable pieces are ComponentResources with stable names, and stateful resources are
protected.
Ground rules
- Read the full preview before every
pulumi up. Anyreplaceordeleteon a stateful resource (database, bucket, volume, DNS zone, KMS key) requires explicit user confirmation and a reason. - Never run
pulumi up --yes,pulumi destroy,pulumi state delete, orpulumi stack rmagainst a shared or production stack without the user confirming that exact command. - Renaming a resource or moving it into a component changes its URN. Add
aliasesfirst, preview, and expect zero replacements. - Secrets go through
pulumi config set --secretor ESC; never plain config, never hardcoded, neverconsole.logof an Output. - Do not create cloud resources inside
apply()callbacks; they will not show in previews reliably. Build resources at top level and pass Outputs as inputs.
Workflow
- [ ] 1. Inspect project, stacks, backend, and current drift
- [ ] 2. Model the change (resources, components, config)
- [ ] 3. Handle Outputs correctly; wire stack references
- [ ] 4. Preview with --diff; explain every replace/delete
- [ ] 5. Apply (with confirmation for shared stacks)
- [ ] 6. Verify outputs and resource state; add policy/tests
1. Inspect
pulumi whoami -v # backend (Pulumi Cloud, s3://, file://) and org
pulumi stack ls
pulumi stack select acme/infra/staging
pulumi config # values (secrets show as [secret])
pulumi preview --diff --refresh # current drift plus pending change
pulumi stack export > /tmp/state.json # read-only snapshot before risky refactors
2. Layout
infra/
├── Pulumi.yaml # name, runtime, description
├── Pulumi.dev.yaml # per-stack config (encrypted secrets inline)
├── Pulumi.prod.yaml
├── index.ts # composition only: config -> components -> exports
└── components/
├── network.ts # class Network extends pulumi.ComponentResource
└── service.ts
| Decision | Default |
|---|---|
| One project vs several | Split by lifecycle and blast radius: network, data, apps; link with stack references |
| Stacks | One per environment (dev, staging, prod), optionally per region |
| Backend | Pulumi Cloud for teams (locking, history, RBAC); S3/GCS/Azure Blob self-managed with a KMS secrets provider |
| Language | Match the team’s app language; TypeScript has the widest examples |
| Shared config across stacks | Pulumi ESC environment imported in Pulumi.<stack>.yaml |
3. Config and secrets
pulumi config set aws:region eu-west-1
pulumi config set dbInstanceClass db.t4g.medium
pulumi config set --secret dbPassword # prompts; stored encrypted
pulumi config set --path 'tags.team' checkout # structured config
import * as pulumi from "@pulumi/pulumi";
const cfg = new pulumi.Config();
const instanceClass = cfg.get("dbInstanceClass") ?? "db.t4g.small";
const dbPassword = cfg.requireSecret("dbPassword"); // Output<string>, stays secret
ESC: create an environment (for example acme/prod) holding dynamic cloud credentials
via OIDC and shared values, then reference it from the stack file:
# Pulumi.prod.yaml
environment:
- acme/prod
config:
aws:region: eu-west-1
Check ESC syntax with esc env get acme/prod (or pulumi env get) before wiring it in.
4. Components
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
export interface BucketSiteArgs { domain: string; tags?: Record<string, string>; }
export class BucketSite extends pulumi.ComponentResource {
public readonly bucketName: pulumi.Output<string>;
constructor(name: string, args: BucketSiteArgs, opts?: pulumi.ComponentResourceOptions) {
super("acme:web:BucketSite", name, {}, opts);
const bucket = new aws.s3.Bucket(`${name}-bucket`, { tags: args.tags }, { parent: this });
new aws.s3.BucketPublicAccessBlock(`${name}-pab`, {
bucket: bucket.id,
blockPublicAcls: true, blockPublicPolicy: true,
ignorePublicAcls: true, restrictPublicBuckets: true,
}, { parent: this });
this.bucketName = bucket.bucket;
this.registerOutputs({ bucketName: this.bucketName });
}
}
Rules: type token org:module:Name never changes after first deploy; children use
{ parent: this } and names derived from name; call registerOutputs at the end.
Python equivalent: subclass pulumi.ComponentResource, call
super().__init__("acme:web:BucketSite", name, None, opts), pass
opts=pulumi.ResourceOptions(parent=self) to children. Check the provider’s current
resource names in the registry docs for the installed major version (AWS provider v7
uses aws.s3.Bucket; on v6 the equivalent is aws.s3.BucketV2).
5. Outputs
// good: interpolate and transform with Outputs
const url = pulumi.interpolate`https://${lb.dnsName}/health`;
const conn = pulumi.all([db.address, db.port]).apply(([h, p]) => `${h}:${p}`);
// bad: Outputs are not strings
const broken = `https://${lb.dnsName}`; // yields "Calling [toString] on an Output..."
Stack references for cross-project values:
const net = new pulumi.StackReference(`acme/network/${pulumi.getStack()}`);
const vpcId = net.getOutput("vpcId");
const privateSubnets = net.requireOutput("privateSubnetIds");
6. Preview, apply, refactor safely
pulumi preview --diff # read every ~ (update), +- (replace), - (delete)
pulumi preview --diff --target 'urn:pulumi:prod::infra::aws:rds/instance:Instance::main'
pulumi up # interactive confirm; show plan
pulumi refresh --diff # pull real state; review before accepting
pulumi import aws:s3/bucket:Bucket logs my-existing-logs-bucket # generates code
Replace triggers to recognize: changing immutable properties (DB engine, subnet group,
bucket name, AZ), changing the resource name, changing the parent, provider swap. Fix
with aliases, explicit physical names kept stable, or ignoreChanges for fields
another system owns.
new aws.rds.Instance("main", { /* ... */ }, {
protect: true, // blocks delete until unprotected in code
retainOnDelete: true, // leave cloud resource if removed from program
aliases: [{ name: "primary-db" }], // old logical name, avoids replace on rename
ignoreChanges: ["password"], // rotated outside Pulumi
});
deleteBeforeReplace: true is required when the replacement needs the same physical
name; it causes downtime, so call it out.
7. Policy and tests
- CrossGuard policy pack (
pulumi policy new aws-typescript) to enforce tags, encryption, no public buckets; run locally withpulumi preview --policy-pack ./policyand enforce centrally in Pulumi Cloud. - Unit tests with mocks (
pulumi.runtime.setMocks) for component logic: tags applied, public access blocked, naming correct. - Integration: deploy an ephemeral stack in CI for components, then
pulumi destroythat ephemeral stack only.
CI
# GitHub Actions: preview on PR, up on main (environment-gated)
- uses: pulumi/actions@v6
with:
command: preview # 'up' in the deploy job
stack-name: acme/infra/prod
comment-on-pr: true
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
Cloud credentials via OIDC (see github-actions) or ESC dynamic credentials, not static keys. Pulumi Cloud also supports OIDC for its own access token; prefer it where enabled.
Gotchas
- Auto-naming appends a random suffix (
bucket-1a2b3c). Setting an explicit physical name makes replacements fail or requiredeleteBeforeReplace. pulumi refreshaccepts real-world state into the stack; a resource someone deleted manually disappears from state and gets recreated on the nextup. Review its diff.- Secrets passed through
applystay secret, butJSON.stringifyof a plain value read from a secret inside apply and exported via a non-secret path leaks it; wrap withpulumi.secret(). pulumi cancelreleases a stuck lock but can leave pending operations; runpulumi refreshafterwards and resolve pending creates.- Default providers read ambient credentials and region; in multi-account code create
explicit
new aws.Provider(...)instances and pass{ provider }. - Changing component type tokens or parents without aliases replaces every child.
Output
**Stack:** acme/infra/prod **Backend:** Pulumi Cloud
**Change:** add BucketSite component for docs site; move rds "primary-db" -> "main" (aliased)
**Preview:** +4 create, ~1 update (tags), 0 replace, 0 delete
**Secrets:** dbPassword via config --secret; AWS creds via ESC OIDC
**Safety:** protect on rds main; policy pack aws-baseline passed
**Applied:** pulumi up by <user> after confirmation; outputs: siteBucket=docs-site-8f2a About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.