Claude Code with Pulumi: Infrastructure as Real Code
Why Pulumi without CLAUDE.md generates infrastructure that fails on second deploy
Pulumi is the closest thing to writing infrastructure in your application language. You import a TypeScript or Python provider, instantiate resources as objects, and pulumi up reconciles the desired state with the cloud. It is the most ergonomic IaC tool available in 2026, and it is also one of the easiest to get wrong because the abstractions look like regular code but behave like a state machine.
Claude Code without explicit constraints generates Pulumi programs that work on the first deploy and break on the second. The most common patterns Claude produces: resources with auto-generated names that change on every refactor, secrets read directly from process.env instead of pulumi.Config().requireSecret(), hardcoded stack names, missing pulumi.export for downstream stacks, and resource references that use .name instead of .id when the resource has not yet been created. Each of these compiles cleanly and may even deploy successfully once. They fail the moment you rename a resource, switch stacks, or run inside CI where the local state is not present.
This guide covers the CLAUDE.md configuration that locks Claude Code into Pulumi's actual operating model: stacks as first-class environments, secrets handled through the Pulumi config, resource names that survive rename refactors, and state backends that match your team workflow. For broader cloud context, Claude Code with AWS covers the AWS-specific resource patterns your Pulumi program will create, and Claude Code with Terraform shows the alternative IaC approach if you want to compare both before committing.
The Pulumi CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Pulumi project it needs to declare: the language SDK version, the cloud provider, the state backend, the stack naming convention, the secret handling rules, the resource naming policy, and the hard rules that block the mistakes Claude makes most often.
# Pulumi infrastructure rules
## Stack
- Pulumi ^3.x, TypeScript 5.x strict (or Python 3.11+)
- Provider: @pulumi/aws ^6.x (or @pulumi/gcp, @pulumi/azure-native)
- State backend: Pulumi Cloud (managed) OR s3://bucket/state for self-managed
- Stack names: dev, staging, prod (lowercase, no underscores)
## Project structure
- index.ts , entrypoint, top-level resource composition
- src/network/ , VPC, subnets, security groups
- src/compute/ , EC2, ECS, Lambda
- src/data/ , RDS, DynamoDB, S3 buckets
- src/iam/ , roles, policies
- Pulumi.yaml , project metadata
- Pulumi.<stack>.yaml , per-stack config and encrypted secrets
## Secret handling (MANDATORY)
- ALWAYS use pulumi.Config() for stack-scoped values:
const config = new pulumi.Config();
const dbPassword = config.requireSecret('dbPassword');
- NEVER read secrets from process.env in Pulumi code
- NEVER commit Pulumi.<stack>.yaml without encryption enabled
- Set secrets with: pulumi config set --secret dbPassword <value>
- Reference secrets with config.requireSecret(), NOT config.require()
## Resource naming (PREVENTS REPLACEMENT ON RENAME)
- The first argument to every resource constructor is the LOGICAL name
- The logical name MUST be stable across refactors
- Use explicit name: in resource args for the physical name shown in the cloud
- Example:
const bucket = new aws.s3.Bucket('app-data', {
bucket: `claudify-app-data-${pulumi.getStack()}`,
});
- The first arg 'app-data' is the LOGICAL name (stable)
- The bucket: property is the PHYSICAL name (visible in AWS)
- NEVER change the first argument unless you intend to replace the resource
## Output handling
- pulumi.Output<T> values are NOT plain values, they are promises
- Use .apply() to transform: bucket.arn.apply(arn => `arn:${arn}`)
- Use pulumi.interpolate for string templates with outputs:
const url = pulumi.interpolate`https://${bucket.websiteEndpoint}`;
- NEVER use template literals directly on Output values
## Hard rules
- NEVER hardcode AWS account IDs, use aws.getCallerIdentity() instead
- NEVER hardcode regions, use aws.getRegion() or the provider config
- NEVER use config.require() for secret values, use config.requireSecret()
- NEVER mutate resource args after instantiation
- ALWAYS pulumi.export the values downstream stacks or CI workflows need
- ALWAYS pin the Pulumi SDK version in package.json (no ^ wildcards on majors)
Three rules here prevent the majority of production incidents Claude generates without them.
The logical name rule is the most important Pulumi-specific constraint. Every resource constructor takes a string as its first argument. That string is the logical name Pulumi uses to track the resource in state. If Claude rewrites new aws.s3.Bucket('app-data', ...) to new aws.s3.Bucket('appData', ...) during a refactor, Pulumi sees a brand new resource and deletes the old one. For an S3 bucket holding production data, this is unrecoverable. The CLAUDE.md rule makes Claude treat logical names as constants.
The secret rule prevents the most common credential leak pattern. Claude defaults to process.env.DB_PASSWORD because that is how application code reads environment variables. Pulumi has its own config system with encryption built in. config.requireSecret('dbPassword') reads from the encrypted Pulumi.<stack>.yaml, never appears in plaintext on disk, and is decrypted only at deploy time. Without this rule Claude generates programs that leak secrets to git the moment a developer runs pulumi up with a .env file present.
The Output handling rule prevents the most common runtime bug. Pulumi's Output<T> type is a promise-like wrapper around values that do not exist until after the resource is created. Claude writes const url = \https://${bucket.websiteEndpoint}`and produces a string likehttps://[object Object]. The correct pattern uses pulumi.interpolateor.apply()` to defer the string concatenation until the value is resolved.
Install and project setup
Install the Pulumi CLI (one-time, machine-wide):
brew install pulumi/tap/pulumi # macOS
curl -fsSL https://get.pulumi.com | sh # Linux
Initialise a new Pulumi project:
mkdir claudify-infra && cd claudify-infra
pulumi new aws-typescript # interactive: name, description, stack, region
The pulumi new template creates index.ts, Pulumi.yaml, Pulumi.<stack>.yaml, and installs @pulumi/pulumi and @pulumi/aws. The default state backend is Pulumi Cloud. To switch to S3 self-managed:
pulumi login s3://claudify-pulumi-state
Add the state backend choice to CLAUDE.md so Claude generates references that match. For example, with S3-backed state Claude should know the bucket exists and is versioned, and should suggest enabling object lock for compliance scenarios.
Stack and config patterns
Stacks are how Pulumi separates environments. Each stack has its own config file, its own secrets, and its own state. A typical layout:
pulumi stack init dev
pulumi stack init staging
pulumi stack init prod
Each command creates a Pulumi.<stack>.yaml file. Setting config values:
pulumi config set aws:region us-east-1 --stack dev
pulumi config set aws:region us-west-2 --stack prod
pulumi config set --secret dbPassword 'real-password-here' --stack prod
Reading them in index.ts:
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';
const config = new pulumi.Config();
const stack = pulumi.getStack();
const region = aws.config.requireRegion();
// Plain config values
const instanceType = config.get('instanceType') ?? 't3.micro';
const minSize = config.getNumber('minSize') ?? 1;
// Secret values
const dbPassword = config.requireSecret('dbPassword');
// Stack-specific resource sizing
const dbInstanceClass = stack === 'prod' ? 'db.t3.medium' : 'db.t3.micro';
export const stackName = stack;
export const dbInstanceClassExport = dbInstanceClass;
Add a config section to CLAUDE.md:
## Config access pattern
- Every program starts with: const config = new pulumi.Config();
- Plain values: config.get('key') OR config.require('key') for mandatory
- Numbers: config.getNumber('key'), config.requireNumber('key')
- Booleans: config.getBoolean('key'), config.requireBoolean('key')
- Secrets: config.requireSecret('key'), NEVER config.require for secrets
- Provider config: aws.config.region, aws.config.requireRegion()
- Stack name: pulumi.getStack(), use for environment-aware sizing
- NEVER hardcode environment-specific values, use config or stack checks
Without this section Claude generates a constant const dbHost = 'prod-db.example.com' directly in the program. With it, Claude generates const dbHost = config.require('dbHost') and adds the value via the CLI per stack.
Resource naming that survives refactors
The most expensive Pulumi mistake is renaming the first argument of a resource constructor. Pulumi uses that string as the URN segment for the resource. Changing it tells Pulumi the old resource is gone and a new one should be created. For stateful resources (databases, buckets, queues with messages) this is destructive.
The two-name pattern fixes this. The logical name (first argument) stays stable. The physical name (the name: property in the args) carries the stack and any other variability:
import * as aws from '@pulumi/aws';
import * as pulumi from '@pulumi/pulumi';
const stack = pulumi.getStack();
// Logical name 'app-data' is stable. Physical name varies by stack.
const dataBucket = new aws.s3.Bucket('app-data', {
bucket: `claudify-app-data-${stack}`,
versioning: { enabled: true },
tags: {
Stack: stack,
Project: 'claudify-infra',
},
});
// Logical name 'app-uploads' is stable. Same pattern.
const uploadsBucket = new aws.s3.Bucket('app-uploads', {
bucket: `claudify-uploads-${stack}`,
versioning: { enabled: true },
corsRules: [{
allowedHeaders: ['*'],
allowedMethods: ['GET', 'PUT'],
allowedOrigins: ['https://claudify.tech'],
maxAgeSeconds: 3000,
}],
});
export const dataBucketArn = dataBucket.arn;
export const uploadsBucketArn = uploadsBucket.arn;
Add a resource naming section to CLAUDE.md:
## Resource naming (CRITICAL)
Every resource has TWO names:
1. Logical name (first constructor arg) , Pulumi state identifier
2. Physical name (in args, e.g. bucket:) , what the cloud provider sees
Rules:
- Logical name MUST be stable. Renaming it REPLACES the resource.
- Logical names are lowercase, hyphens, semantic: 'app-data', 'web-server'
- Physical names include the stack: `claudify-app-data-${stack}`
- For resources with provider-side immutable names (S3 buckets, IAM roles):
- Set the physical name explicitly so it does NOT auto-generate
- Auto-generated names include a random suffix that changes on diff
- Tag every resource with Stack and Project at minimum
Claude reads this section and stops renaming logical names during refactors. It also starts setting explicit physical names for resources where the cloud-side identifier matters, which prevents the random suffix problem.
State backends and locking
Pulumi state is the source of truth for what resources exist and which logical names map to which physical resources. Three backend choices:
| Backend | Best for | Locking | Cost |
|---|---|---|---|
| Pulumi Cloud | Teams, default choice | Built-in | Free for small teams, paid above |
| S3 with DynamoDB | Self-managed AWS shops | DynamoDB table required | Storage + DDB capacity |
| Local file | Solo experiments | None | Free |
Pulumi Cloud is the default and the only backend that handles locking out of the box. If two engineers run pulumi up simultaneously, Pulumi Cloud rejects the second with a clear error. With S3 self-managed you must configure a DynamoDB table for locking:
pulumi login 's3://claudify-pulumi-state?awssdk=v2®ion=us-east-1&dynamodb_table=pulumi-lock'
The DynamoDB table needs a single partition key named LockID (string). Without locking, concurrent pulumi up runs corrupt state.
Add a state section to CLAUDE.md:
## State backend
- Backend: Pulumi Cloud (or s3://... with DynamoDB locking)
- NEVER use the local file backend for shared projects
- pulumi login is one-time per machine, do NOT include credentials in code
- Encrypt all secrets with the project default (Pulumi Cloud passphrase or KMS)
- For S3 backend, the DynamoDB lock table MUST exist before first pulumi up
For a deeper dive on cloud-side resource patterns, Claude Code with AWS covers the IAM and networking constructs your Pulumi program will reference.
Get Claudify. The Pulumi CLAUDE.md template plus 600 more developer rules, pre-configured.
Outputs and cross-stack references
Pulumi outputs are how one stack publishes values another stack consumes. Network stacks export VPC IDs, security group IDs, and subnet IDs. Application stacks read them to place compute resources inside the existing network.
Exporting from a network stack:
// network/index.ts
import * as aws from '@pulumi/aws';
import * as pulumi from '@pulumi/pulumi';
const vpc = new aws.ec2.Vpc('main', {
cidrBlock: '10.0.0.0/16',
enableDnsHostnames: true,
});
const publicSubnet = new aws.ec2.Subnet('public', {
vpcId: vpc.id,
cidrBlock: '10.0.1.0/24',
availabilityZone: 'us-east-1a',
});
export const vpcId = vpc.id;
export const publicSubnetId = publicSubnet.id;
Reading them in a consumer stack:
// app/index.ts
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';
const networkStack = new pulumi.StackReference('claudify/network/prod');
const vpcId = networkStack.requireOutput('vpcId');
const publicSubnetId = networkStack.requireOutput('publicSubnetId');
const securityGroup = new aws.ec2.SecurityGroup('app-sg', {
vpcId: vpcId as pulumi.Output<string>,
ingress: [{
protocol: 'tcp',
fromPort: 443,
toPort: 443,
cidrBlocks: ['0.0.0.0/0'],
}],
});
const instance = new aws.ec2.Instance('app-server', {
ami: 'ami-0c55b159cbfafe1f0',
instanceType: 't3.medium',
subnetId: publicSubnetId as pulumi.Output<string>,
vpcSecurityGroupIds: [securityGroup.id],
});
export const instancePublicIp = instance.publicIp;
The pulumi.StackReference('org/project/stack') triple identifies the source stack. requireOutput returns a pulumi.Output<unknown> that you cast to the correct type. The cast does not validate, so if the network stack changes its export shape, the consumer breaks at deploy time.
Add a stack reference section to CLAUDE.md:
## Stack references
- For shared infrastructure (network, IAM): export with pulumi.export
- Consume in other stacks with: const ref = new pulumi.StackReference('org/proj/stack');
- Use ref.requireOutput('key') not ref.getOutput, fail fast on missing
- Cast to the expected type: ref.requireOutput('vpcId') as pulumi.Output<string>
- NEVER inline the StackReference identifier, declare it once at the top
- Stack references add a dependency, the upstream stack must deploy first
Provider configuration and credentials
Pulumi providers read credentials from the same environment variables their cloud CLIs use. For AWS that means AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and optionally AWS_SESSION_TOKEN. The CLI also reads ~/.aws/credentials profiles.
The provider config in Pulumi.<stack>.yaml lets you set per-stack provider options:
config:
aws:region: us-east-1
aws:profile: claudify-prod
app:dbHost: prod-db.example.com
app:dbPassword:
secure: AAABALWGmDkjV2v... # encrypted by Pulumi
The aws:profile config value selects an AWS named profile. For CI environments without local credentials files, the standard AWS_ACCESS_KEY_ID env vars work directly. Never check credentials into git, even in Pulumi.<stack>.yaml. The Pulumi config encryption protects values marked secure: but not the file structure or non-secret values.
Add a provider section to CLAUDE.md:
## Provider configuration
- aws:region in Pulumi.<stack>.yaml, per stack
- aws:profile for local development, omit in CI (use env vars)
- For multi-region resources: use new aws.Provider('us-west', { region: 'us-west-2' })
- Pass the explicit provider to resources: { provider: westProvider }
- NEVER hardcode credentials, NEVER commit AWS_SECRET_ACCESS_KEY
- For assume role: use the assumeRole option on the provider, not in code
Drift detection and CI
Pulumi's preview command shows what up would change. In CI it doubles as drift detection: if preview shows changes on a stack that should be steady-state, someone has modified the cloud out of band.
A minimal GitHub Actions workflow:
name: Pulumi Preview
on:
pull_request:
branches: [main]
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- uses: pulumi/actions@v5
with:
command: preview
stack-name: claudify/staging
comment-on-pr: true
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
pulumi/actions@v5 posts the preview output as a PR comment. For drift detection on a schedule, run the same preview against the prod stack on a cron and alert if it shows any changes. Claude Code with GitHub Actions covers the broader CI pattern your Pulumi workflow will sit inside.
Add a CI section to CLAUDE.md:
## CI / CD
- PR check: pulumi preview against the matching stack
- Main merge: pulumi up to staging, manual approval to prod
- PULUMI_ACCESS_TOKEN for Pulumi Cloud auth, scoped to the project
- AWS credentials via env vars (NOT files in CI)
- Drift detection: nightly pulumi preview on prod, fail the job if changes detected
- NEVER auto-apply to prod without manual approval gate
Common Claude Code mistakes with Pulumi
Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.
1. Renaming the logical name
Claude generates: refactoring a variable from dataBucket to applicationDataBucket and updating the first constructor arg to match.
Correct pattern: leave the first constructor arg unchanged. Rename only the JavaScript variable name and physical resource properties.
2. Reading secrets from process.env
Claude generates: const dbPassword = process.env.DB_PASSWORD;
Correct pattern: const dbPassword = config.requireSecret('dbPassword'); with the value set via pulumi config set --secret.
3. Template literals on Output values
Claude generates: const url = `https://${bucket.websiteEndpoint}`;
Correct pattern: const url = pulumi.interpolate\https://${bucket.websiteEndpoint}`;`
4. Auto-generated physical names
Claude generates: new aws.s3.Bucket('app-data', { versioning: { enabled: true } });
The bucket gets a random suffix like app-data-7f3e21a. Correct pattern: explicit bucket: \claudify-app-data-${stack}``.
5. Hardcoded regions and account IDs
Claude generates: arn: 'arn:aws:iam::123456789012:role/MyRole'.
Correct pattern: arn: pulumi.interpolate\arn:aws:iam::${currentAccountId}:role/MyRole`wherecurrentAccountIdcomes fromaws.getCallerIdentity()`.
6. Missing exports for downstream consumers
Claude generates: a network stack with no export const vpcId = vpc.id.
Correct pattern: every resource a downstream stack may need is exported, with a stable name.
Add a common mistakes section to CLAUDE.md with these six pairs. The before/after format helps Claude match the pattern it would otherwise generate to the corrected form.
Permission hooks for Pulumi operations
Pulumi commands range from read-only to genuinely destructive. pulumi preview is safe. pulumi up deploys real changes. pulumi destroy deletes everything in the stack. Permission hooks gate the destructive operations.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(pulumi preview*)",
"Bash(pulumi stack ls*)",
"Bash(pulumi config get*)",
"Bash(pulumi stack output*)",
"Bash(pulumi about*)"
],
"deny": [
"Bash(pulumi up*)",
"Bash(pulumi destroy*)",
"Bash(pulumi stack rm*)",
"Bash(pulumi config set --secret*)",
"Bash(pulumi import*)"
]
}
}
The deny list surfaces destructive operations as prompts. Setting secrets is on the deny list because the value appears in the command history if Claude generates it inline. The correct flow is for Claude to suggest the command and for the developer to type the secret value into the prompt directly.
Building Pulumi programs that survive production
The Pulumi CLAUDE.md in this guide produces infrastructure where logical names are stable across refactors, secrets are stack-scoped and encrypted, outputs use pulumi.interpolate instead of template literals, every resource is tagged with stack and project, state lives in a backend that supports locking, drift is detected in CI, and dangerous CLI commands require explicit confirmation.
The underlying principle is the same as any code Claude writes. Pulumi without a CLAUDE.md produces programs that look like idiomatic TypeScript but violate the Pulumi state model in ways that surface only on the second or third deploy. The CLAUDE.md template removes each failure mode by making the correct pattern the only pattern Claude can generate.
For the next layer up from infrastructure, Pulumi works alongside Claude Code with Vercel for the application deployment path, and Claudify includes a Pulumi-specific CLAUDE.md template with the logical naming rules, secret handling patterns, stack reference examples, and all six common-mistake rules pre-configured.
Get Claudify. The CLAUDE.md template Pulumi developers use to ship infrastructure that survives the second deploy.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify