Describe the cloud in code with AWS CDK
Outcome: You run cdk synth successfully, read the two stacks that define the site hosting and the agent runtime, and can name every resource each one creates (15 in the site stack, 8 in the agent stack) plus the exact model and memory ARNs the agent's execution role is allowed to use.
Outcome
By the end of this lesson you will read the two files that define all of the capstone’s cloud, and you will run one command that turns them into the exact templates AWS (Amazon Web Services) would deploy. You will be able to point at each stack and name every resource it creates — 15 in the site-hosting stack and 8 in the agent stack — and you will see, in the synthesized output, that the agent’s permissions are scoped to named model and memory resources rather than “everything.” All of this happens on your machine, for free, with no AWS account touched.
This is the first cloud lesson, and it deliberately deploys nothing. You look before you leap.
Mental model
In a visual automation tool, you build a workflow by dragging nodes onto a canvas, then press Publish. Publishing takes the design you drew and turns it into the real, running thing. Infrastructure as code is that same split, written down: you describe the cloud you want in a file, and a tool turns that description into real resources. The description is reviewable, versioned, and repeatable, the same way a saved workflow is.
The tool here is the AWS Cloud Development Kit (CDK). You write TypeScript that
declares resources — a storage bucket, a container repository, an agent runtime —
and CDK synthesizes that code into a CloudFormation template. CloudFormation is
AWS’s own deployment engine: a template is a long, exact document listing every
resource and property. You rarely write that document by hand; CDK generates it
from the much shorter code you read below. The command that does this is
cdk synth. It is the “show me exactly what Publish would create” button, and it
runs without deploying anything.
CDK organizes code into constructs, which come in three levels:
- L1 constructs map one-to-one to a raw CloudFormation resource. Their names
start with
Cfn(for exampleCfnBucket). They expose every property AWS offers and none of the convenience. Think of the rawest possible node with every setting exposed. - L2 constructs are curated wrappers around L1. They pick safe defaults, add
helper methods, and are what you use most of the time.
s3.Bucketis an L2: it is far shorter thanCfnBucketand defaults to sensible, secure settings. - L3 constructs (also called patterns) bundle several L2 constructs into one common architecture. This course does not need any.
Where the analogy stops: pressing Publish in a visual tool usually updates one
workflow in place. cdk synth produces a full template but changes nothing; the
change happens only later, in Lesson 9, when you deliberately deploy. Synth is
safe to run as often as you like.
Prerequisites and cost
- Lesson 0 complete: Node.js 22.12 or newer and the AWS CLI installed. Check with
node --version. - Lesson 7 complete: the agent code is tested and ready.
Cost: free. cdk synth reads the two stack files and the built site, then writes
templates into a local cdk.out/ folder. It calls no AWS service and needs no
credentials, so it cannot bill anything. Deployment (which does cost money) is the
next lesson.
Steps
Run these from the infra/ folder.
-
Install the CDK library and CLI listed in this project’s
package.json:npm install -
Build the site first. The site-hosting stack bundles the built site (
site/dist/) as an asset, and CDK reads that folder at synth time, so it must exist:cd ../site && npm install && npm run build && cd ../infra -
Synthesize both stacks:
npx cdk synthThis writes both templates into
cdk.out/. Because this app has two stacks, the command prints an INFO note andSupply a stack id ... to display its templateinstead of a template. To print one stack’s template to your screen, name it:npx cdk synth WorkflowsToAgentsAgent -
List the stacks to confirm both were found:
npx cdk list
Smallest code sample
Read both stacks. Each is one class describing one stack. Notice how short they are compared to the templates they generate.
First, the site-hosting stack. It creates a private storage bucket (Block
Public Access fully on, encrypted, HTTPS enforced) and puts a CloudFront
distribution in front of it using Origin Access Control, so only the distribution
can read the bucket and every visitor is redirected to HTTPS. A small CloudFront
Function rewrites directory-style URLs to their index.html object. Every resource
uses an L2 construct.
import * as path from 'path';
import { CfnOutput, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import { S3BucketOrigin } from 'aws-cdk-lib/aws-cloudfront-origins';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import { Construct } from 'constructs';
/**
* Absolute path to the built Astro site. `site/dist` is produced by
* `npm run build` in ../site. BucketDeployment reads this directory at synth
* time, so it MUST exist before `cdk synth`/`cdk deploy` (see infra/README.md).
*/
const SITE_DIST = path.join(__dirname, '..', '..', 'site', 'dist');
/**
* Static website hosting: a private S3 bucket fronted by CloudFront using
* Origin Access Control (OAC). The bucket is never public; only CloudFront can
* read it, and all viewer traffic is redirected to HTTPS.
*/
export class SiteHostingStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Private origin bucket. Nothing is public: Block Public Access is fully on,
// objects are encrypted with S3-managed keys, and non-TLS requests are denied
// by an auto-generated bucket policy (enforceSSL). Disposable tutorial env, so
// the bucket and its contents are destroyed with the stack.
const bucket = new s3.Bucket(this, 'SiteBucket', {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});
// Astro builds "directory" URLs: /lessons/00-setup/ -> lessons/00-setup/index.html.
// A private S3 REST origin (OAC) does not resolve directory index documents the
// way the S3 *website* endpoint does, so a viewer-request CloudFront Function
// rewrites directory-style URIs to the underlying index.html object. Without it
// every lesson sub-page would 403. CloudFront Functions run at the edge with no
// extra origin round-trip. (Deviation from the minimal brief -- see README.)
const indexRewrite = new cloudfront.Function(this, 'IndexRewrite', {
comment: 'Rewrite directory-style URIs to their index.html object.',
code: cloudfront.FunctionCode.fromInline(
[
'function handler(event) {',
' var request = event.request;',
' var uri = request.uri;',
' if (uri.endsWith("/")) {',
' request.uri = uri + "index.html";',
' } else if (uri.lastIndexOf(".") <= uri.lastIndexOf("/")) {',
' // No file extension in the last path segment -> treat as a directory.',
' request.uri = uri + "/index.html";',
' }',
' return request;',
'}',
].join('\n'),
),
});
const distribution = new cloudfront.Distribution(this, 'SiteDistribution', {
comment: 'From Workflows to Agents course site',
defaultRootObject: 'index.html',
// US/EU/Canada/Israel edge locations only -- cheapest tier that still serves
// the tutorial audience. Cost note: see README (CloudFront pricing page).
priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
defaultBehavior: {
// withOriginAccessControl creates the OAC and attaches a bucket policy that
// allows ONLY this distribution (scoped by SourceArn) to read the bucket.
// Verified against aws-cloudfront-origins .d.ts: static factory returning IOrigin.
origin: S3BucketOrigin.withOriginAccessControl(bucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
functionAssociations: [
{
function: indexRewrite,
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
},
],
},
// No custom errorResponses: the built site currently ships no 404.html
// (no src/pages/404.astro in ../site). Referencing a non-existent error
// object would loop, so we rely on CloudFront's default error passthrough.
// If the site later adds a 404 page, map 403/404 -> /404.html here.
});
// Upload the built site and invalidate the CDN cache on each deploy. This reads
// SITE_DIST at synth time and bundles it as a CDK asset.
new s3deploy.BucketDeployment(this, 'DeploySite', {
sources: [s3deploy.Source.asset(SITE_DIST)],
destinationBucket: bucket,
distribution,
distributionPaths: ['/*'],
// Bounded memory keeps the deployment Lambda cheap for a small static site.
memoryLimit: 256,
});
new CfnOutput(this, 'DistributionDomainName', {
value: distribution.distributionDomainName,
description: 'CloudFront domain to open the course site (https).',
});
new CfnOutput(this, 'BucketName', {
value: bucket.bucketName,
description: 'Private S3 origin bucket name (not publicly reachable).',
});
}
}
Second, the agent stack. It creates a container repository (ECR, Elastic Container Registry) for the agent
image, an AgentCore Runtime to run that image, an AgentCore Memory for the
cross-session preference from Lesson 6, a log group with bounded retention, and a
least-privilege execution role. The AgentCore constructs are stable L2 constructs
from aws-cdk-lib/aws-bedrockagentcore.
import {
CfnOutput,
Duration,
RemovalPolicy,
Stack,
StackProps,
} from 'aws-cdk-lib';
import * as agentcore from 'aws-cdk-lib/aws-bedrockagentcore';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';
/**
* Configuration for the capstone runtime. Kept as named constants (overridable
* via `cdk.json` context / `-c`) rather than hard-coded inline, per AGENTS.md
* "Keep model ID, region, memory ID ... configurable". Defaults match
* docs/research-log.md (verified 2026-07-15).
*/
// Cross-region (US geo) inference profile id for Claude Haiku 4.5. This is the
// value the agent passes to Bedrock and the tail of the inference-profile ARN.
const DEFAULT_MODEL_ID = 'us.anthropic.claude-haiku-4-5-20251001-v1:0';
// The underlying foundation-model id (no geo prefix). A `us.` inference profile
// fans out to the same base model in each US regional endpoint, so the execution
// role must be allowed to invoke the model in every region the profile can route to.
const BASE_MODEL_ID = 'anthropic.claude-haiku-4-5-20251001-v1:0';
// Region the tutorial deploys to and where Bedrock is called. The inference
// profile ARN is regional; this must match the deploy region.
const DEFAULT_REGION = 'us-east-1';
// Regional endpoints the US-geo inference profile can dispatch to. Granting
// InvokeModel on only the profile ARN is NOT enough: Bedrock authorizes the call
// against the resolved regional foundation-model ARN too. Source: Bedrock
// cross-region inference IAM guidance (docs.aws.amazon.com/bedrock, verified 2026-07-15).
const US_GEO_MODEL_REGIONS = ['us-east-1', 'us-east-2', 'us-west-2'];
// Container image tag the runtime pulls from ECR. Learners build + push this tag
// in Lesson 9 (see README push flow) before `cdk deploy`.
const DEFAULT_IMAGE_TAG = 'latest';
/**
* Capstone runtime stack: an Amazon Bedrock AgentCore Runtime that runs the
* containerized intake agent, an AgentCore Memory (short-term events + a
* long-term SEMANTIC strategy), and a least-privilege execution role.
*/
export class AgentStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const modelId = this.node.tryGetContext('modelId') ?? DEFAULT_MODEL_ID;
const region = this.node.tryGetContext('region') ?? DEFAULT_REGION;
const imageTag = this.node.tryGetContext('imageTag') ?? DEFAULT_IMAGE_TAG;
// -----------------------------------------------------------------------
// Container artifact: ECR repository (build/push flow documented in README).
// -----------------------------------------------------------------------
// We use fromEcrRepository rather than fromAsset('../agent') deliberately:
// fromAsset triggers a `docker build` at synth time and the image targets
// linux/arm64, which needs qemu/binfmt emulation and network access. Keeping
// synth free of Docker makes `cdk synth` reproducible for every learner and
// on CI without credentials. The learner builds + pushes the arm64 image in
// Lesson 9 before deploying. (Documented deviation -- see README.)
const repository = new ecr.Repository(this, 'AgentImageRepository', {
repositoryName: 'workflows-to-agents-intake',
imageScanOnPush: true,
// Disposable env: destroying the stack also deletes the repo AND its images
// (emptyOnDelete), so no ECR images are left billing after teardown.
removalPolicy: RemovalPolicy.DESTROY,
emptyOnDelete: true,
// Bound stored images to control ECR storage cost during the tutorial.
lifecycleRules: [{ maxImageCount: 5 }],
});
const artifact = agentcore.AgentRuntimeArtifact.fromEcrRepository(
repository,
imageTag,
);
// -----------------------------------------------------------------------
// Memory: short-term raw events + a long-term SEMANTIC extraction strategy.
// -----------------------------------------------------------------------
// Prop names verified against the installed aws-cdk-lib 2.261.0 .d.ts:
// - `expirationDuration` sets short-term (raw event) retention (7-365 days,
// default 90). Bounded to 30 days here for a disposable env.
// - `memoryStrategies` holds long-term extraction strategies;
// MemoryStrategy.usingBuiltInSemantic() -> a managed SEMANTIC strategy.
const memory = new agentcore.Memory(this, 'IntakeMemory', {
memoryName: 'intake_memory',
expirationDuration: Duration.days(30),
memoryStrategies: [agentcore.MemoryStrategy.usingBuiltInSemantic()],
});
// Teardown: AWS::BedrockAgentCore::Memory has no L2 removal-policy prop, and
// CloudFormation's default deletion policy (Delete) already removes the memory
// -- and its stored records -- on `cdk destroy`. No escape hatch is needed;
// the synthesized template carries no DeletionPolicy (= Delete). The L2 also
// auto-creates a memory service role (trust scoped to this memory's ARN) that
// is deleted with the stack.
// -----------------------------------------------------------------------
// Runtime. The L2 auto-creates an execution role with a correct trust policy
// (bedrock-agentcore.amazonaws.com + aws:SourceAccount/SourceArn confused-
// deputy conditions) and injects a baseline set of execution-role statements
// (logs, X-Ray, CloudWatch metrics, workload identity). We add ONLY the two
// scoped statements the baseline does not cover: Bedrock model invocation and
// AgentCore Memory access. See README "IAM" for the full inventory, including
// the construct-injected wildcard-resource statements AWS requires.
// -----------------------------------------------------------------------
const runtime = new agentcore.Runtime(this, 'IntakeAgentRuntime', {
runtimeName: 'intakeAgent',
agentRuntimeArtifact: artifact,
description: 'Bounded automation intake agent (capstone).',
environmentVariables: {
// Consumed by agent/src/intake/config.py (MODEL_ID, AWS_REGION, MEMORY_ID).
MODEL_ID: modelId,
AWS_REGION: region,
MEMORY_ID: memory.memoryId,
},
});
// --- Bedrock model invocation (scoped, no wildcards) -------------------
// Exactly the two actions the Strands BedrockModel needs: non-streaming and
// streaming inference. Scoped to (a) the regional inference-profile ARN the
// agent calls, and (b) the resolved foundation-model ARN in each US regional
// endpoint the profile can route to. Foundation-model ARNs are account-less
// by design.
const modelResources = [
Stack.of(this).formatArn({
service: 'bedrock',
region,
resource: 'inference-profile',
resourceName: modelId,
}),
...US_GEO_MODEL_REGIONS.map((r) =>
Stack.of(this).formatArn({
service: 'bedrock',
region: r,
account: '', // foundation-model ARNs have no account id
resource: 'foundation-model',
resourceName: BASE_MODEL_ID,
}),
),
];
runtime.addToRolePolicy(
new iam.PolicyStatement({
sid: 'InvokeClaudeHaiku',
effect: iam.Effect.ALLOW,
actions: ['bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream'],
resources: modelResources,
}),
);
// --- AgentCore Memory access (scoped to this memory's ARN) -------------
// grantWrite -> CreateEvent (write conversation turns).
// grantRead -> Get/List events + Get/Retrieve/List long-term memory records.
// Both grants set resourceArns to memory.memoryArn only (verified in the
// installed .d.ts/perms.js), so nothing else is reachable.
memory.grantWrite(runtime);
memory.grantRead(runtime);
// -----------------------------------------------------------------------
// Log group with bounded retention + DESTROY removal.
// -----------------------------------------------------------------------
// AgentCore writes application logs to /aws/bedrock-agentcore/runtimes/{id}-DEFAULT.
// The service creates that group on first invocation with NO retention (logs
// kept forever = cost). Pre-creating it here lets CDK own its retention and
// delete it on teardown. This is the exact pattern the runtime construct's
// own .d.ts recommends. The execution role's baseline already scopes log
// writes to /aws/bedrock-agentcore/runtimes/*, so no extra IAM is needed.
new logs.LogGroup(this, 'RuntimeLogGroup', {
logGroupName: `/aws/bedrock-agentcore/runtimes/${runtime.agentRuntimeId}-DEFAULT`,
retention: logs.RetentionDays.ONE_MONTH,
removalPolicy: RemovalPolicy.DESTROY,
});
new CfnOutput(this, 'RuntimeArn', {
value: runtime.agentRuntimeArn,
description: 'ARN of the AgentCore Runtime (invoke target for Lesson 9).',
});
new CfnOutput(this, 'MemoryId', {
value: memory.memoryId,
description: 'AgentCore Memory id -- set as MEMORY_ID when running locally.',
});
new CfnOutput(this, 'EcrRepositoryUri', {
value: repository.repositoryUri,
description: 'Push the agent image here before deploying (see README).',
});
}
}
Least privilege, made concrete
The most important part of the agent stack is what the runtime is allowed to
do. The Runtime construct attaches a baseline set of permissions you cannot opt
out of, and this code adds exactly two scoped statements on top: permission to call
the one model, and permission to use the one memory.
The model statement is the one to study. It grants only two actions
(bedrock:InvokeModel and bedrock:InvokeModelWithResponseStream) and scopes them
to named resources — the inference profile the agent calls, plus the underlying
foundation model in each US region that profile can route to. This is the real,
synthesized statement (captured on 2026-07-15):
{
"Sid": "InvokeClaudeHaiku",
"Effect": "Allow",
"Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
"Resource": [
"arn:<PARTITION>:bedrock:us-east-1:<ACCOUNT_ID>:inference-profile/us.anthropic.claude-haiku-4-5-20251001-v1:0",
"arn:<PARTITION>:bedrock:us-east-1::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0",
"arn:<PARTITION>:bedrock:us-east-2::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0",
"arn:<PARTITION>:bedrock:us-west-2::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0"
]
}
<PARTITION> and <ACCOUNT_ID> are filled in at deploy time from your account;
CDK leaves them as references in the template. A us. inference profile resolves to
the same base model in several US regions, so the role must be allowed to invoke the
model in each of them — that is why one profile ARN (Amazon Resource Name) is paired with three
foundation-model ARNs.
Some baseline statements do use Resource: "*". That is not sloppiness; a few AWS
APIs take no resource ARN at all. For example, logs:DescribeLogGroups lists log
groups and cannot be scoped to one, and cloudwatch:PutMetricData publishes a
metric with no resource to name (the stack still narrows it with a
cloudwatch:namespace condition). Every such wildcard pairs a narrow, list-or-emit
action with a resource type AWS does not let you scope by ARN. There is no
Action: "*" and no broad-action Resource: "*" anywhere. The full statement-by-
statement inventory is in infra/README.md.
Expected output
cdk synth runs locally, so this is real output, captured on 2026-07-15. First,
npx cdk list shows the two stacks:
WorkflowsToAgentsSite
WorkflowsToAgentsAgent
Then npx cdk synth WorkflowsToAgentsAgent prints that stack’s template. Your run first
prints one INFO note (container image validation deferred to deploy time —
expected, the image reference is a placeholder until deploy). This is the top of
the real output, trimmed to the first two resources with some properties
(lifecycle policy, tags, metadata) omitted for space:
Description: Amazon Bedrock AgentCore Runtime + Memory for the capstone intake agent.
Resources:
AgentImageRepository1A12994E:
Type: AWS::ECR::Repository
Properties:
EmptyOnDelete: true
ImageScanningConfiguration:
ScanOnPush: true
RepositoryName: workflows-to-agents-intake
UpdateReplacePolicy: Delete
DeletionPolicy: Delete
IntakeMemoryF1629C59:
Type: AWS::BedrockAgentCore::Memory
Properties:
EventExpiryDuration: 30
The checkpoint for this lesson is that synth succeeds and you can name every resource. Counting the synthesized templates, the site stack creates 15 resources:
- 1 S3 (Amazon Simple Storage Service) bucket and 1 bucket policy (the private origin)
- 1 CloudFront distribution, 1 Origin Access Control, and 1 CloudFront Function
- 1 CloudFormation custom resource plus 2 Lambda functions, 2 IAM (Identity and Access Management) roles, 1 IAM policy, and 1 Lambda layer that together upload the built site and empty the bucket on teardown
- 1 log group for those helper Lambdas
- 1 CDK metadata resource (added to every stack)
The agent stack creates 8 resources:
- 1 ECR repository (the container image store)
- 1 AgentCore Memory and its auto-created memory service role (an IAM role)
- 1 AgentCore Runtime and its auto-created execution role (an IAM role)
- 1 IAM policy (the two scoped statements above, attached to the execution role)
- 1 log group with one-month retention
- 1 CDK metadata resource
If synth prints a template, cdk list shows both stacks, and you can point at each
resource above in the cdk.out/*.template.json files, you have hit the checkpoint.
One common failure
Symptom: npx cdk synth fails immediately with command not found, a
Cannot find module 'aws-cdk-lib' error, or a message that your Node.js version is
unsupported — before any template is printed.
Diagnosis: the dependencies were never installed, or Node.js is too old. cdk
and aws-cdk-lib are project dependencies; if you skipped npm install in
infra/, nothing is there to run. Astro and CDK both require Node.js 22.12 or
newer, so an older Node also fails here.
Fix: from infra/, run npm install, then check node --version shows 22.12
or newer and run npx cdk synth again. A second, later symptom — synth complaining
it cannot find ../site/dist — means you skipped the build step; run
npm run build in site/ first.
Why this works
Reading the stacks before deploying is the whole point. The code is short because
L2 constructs carry safe defaults, but cdk synth expands it into the full, exact
CloudFormation template AWS will act on, so you review the real thing rather than a
summary. Because synth only reads and writes local files, you can inspect that
template, count its resources, and check the IAM scope as many times as you want
without spending a cent or creating anything. And the synthesized policy shows least
privilege as a fact, not a promise: the agent may invoke exactly one model family
and one memory, named by ARN, and every wildcard that remains is one AWS itself
requires for an action that has no resource to name. You now know precisely what
Lesson 9 will create before it creates it.
Verify it yourself
The checkpoint is: synth succeeds and you can name every resource in each stack.
- Run
npx cdk synth WorkflowsToAgentsAgentfrominfra/and confirm it prints a template without error. - Open
cdk.out/WorkflowsToAgentsAgent.template.jsonand find the eight resources listed above. Confirm theAWS::BedrockAgentCore::RuntimeandAWS::BedrockAgentCore::Memoryresources are both present. - Open
cdk.out/WorkflowsToAgentsSite.template.jsonand confirm the S3 bucket hasPublicAccessBlockConfigurationwith all four settingstrue— the bucket is private, exactly as the code declared. - Find the
InvokeClaudeHaikustatement in the agent template and confirm itsResourcelist names the model ARNs, not*.
Seeing the templates, counting the resources, and reading the scoped IAM statement is this lesson’s checkpoint: you can describe the entire cloud footprint before any of it exists.
Cleanup
Nothing billable was created, so there is nothing in the cloud to remove. cdk synth only wrote the local cdk.out/ folder. If you want a clean working
directory you can delete it:
rm -rf cdk.out
It will be regenerated the next time you run synth. You now understand the exact resources the next lesson deploys, and why the agent’s permissions are as narrow as they are.

