Claude Code with AWS: Lambda, IAM, CDK Workflow
Using Claude Code on AWS projects
AWS is where Claude Code can save the most engineering time and where it can also do the most damage. The surface area is enormous: IAM, Lambda, CDK, SAM, S3, DynamoDB, EventBridge, SQS, Step Functions, API Gateway, KMS, CloudWatch. Claude knows all of it. What Claude does not know is which patterns your team trusts and which deploys are reversible versus production-bound.
Without a project-specific CLAUDE.md, Claude generates IAM with wildcard actions, mixes legacy SDK v2 with modular v3, writes Lambda handlers without idempotency, and produces CDK code that creates resources outside your tagging conventions. Worse, it can suggest deploys that bypass change sets and apply directly to production.
This guide covers the CLAUDE.md patterns that prevent those failures. If you are new to Claude Code, the Claude Code setup guide covers installation. For the foundational concepts, the CLAUDE.md explained guide walks through the file format.
The AWS CLAUDE.md template
The CLAUDE.md at your project root is read before every Claude Code session. For an AWS project, it needs to declare which IaC tool you use, which SDK version is active, which regions and accounts are in scope, and the IAM rules that govern every policy Claude writes.
# AWS project rules
## Stack
- IaC: AWS CDK v2 (TypeScript), preferred for new stacks
- Legacy stacks: SAM (template.yaml), read-only unless explicitly migrating
- AWS SDK: @aws-sdk/* (v3 modular packages only, no aws-sdk v2)
- Node runtime: nodejs22.x for all Lambda functions
- Region default: eu-west-2 (London)
- Account: 123456789012 (dev), 123456789013 (prod), set via AWS_PROFILE
- Bundler: esbuild (via NodejsFunction in CDK)
## Project structure
- bin/: CDK app entry points (one file per environment)
- lib/: CDK stack and construct definitions
- lib/stacks/: top-level Stack classes
- lib/constructs/: reusable L3 constructs
- src/lambda/: Lambda handler source, one folder per function
- src/lib/: shared TypeScript modules used by handlers
- test/: CDK assertions tests + Lambda unit tests
- scripts/: operational scripts (never auto-run in CI)
## Naming and tagging
- Resource physical names: ${stage}-${stackName}-${logicalId} (kebab-case)
- All stacks tag: Project, Stage, Owner, CostCenter
- Lambda functions: descriptive verb-noun, e.g. process-order, archive-invoice
- IAM roles: ${functionName}-execution-role
- S3 buckets: ${account}-${stage}-${purpose} (lowercase, no underscores)
## IAM rules (HARD)
- NEVER use Action: "*" in any policy statement
- NEVER use Resource: "*" except for actions that genuinely require it (sts:GetCallerIdentity, ec2:DescribeRegions)
- Lambda functions get one execution role each, scoped to that function's needs
- Cross-service access uses role assumption, not shared keys
- All policies are written as IAM Policy JSON or CDK iam.PolicyStatement, never inline string concatenation
- If a needed action is unknown, ASK before guessing the action name
## Deploy rules
- `cdk diff` before every `cdk deploy`
- `cdk deploy --require-approval broadening` minimum, always
- Production deploys require manual approval. Claude never runs `cdk deploy` against the prod profile
- All destructive commands (cdk destroy, aws s3 rb, aws dynamodb delete-table) are denied for Claude
## SDK conventions
- Import from @aws-sdk/client-* per service, do not import the full SDK
- All clients are instantiated once at module scope, reused across invocations
- All commands use the Command pattern: client.send(new XCommand({ ... }))
- All errors caught and rethrown with context, never swallowed
- All async operations have an explicit timeout
Three rules in this CLAUDE.md prevent the most common Claude Code failures with AWS.
The no wildcard rule is the most consequential. AWS training data is full of Action: "*" examples, especially in introductory tutorials. Without an explicit prohibition, Claude produces policies that grant blanket administrative access "to keep things simple while we get it working", which is exactly the moment those policies make it into production.
The SDK v3 only rule prevents Claude from generating a mixed codebase that imports both aws-sdk and @aws-sdk/client-s3. The two SDKs have different invocation patterns and different cold start characteristics. Mixing them doubles cold start time and bundle size.
The ask-do-not-guess rule is the antidote to a specific failure mode. AWS has thousands of IAM actions, and Claude will sometimes invent plausible-sounding ones (s3:ReadBucket, dynamodb:GetTable) that do not exist. The deploy fails with a confusing error. Telling Claude to ask converts a silent fabrication into a question.
IAM patterns that hold up under review
IAM is where AWS engineering is won or lost. A correct design is least-privilege by default, uses role assumption for cross-service access, and never relies on long-lived credentials. Claude can produce IAM that meets this bar with explicit pattern guidance.
## IAM patterns
### Lambda execution role (CDK), use grant functions, never wildcard
const fn = new NodejsFunction(this, 'ProcessOrder', { /* ... */ });
ordersTable.grantReadWriteData(fn);
ordersBucket.grantPut(fn);
// Never: fn.addToRolePolicy(new PolicyStatement({ actions: ['*'], resources: ['*'] }))
### Cross-account role assumption (externalId required, confused deputy)
const role = new iam.Role(this, 'CrossAccountReader', {
assumedBy: new iam.AccountPrincipal('123456789014'),
externalId: process.env.EXTERNAL_ID,
});
role.addToPolicy(new iam.PolicyStatement({
actions: ['s3:GetObject'],
resources: [`${dataBucket.bucketArn}/*`],
}));
### Service-to-service: assume a role, never share access keys
const sts = new STSClient({ region });
const credentials = await sts.send(new AssumeRoleCommand({
RoleArn: 'arn:aws:iam::123456789014:role/PartnerReader',
RoleSessionName: `${functionName}-${Date.now()}`,
DurationSeconds: 900,
}));
### Forbidden patterns
- NEVER store access keys in env vars, Parameter Store, or Secrets Manager
- NEVER create IAM users for application code, only roles
- NEVER attach AdministratorAccess to application roles
- NEVER set Resource: "*" without a same-line comment explaining why
The CDK grantReadWriteData style produces correct IAM. Each grant generates a precise policy statement targeting only the resource it is called on. Without the pattern, Claude reaches for inline PolicyStatement definitions where wildcards creep in.
The external ID on cross-account role assumption is the standard defence against the confused deputy problem. Claude includes it when it is in CLAUDE.md and skips it silently otherwise.
The "no IAM users for application code" rule is one experienced engineers internalise but Claude does not, because IAM user examples dominate older AWS documentation. Roles, role assumption, and Cognito identity pools are the patterns that work. For locking Claude Code's runtime permissions to match the IAM scope, the Claude Code permissions guide covers the settings.json patterns.
Lambda development workflow
Lambda is where most AWS development time is spent and where Claude Code is most useful when configured. Handler pattern, idempotency, observability, and bundling all belong in CLAUDE.md.
## Lambda conventions
### Handler structure (src/lambda/{name}/index.ts)
export const handler: Handler = async (event, context) => {
logger.addContext(context);
try {
return { statusCode: 200, body: JSON.stringify(await ordersService.process(event)) };
} catch (err) {
logger.error('handler failed', { error: err });
throw err; // rethrow so Lambda marks the invocation failed
}
};
### Idempotency
- Every handler that mutates state must be idempotent
- Use @aws-lambda-powertools/idempotency with DynamoDB persistence
- Key = event.id for SQS, event.detail.requestId for EventBridge
### Observability (mandatory, all imported from src/lib/observability.ts)
- @aws-lambda-powertools/logger for structured logs
- @aws-lambda-powertools/tracer for X-Ray segments
- @aws-lambda-powertools/metrics for CloudWatch EMF metrics
### Cold start
- Module-scope: imports, SDK clients, config loading
- Handler-scope: business logic only
- Provisioned concurrency for latency-sensitive paths only
- Bundle target: < 1MB (esbuild minify)
### Local
- esbuild watch: `npm run lambda:watch`
- Local invoke: `sam local invoke FunctionName -e events/test.json`
- Tests: `npm test -- src/lambda/`
The module-scope versus handler-scope distinction is the biggest cold start optimisation Claude can apply, and it does so consistently when the rule is explicit. Without it, Claude instantiates SDK clients inside the handler, producing a fresh client on every cold start.
The Powertools idempotency pattern matters because Lambda invocations are retried by SQS, EventBridge, and Step Functions. A non-idempotent handler that processes an order can charge a customer twice. For TypeScript patterns that improve handler quality, the Claude Code TypeScript guide covers strict mode, type-only imports, and discriminated unions.
CDK patterns in TypeScript
CDK is the IaC tool where Claude Code shines. The TypeScript constructs are typed, the patterns are predictable, and Claude generates correct stack code quickly when conventions are clear.
## CDK patterns
### Stack structure (lib/stacks/orders-stack.ts)
import { Stack, StackProps, Duration, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
interface OrdersStackProps extends StackProps {
stage: 'dev' | 'staging' | 'prod';
}
export class OrdersStack extends Stack {
constructor(scope: Construct, id: string, props: OrdersStackProps) {
super(scope, id, props);
const { stage } = props;
const ordersTable = new dynamodb.Table(this, 'Orders', {
tableName: `${stage}-orders`,
partitionKey: { name: 'orderId', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
pointInTimeRecovery: true,
removalPolicy: stage === 'prod' ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
});
const ordersBucket = new s3.Bucket(this, 'OrdersData', {
bucketName: `${this.account}-${stage}-orders-data`,
encryption: s3.BucketEncryption.S3_MANAGED,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
versioned: true,
removalPolicy: stage === 'prod' ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
});
const processFn = new NodejsFunction(this, 'ProcessOrder', {
functionName: `${stage}-process-order`,
entry: 'src/lambda/process-order/index.ts',
runtime: lambda.Runtime.NODEJS_22_X,
timeout: Duration.seconds(30),
memorySize: 512,
environment: {
ORDERS_TABLE: ordersTable.tableName,
ORDERS_BUCKET: ordersBucket.bucketName,
STAGE: stage,
},
});
ordersTable.grantReadWriteData(processFn);
ordersBucket.grantPut(processFn);
}
}
### App entry (bin/app.ts), tag everything at the App level
const app = new App();
const stage = (app.node.tryGetContext('stage') ?? 'dev') as 'dev' | 'staging' | 'prod';
new OrdersStack(app, `${stage}-orders`, { stage, env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: 'eu-west-2' } });
Tags.of(app).add('Project', 'Orders');
Tags.of(app).add('Stage', stage);
Tags.of(app).add('Owner', 'platform-team');
### CDK Aspects for compliance: block public buckets app-wide
class DenyPublicBuckets implements IAspect {
visit(node: IConstruct): void {
if (node instanceof s3.CfnBucket) {
node.publicAccessBlockConfiguration = { blockPublicAcls: true, blockPublicPolicy: true, ignorePublicAcls: true, restrictPublicBuckets: true };
}
}
}
Aspects.of(app).add(new DenyPublicBuckets());
The stage-conditional removalPolicy prevents accidental data loss in production while keeping dev cycles fast. Dev and staging stacks tear down cleanly. Production retains those resources even if the stack is destroyed.
CDK Aspects enforce account-wide invariants. DenyPublicBuckets blocks public access on every S3 bucket, regardless of who wrote the stack. Combined with aspects for tags, encryption, and logging, you get policy-as-code at synthesis time. For SAM projects the equivalent constraints live in template.yaml. CDK is recommended for new work because typed constructs catch more errors at synth than YAML does at deploy.
S3, DynamoDB, and SDK v3
The AWS SDK v3 is modular: each service is its own package, each operation is its own command class. The pattern is consistent across services and Claude generates it correctly when configured.
// src/lib/services/orders.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const region = process.env.AWS_REGION ?? 'eu-west-2';
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region }));
const s3 = new S3Client({ region });
const ORDERS_TABLE = requireEnv('ORDERS_TABLE');
const ORDERS_BUCKET = requireEnv('ORDERS_BUCKET');
export const ordersService = {
async getOrder(orderId: string) {
const r = await ddb.send(new GetCommand({ TableName: ORDERS_TABLE, Key: { orderId } }));
return r.Item;
},
async putOrder(order: { orderId: string; items: unknown[]; total: number }) {
await ddb.send(new PutCommand({
TableName: ORDERS_TABLE,
Item: order,
ConditionExpression: 'attribute_not_exists(orderId)', // no overwrite
}));
},
async archive(orderId: string, payload: Buffer) {
await s3.send(new PutObjectCommand({
Bucket: ORDERS_BUCKET, Key: `archive/${orderId}.json`,
Body: payload, ContentType: 'application/json', ServerSideEncryption: 'AES256',
}));
},
};
function requireEnv(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Required env var not set: ${name}`);
return v;
}
The single client at module scope is the cold start pattern applied. The requireEnv helper fails fast at module load if a variable is missing, instead of producing a runtime error halfway through processing. The Claude Code environment variables guide covers this pattern in depth.
The ConditionExpression: 'attribute_not_exists(orderId)' on PutCommand prevents overwriting an existing record. Claude includes this when it is in CLAUDE.md and omits it otherwise, producing a write that silently overwrites prior data. For pre-signed URLs, multipart uploads, and DynamoDB transactional writes, the patterns extend the same way: command classes, single client instances, explicit error handling.
Permissions, deploys, and the destructive command list
Claude Code has its own permission system separate from AWS IAM. The .claude/settings.local.json controls which Bash commands Claude can run autonomously. For AWS projects, this is where you stop Claude from running production deploys or destroying resources.
{
"permissions": {
"allow": [
"Bash(npm test*)",
"Bash(npm run build*)",
"Bash(npx cdk synth*)",
"Bash(npx cdk diff*)",
"Bash(npx cdk deploy --profile dev*)",
"Bash(aws s3 ls*)",
"Bash(aws s3 cp*)",
"Bash(aws lambda invoke --function-name dev-*)",
"Bash(aws sts get-caller-identity*)"
],
"deny": [
"Bash(npx cdk deploy --profile prod*)",
"Bash(npx cdk destroy*)",
"Bash(aws s3 rb*)",
"Bash(aws s3 rm*)",
"Bash(aws dynamodb delete-table*)",
"Bash(aws iam delete-*)",
"Bash(aws iam create-user*)",
"Bash(aws iam attach-user-policy*)"
]
}
}
The allow list lets Claude do real work: build, test, synth, diff, deploy to dev, read S3, invoke dev Lambdas. The deny list stops production deploys, destroys, IAM user creation, and S3 object deletion. When Claude tries a denied command, you get a prompt to approve or reject. The dev profile is approved, prod requires explicit human approval, destructive primitives are gated. Claude does the implementation, you review and approve the deploys.
For debugging deploys that go wrong, the Claude Code debugging guide covers CloudWatch logs, X-Ray traces, and Lambda local debugging.
Multi-environment, monorepo, and adjacent platforms
Real AWS work is often part of a broader system: CDK for the cloud components, a Next.js frontend, a separate observability stack, shared TypeScript packages. The Claude Code monorepo guide covers the workspace patterns that let Claude navigate this without getting lost.
For projects combining Lambda with Cloudflare Workers at the edge, the Claude Code with Cloudflare Workers guide covers the worker-side patterns. If you extend Claude Code with AWS-specific MCP servers (CloudWatch queries, IAM lookups, Bedrock calls), the Claude Code MCP servers guide covers configuration and context-cost trade-offs.
Hard rules and final guardrails
A short list of non-negotiable rules belongs at the bottom of every AWS CLAUDE.md. These are the rules Claude must never violate regardless of what the user asks for.
## Hard rules
1. NEVER use Action: "*" or Resource: "*" in IAM, except for actions that genuinely require it, with a same-line comment explaining why.
2. NEVER create IAM users for application code, use roles only.
3. NEVER store AWS credentials in environment variables, code, or version control.
4. NEVER deploy to a production account without explicit user approval per deploy.
5. NEVER use the legacy aws-sdk v2, only @aws-sdk/* v3 modular packages.
6. NEVER skip cdk diff before cdk deploy.
7. NEVER write a Lambda handler that mutates state without idempotency.
8. NEVER swallow errors silently, every catch block must log or rethrow.
9. NEVER hardcode account IDs, region strings, or resource ARNs, use Stack.account, Stack.region, and resource references.
10. If an IAM action name, service quota, or service capability is uncertain, ASK before generating code that depends on it.
Claude can hold ten constraints in mind and apply them consistently. Twenty become probabilistic. These ten cover the failure modes that produce real AWS incidents.
The "ask if uncertain" rule deserves emphasis. AWS has thousands of moving parts and Claude's training has gaps. The honest behaviour, when Claude does not know whether lambda:UpdateFunctionConfiguration allows changing MemorySize without changing Code, is to ask. CLAUDE.md is where you make the honest behaviour explicit.
Building production AWS systems with Claude Code
The configuration in this guide produces a development environment where IAM is least-privilege, CDK stacks are tagged and stage-aware, Lambda handlers are idempotent and observable, S3 and DynamoDB calls follow SDK v3 patterns, and deploys are gated against production damage. The result is Claude generating AWS code at the level of a careful senior engineer, not a junior with admin access.
Claude Code performs at the level of context you give it. Without CLAUDE.md it mixes SDK versions, generates wildcard IAM, and ships handlers without idempotency. With the configuration above it follows your conventions, asks when uncertain, and lets you focus on the parts of AWS engineering that need a human. The Claude Code best practices guide covers principles across project types. The Claude Code custom agents guide covers AWS-specific subagents. Claudify includes an AWS-specific CLAUDE.md template, pre-configured for CDK TypeScript, IAM least-privilege, Lambda Powertools, and SDK v3.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify