AWS CDK Infrastructure Best Practices
TypeScript
- Use strict type checking
- Import from
aws-cdk-libandconstructs - Use L2 constructs when available, L1 (Cfn*) when necessary
Architecture — Single Stack
The entire application is provisioned by one CDK stack (PlatformStack). Application code is shipped out-of-band via AWS APIs (ECR push → ECS service update / Lambda code update / AgentCore Runtime update).
infrastructure/
├── bin/infrastructure.ts # App entrypoint (instantiates PlatformStack)
├── lib/
│ ├── platform-stack.ts # The one stack — all infrastructure
│ ├── config.ts # Configuration loader & validator
│ └── constructs/ # 39 reusable CDK constructs
│ ├── network/ # VPC, ALB, ECS cluster
│ ├── identity/ # Cognito, secrets, KMS, OAuth
│ ├── data/ # DynamoDB tables, file uploads
│ ├── rag/ # RAG documents, vectors
│ ├── rag-ingestion/ # RAG ingestion Lambda
│ ├── artifacts/ # Artifact rendering pipeline
│ ├── mcp-sandbox/ # MCP Apps sandbox proxy
│ ├── agentcore/ # Memory, Code Interpreter, Browser, Gateway
│ ├── inference-api/ # AgentCore Runtime
│ ├── app-api/ # Fargate service
│ ├── fine-tuning/ # SageMaker IAM
│ ├── spa/ # SPA CloudFront distribution
│ └── zones/ # Route53, ALB DNS
└── cdk.context.json # Configuration defaults
Key principle: CDK deploys are rare (infrastructure changes only). Day-to-day code changes deploy via backend.yml (AWS API calls, no CDK).
Configuration
Use the centralized config system:
import { loadConfig, getResourceName, getStackEnv, applyStandardTags } from './config';
PlatformStack receives config via props:
const config = loadConfig(app);
new PlatformStack(app, `${config.projectPrefix}-PlatformStack`, { config, env });
For configuration patterns, see references/configuration.md.
Naming Conventions
Resource Names: Use getResourceName():
getResourceName(config, 'user-quotas') // "bsu-agentcore-user-quotas"
SSM Parameters: Hierarchical naming for runtime consumption:
/{projectPrefix}/{category}/{resource-type}
Categories: /network/, /quota/, /cost-tracking/, /auth/, /frontend/, /gateway/, /rag/, /artifacts/
Cross-Construct References
Since everything is in one stack, use typed props — not SSM:
// In PlatformStack:
const network = new NetworkConstruct(this, 'Network', { config });
new AlbConstruct(this, 'Alb', { config, vpc: network.vpc });
SSM parameters are published only for runtime consumption by ECS tasks and Lambdas — never for CDK-to-CDK references within the same stack.
Wire a resource's name to every compute that reads it
When a construct exposes a table/bucket that backend code reads via
os.environ.get("X_NAME", "default"), you must set X_NAME in the container
environment of every compute that runs that code — thread the typed ref
through that compute's env builder (e.g. buildAppApiEnvironment for app-api,
the inference-agentcore construct's environment for inference-api). Wiring one
does not wire the other.
Why this bites (silent 502): the backend's default fallback hides the
omission. If the env var is missing, the code queries the default name
(e.g. "memory-spaces" instead of {prefix}-memory-spaces), the resource
isn't found, boto3 raises ResourceNotFoundException, and the centralized
handler (apis/shared/security/error_handler.py) maps it to a generic
502 {"detail":"Upstream service error."}. Nothing in cdk synth or CI
catches it — the stack is valid, the IAM grant may even exist; only a runtime
read fails. (Real instance: PR #588 — memory-spaces names were wired to
inference-api but not app-api, which owns the CRUD routes.)
Guard it: add an env-map unit test asserting the key is emitted (see
test/app-api-environment.test.ts). Mind the boundary: app-api owns
user-facing CRUD; granting IAM or wiring inference-api does not cover it.
DynamoDB Tables
- Always use PK + SK for flexibility
- Use
PAY_PER_REQUESTbilling - Enable point-in-time recovery
- Environment-based removal policy
For table patterns, see references/dynamodb.md.
ECS/Fargate
- Cluster created by NetworkConstruct, referenced via typed prop
- Health checks mandatory
- Auto-scaling with CPU/memory targets
- Circuit breaker for rollback
- Bootstrap container pattern: CDK creates the service with a placeholder image; the backend workflow pushes the real image via
update-service
For service patterns, see references/ecs-fargate.md.
Lambda
- Use ARM64 architecture (cost optimization)
- Role with least privilege
- Secrets Manager access requires wildcard suffix
- Bootstrap pattern: CDK creates the function with placeholder code; the backend workflow pushes real code via
update-function-code
For Lambda patterns, see references/lambda.md.
S3 Buckets
- Block public access
- Enable versioning
- Lifecycle rules for cost optimization
- Include account ID for global uniqueness
For bucket patterns, see references/s3.md.
Security
- Separate security groups for ALB and ECS
- Private subnets for services
- IAM roles with SIDs for clarity
- Never hardcode secrets
For IAM patterns, see references/iam.md.
Important Constraints
AgentCore Names: Use underscores, not hyphens:
name: getResourceName(config, 'memory').replace(/-/g, '_')
Secrets Manager ARN: Include wildcard for random suffix:
resources: [`${secret.secretArn}*`]
Removal Policy:
removalPolicy: getRemovalPolicy(config) // RETAIN in prod, DESTROY in dev
CDK Commands
cd infrastructure
npm ci # Install dependencies
npx cdk synth # Synthesize CloudFormation
npx cdk deploy {prefix}-PlatformStack # Deploy
npx cdk diff # Preview changes