Pulumi IaC skill for Codex

Typed infrastructure code with no surprise replaces. Codex writes Pulumi programs that treat Outputs correctly with interpolate and apply, keep secrets encrypted in stack config or ESC, and group resources into ComponentResources with proper parents. Before anything deploys it reads pulumi preview --diff, adds aliases or protect where a rename would replace live resources, runs mocked unit tests, and waits for your go-ahead on pulumi up.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- pulumi-iac

Installs to ~/.agents/skills/pulumi-iac/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $pulumi-iac, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes and maintains Pulumi infrastructure programs in TypeScript and Python - project and stack layout, stack config and encrypted secrets, Outputs and apply without leaking unknowns, ComponentResources, StackReferences, aliases for refactors, pulumi import, Pulumi ESC environments, unit tests with mocks and preview-driven CI. Use when creating a Pulumi project, adding resources or stacks, refactoring without replacements, managing secrets in Pulumi config, or reviewing a pulumi preview diff.

Use it when

  • Infrastructure is defined, or will be, in Pulumi TypeScript or Python
  • A preview shows replacements you did not expect
  • Secrets or per-environment values need to live in Pulumi config
  • Existing cloud resources should be imported into a Pulumi stack

Use something else when

  • The team uses HCL with Terraform or OpenTofu: use terraform-modules
  • You are building AWS infrastructure with CDK constructs and CloudFormation: use aws-cdk

How to check the result

pulumi preview --diff shows only intended operations before the update, and pulumi preview --expect-no-changes passes afterwards.

The mistake it prevents

Using a resource Output inside a plain template string, which bakes a placeholder into policies or URLs instead of the real value.

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

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

A Pulumi change is ready when pulumi preview --diff shows only the intended creates, updates and replacements, secrets never appear in plaintext in code, config or logs, and the program is ordinary well-typed code with tests. Treat every replacement in a preview as a potential outage until proven otherwise.

