DevOps & SRE No. 167

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.

  • namepulumi-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.

~/.claude/skills/pulumi-iac/SKILL.md

--- name: pulumi-iac description: Writes and reviews Pulumi infrastructure code in TypeScript and Python - project and stack layout, stack config and encrypted secrets,… license: MIT ---

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. Any replace or delete on 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, or pulumi stack rm against 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 aliases first, preview, and expect zero replacements.
  • Secrets go through pulumi config set --secret or ESC; never plain config, never hardcoded, never console.log of 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
DecisionDefault
One project vs severalSplit by lifecycle and blast radius: network, data, apps; link with stack references
StacksOne per environment (dev, staging, prod), optionally per region
BackendPulumi Cloud for teams (locking, history, RBAC); S3/GCS/Azure Blob self-managed with a KMS secrets provider
LanguageMatch the team’s app language; TypeScript has the widest examples
Shared config across stacksPulumi 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 with pulumi preview --policy-pack ./policy and 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 destroy that 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 require deleteBeforeReplace.
  • pulumi refresh accepts real-world state into the stack; a resource someone deleted manually disappears from state and gets recreated on the next up. Review its diff.
  • Secrets passed through apply stay secret, but JSON.stringify of a plain value read from a secret inside apply and exported via a non-secret path leaks it; wrap with pulumi.secret().
  • pulumi cancel releases a stuck lock but can leave pending operations; run pulumi refresh afterwards 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.

esc