Inspect first

  1. Read AGENTS.md, Pulumi.yaml (runtime, main entry), and every Pulumi.<stack>.yaml.
  2. Identify the backend: pulumi whoami -v (Pulumi Cloud, or self-managed s3://, gs://, azblob://, file://). Self-managed backends use a passphrase or cloud KMS secrets provider; know which before touching secrets.
  3. Check the language toolchain and package manager (npm/pnpm/yarn, pip/uv/poetry) and pinned @pulumi/* or pulumi-* package versions.
  4. pulumi stack ls and pulumi stack select <stack>; never assume the selected stack is the one the user means.
  5. Run pulumi preview before editing to capture the baseline. Pending changes before you start must be understood, not mixed into your change.

Previews and updates need backend access, cloud credentials and network. Without them, write and type-check the code and leave preview and update to the user.

Layout

infra/
├── Pulumi.yaml
├── Pulumi.dev.yaml           # per-stack config, secrets encrypted
├── Pulumi.prod.yaml
├── index.ts                  # composes components; little logic here
├── components/
│   └── webService.ts         # ComponentResource
└── tests/
    └── webService.test.ts
Split byWhen
Stacks of one projectSame infrastructure, different environments (dev, staging, prod)
Separate projects plus StackReferencesDifferent lifecycles or owners (network vs apps), or blast-radius limits
ComponentResourceA reusable group of resources inside or across projects

Config and secrets

pulumi config set aws:region eu-west-1
pulumi config set app:instanceCount 3
pulumi config set --secret app:dbPassword          # prompts; value stored encrypted
pulumi config get app:dbPassword                   # decrypts; do not echo into chat or logs

TypeScript:

import * as pulumi from "@pulumi/pulumi";

const cfg = new pulumi.Config("app");
const instanceCount = cfg.getNumber("instanceCount") ?? 2;
const dbPassword = cfg.requireSecret("dbPassword");      // Output<string>, marked secret

Python:

import pulumi

cfg = pulumi.Config("app")
instance_count = cfg.get_int("instanceCount") or 2
db_password = cfg.require_secret("dbPassword")
  • Never pulumi config set a secret without --secret; it lands in plaintext in Git.
  • Values derived from secrets stay secret; values you build from plain strings do not. Wrap with pulumi.secret(value) when a non-config value is sensitive.
  • Shared secrets and cloud credentials across stacks belong in Pulumi ESC environments, imported in stack config with environment: [org/project/env]; reading them needs Pulumi Cloud.

Outputs, the main trap

Resource properties are Output<T>: values known only after deployment. Do not read them as plain values.

import * as aws from "@pulumi/aws";

const bucket = new aws.s3.Bucket("assets");   // pulumi-aws v7+; v6 programs use BucketV2

// Wrong: produces "[object Object]" or a warning string
const url = `https://${bucket.bucketRegionalDomainName}/index.html`;

// Right
const url2 = pulumi.interpolate`https://${bucket.bucketRegionalDomainName}/index.html`;
const policyJson = pulumi.all([bucket.arn, role.arn]).apply(([b, r]) =>
  JSON.stringify({ Version: "2012-10-17", Statement: [{ Effect: "Allow", Principal: { AWS: r }, Action: "s3:GetObject", Resource: `${b}/*` }] }),
);

Python equivalents: pulumi.Output.concat("https://", bucket.bucket_regional_domain_name), pulumi.Output.all(bucket.arn, role.arn).apply(lambda args: ...).

  • Never create resources inside apply; they will not show in preview and break diffing.
  • apply callbacks do not run during preview when inputs are unknown; logic there must tolerate that.

Components

export interface WebServiceArgs {
  image: pulumi.Input<string>;
  replicas?: number;
}

export class WebService extends pulumi.ComponentResource {
  public readonly url: pulumi.Output<string>;

  constructor(name: string, args: WebServiceArgs, opts?: pulumi.ComponentResourceOptions) {
    super("acme:app:WebService", name, {}, opts);
    // child resources take { parent: this }
    // ...
    this.url = pulumi.output("https://example.invalid");
    this.registerOutputs({ url: this.url });
  }
}

Pass { parent: this } to every child so names, aliases and deletes follow the component. The type token (acme:app:WebService) is part of each child’s URN: changing it later requires aliases.

Refactoring without replacement

Renaming a resource, changing its parent, or moving it into a component changes its URN, which Pulumi treats as delete plus create. Declare the old identity:

const logs = new aws.s3.Bucket("app-logs", {}, {
  aliases: [{ name: "logs" }],                  // old logical name
  // aliases: [{ parent: pulumi.rootStackResource }]  when moving into a component
});

Also guard critical resources: protect: true blocks deletion until removed; retainOnDelete: true leaves the cloud resource when Pulumi stops managing it. Properties that force replacement (names, AZs) can be softened with deleteBeforeReplace only when downtime is acceptable.

Importing existing resources

pulumi import aws:s3/bucket:Bucket legacy-assets acme-legacy-assets

The CLI prints generated code; paste it into the program, then pulumi preview must show no changes for that resource. In code, { import: "acme-legacy-assets" } as a resource option does the same through the normal preview and update flow.

Testing

Unit tests with mocks (no cloud calls) for component logic:

import * as pulumi from "@pulumi/pulumi";

pulumi.runtime.setMocks({
  newResource: (a) => ({ id: `${a.name}-id`, state: a.inputs }),
  call: (a) => a.inputs,
});

it("tags every bucket with the environment", async () => {
  const { bucket } = await import("../index");
  const tags = await new Promise((res) => bucket.tags.apply(res));
  expect(tags).toMatchObject({ env: "test" });
});

Set mocks before importing the program. For policy (no public buckets, required tags), use Pulumi policy packs: pulumi preview --policy-pack ./policies.

Workflow

- [ ] 1. Select the right stack; capture baseline pulumi preview
- [ ] 2. Write code; type-check (tsc --noEmit / pyright or mypy)
- [ ] 3. Unit tests with mocks
- [ ] 4. pulumi preview --diff: review every replace and delete
- [ ] 5. Add aliases / protect where a replace is not intended
- [ ] 6. Show the preview; with user confirmation, pulumi up on non-prod first
- [ ] 7. pulumi preview again: expect no changes

Hard rules

  • pulumi up --yes, pulumi destroy, pulumi stack rm, pulumi state delete and pulumi refresh --yes change real infrastructure or state: show what will happen and get explicit confirmation. Never run them against production on your own initiative.
  • Use pulumi up --target only as a confirmed emergency measure; it leaves the stack partially updated.
  • pulumi cancel and manual state edits (pulumi stack export/import) are last resorts: export a backup first.
  • Pin provider package versions; a minor provider bump can change defaults and trigger replaces.

Verification

npx tsc --noEmit                              # or: pyright / mypy
npm test                                      # mocked unit tests
pulumi preview --diff --stack dev
pulumi preview --expect-no-changes --stack dev   # after up: non-zero exit if anything changes
pulumi stack output --stack dev

Output

**Stack:** acme/infra/dev (Pulumi Cloud backend)
**Change:** WebService component; existing bucket moved under it with alias (no replacement)
**Preview:** +4 create, ~1 update, 0 replace, 0 delete
**Secrets:** dbPassword via config --secret; ESC env acme/shared-aws for credentials
**Checks:** tsc clean, 5 mocked tests pass, post-update preview --expect-no-changes ok
**Not done:** prod stack untouched; run the same preview there and confirm before pulumi up

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Pulumi IaC"
  short_description: "Typed infrastructure code with no surprise replaces"
  default_prompt: "$pulumi-iac Set up a Pulumi TypeScript project with dev and prod stacks for this service"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem