IaC: Terraform & CDK
Clicking in the console does not scale. This chapter turns the W4–W6 stack — KB, guardrail, OpenSearch, IAM — into a versioned, reproducible artifact in Terraform and CDK.
- AWS credentials, Node.js (CDK needs it), +
pip install aws-cdk-lib constructs - AWS credentials (
aws configure) + Bedrock model access enabled in your region +pip install boto3
Learning objectives
- Explain why clicking in the console does not scale and IaC does.
- Provision a Guardrail and a Knowledge Base with Terraform.
- Provision the same with AWS CDK (Python).
- Wire the IAM roles these resources require, least-privilege.
code/aws7-iac-bedrock/ in the course, with a README on how to run it. Read here, run there — no need to copy-paste.Why IaC for AWS AI intermediate
Every resource in W4–W6 (KB, OpenSearch collection, agent, guardrail, IAM roles) has dependencies and must be reproducible across dev/stage/prod. Terraform and CDK make the whole stack a versioned, reviewable artifact — the discipline from your LLMOps chapters, applied to AWS AI.
Terraform: guardrail + KB intermediate
main.tf# main.tf — a Bedrock guardrail and a Knowledge Base, in Terraform.
resource "aws_bedrock_guardrail" "support" {
name = "support-guardrail"
blocked_input_messaging = "I can't help with that."
blocked_outputs_messaging = "I can't provide that."
content_policy_config {
filters_config {
type = "HATE"
input_strength = "HIGH"
output_strength = "HIGH"
}
}
sensitive_information_policy_config {
pii_entities_config {
type = "EMAIL"
action = "ANONYMIZE"
}
}
}
resource "aws_bedrockagent_knowledge_base" "docs" {
name = "support-docs"
role_arn = aws_iam_role.kb.arn
knowledge_base_configuration {
type = "VECTOR"
vector_knowledge_base_configuration {
embedding_model_arn = "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0"
}
}
storage_configuration {
type = "OPENSEARCH_SERVERLESS"
opensearch_serverless_configuration {
collection_arn = aws_opensearchserverless_collection.vec.arn
vector_index_name = "support-index"
field_mapping {
vector_field = "vec"
text_field = "text"
metadata_field = "meta"
}
}
}
}
This is Infrastructure as Code (IaC): instead of clicking buttons in the AWS web console, you describe the cloud resources you want in a text file, and Terraform makes reality match the file. This file (main.tf, written in Terraform's language called HCL) asks for two things: a safety guardrail and a Knowledge Base (a searchable store of documents). You never say how to build them — only what you want.
- Each
resource "type" "name" { ... }block declares one cloud thing. The first quoted word is the AWS resource type; the second is a private nickname you choose so other parts of the file can refer to it. Hereaws_bedrock_guardrailnicknamedsupportis the guardrail. - Inside the guardrail,
content_policy_configturns on a filter: blockHATEcontent atHIGHstrength on both the user's input and the model's output.sensitive_information_policy_configtells it toANONYMIZE(hide) anyEMAILit detects. - The second resource,
aws_bedrockagent_knowledge_basenicknameddocs, is the document store.role_arn = aws_iam_role.kb.arnhands it a set of permissions — thataws_iam_role.kbis defined in the next lab; Terraform will link them automatically. knowledge_base_configurationpicks the embedding model (the AI that turns text into searchable number-vectors), andstorage_configurationpoints at an OpenSearch collection to hold those vectors.field_mappingjust names the three columns: the vector, the text, and its metadata.
What the output means: Nothing runs yet — this is a declaration. You'd run terraform apply to make it real; Terraform then prints each resource it creates and a green Apply complete! summary.
Try this: Read the two quoted words after each resource. Can you say, in English, what AWS thing each block asks for and what you nicknamed it? That habit is the whole skill of reading Terraform.
role_arn = aws_iam_role.kb.arn and the collection reference. Terraform builds the graph and creates the role and OpenSearch collection before the KB — the exact ordering that tripped up the boto3 version in W4.The IAM role (least privilege) advanced
iam.tf# The KB's execution role: read the S3 data source, use the embeddings
# model, and write to the OpenSearch collection — nothing more.
resource "aws_iam_role" "kb" {
name = "bedrock-kb-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "bedrock.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "kb" {
role = aws_iam_role.kb.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{ Effect = "Allow", Action = ["s3:GetObject", "s3:ListBucket"],
Resource = ["arn:aws:s3:::support-docs", "arn:aws:s3:::support-docs/*"] },
{ Effect = "Allow", Action = ["bedrock:InvokeModel"],
Resource = "arn:aws:bedrock:*::foundation-model/amazon.titan-embed-text-v2:0" },
{ Effect = "Allow", Action = ["aoss:APIAccessAll"],
Resource = "arn:aws:aoss:*:*:collection/*" },
]
})
}
Before the Knowledge Base above can do its job it needs permission to touch other AWS services. In AWS, permissions are packaged into an IAM role — think of it as a badge the Knowledge Base wears. This file builds that badge and keeps it as small as possible, a security idea called least privilege: grant only what's needed, nothing extra.
- The first resource,
aws_iam_rolenicknamedkb, creates the badge itself. Itsassume_role_policyanswers who is allowed to wear it: only thebedrock.amazonaws.comservice.jsonencode({...})just converts the HCL block into the JSON text AWS expects. - The second resource,
aws_iam_role_policy, attaches the actual permissions to that badge viarole = aws_iam_role.kb.id(linking back to the role above). - Each item in
Statementis one permission:Effect = "Allow", a list ofActions, and theResourcethey apply to. The three allowed abilities map exactly to the comment: read documents from the S3 bucket (s3:GetObject,s3:ListBucket), call the embedding model (bedrock:InvokeModel), and write into the OpenSearch collection (aoss:APIAccessAll). - Notice there is no "allow everything" line. If the KB later tried to, say, delete an S3 bucket, AWS would refuse — because that action was never granted here.
What the output means: Also just a declaration. After terraform apply, AWS would show a new role named bedrock-kb-role with exactly these three permissions and nothing more.
Try this: Cover the code and read only the comment at the top. Then check: does every permission below trace back to one of those three needs? If a line didn't, that would be a red flag in a real security review.
The same in CDK (Python) expert
Prefer code over HCL? CDK generates CloudFormation from Python. The resource model mirrors Terraform's; pick one per team and stay consistent.
ai_stack.pyfrom aws_cdk import Stack, aws_bedrock as bedrock
from constructs import Construct
class AiStack(Stack):
def __init__(self, scope: Construct, cid: str, **kw):
super().__init__(scope, cid, **kw)
bedrock.CfnGuardrail(
self, "SupportGuardrail",
name="support-guardrail",
blocked_input_messaging="I can't help with that.",
blocked_outputs_messaging="I can't provide that.",
content_policy_config=bedrock.CfnGuardrail.ContentPolicyConfigProperty(
filters_config=[bedrock.CfnGuardrail.ContentFilterConfigProperty(
type="HATE", input_strength="HIGH", output_strength="HIGH")]
),
)
# a CfnKnowledgeBase construct wires the same fields as the Terraform above
This does the same job as Lab W7.1 but in Python instead of HCL, using the AWS CDK (Cloud Development Kit). CDK lets you describe infrastructure in a real programming language; behind the scenes it generates AWS's own template format (CloudFormation) and deploys it. A CDK Stack is one deployable bundle of resources.
- The
importlines pull in CDK's building blocks —Stackand the Bedrock resource library. class AiStack(Stack):defines your stack by inheriting from CDK'sStack. The__init__method is where you list the resources;super().__init__(...)runs CDK's own setup first — standard Python for "do the parent's setup, then mine".bedrock.CfnGuardrail(self, "SupportGuardrail", ...)creates the guardrail.selfattaches it to this stack, and"SupportGuardrail"is its id within the stack. The keyword arguments (name,blocked_input_messaging,content_policy_config) are the very same settings you saw in the Terraform version — just written as Python instead.- The
ContentPolicyConfigProperty/ContentFilterConfigPropertyobjects are CDK's typed way of nesting the HATE filter — the Python equivalent of the nestedcontent_policy_config { filters_config { ... } }blocks in HCL. The closing comment notes a Knowledge Base construct would wire up identically.
What the output means: No output on its own. You'd run cdk synth to see the generated CloudFormation template, then cdk deploy to create the guardrail in AWS.
Try this: Put this side by side with Lab W7.1. Find the four matching pieces — the name, the two blocked messages, and the HATE filter. Same infrastructure, two dialects: pick whichever your team prefers and stay consistent.
Exercise W7.1 — Stand up W4–W6 as code
Context: The capstone of the IaC lesson is retiring the console clicks: everything you built by hand in W4 and W6 becomes reproducible code. Real reproducibility is only proven when you can destroy the stack and re-apply it to an identical result.
Your task: Take the Knowledge Base and guardrail you created by hand in W4 and W6 and express the whole stack in Terraform (or CDK), then plan/diff, apply, and finally destroy and re-apply to prove reproducibility.
Requirements:
- Both the guardrail and the KB (with role and vector store) are declared in code
- Review the
plan/diffbefore applying - After
apply, confirm the live resources match the declarations destroytears the stack down cleanly- A second
applyreproduces the identical stack
💡 Hint: Let attribute references wire the dependency order for you, then trust the destroy/re-apply cycle as the real proof that the stack is reproducible.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Clicking a guardrail together in the console is fine once; doing it repeatably across environments is not. Expressing it as an aws_bedrock_guardrail resource makes it reviewable in a PR and reproducible by apply — the IaC equivalent of create_guardrail.
Your task: Write an aws_bedrock_guardrail Terraform resource with blocked input and output messaging and one HATE content filter at HIGH strength.
Requirements:
- A single
aws_bedrock_guardrailresource block - Set
blocked_input_messagingandblocked_outputs_messaging - One
content_policy_configwith afilters_configfor HATE - Filter sets
input_strengthandoutput_strengthto HIGH - This is HCL config, reviewed not run
💡 Hint: Map each boto3 kwarg to its HCL attribute (camelCase → snake_case); the nested filter mirrors filtersConfig.
Show solution
IaC makes the guardrail reproducible and reviewable. This is the Terraform equivalent of create_guardrail.
resource "aws_bedrock_guardrail" "support" {
name = "support-guardrail"
blocked_input_messaging = "I can't help with that."
blocked_outputs_messaging = "Response withheld by policy."
content_policy_config {
filters_config {
type = "HATE"
input_strength = "HIGH"
output_strength = "HIGH"
}
}
}
Context: An agent or KB needs a role, and the difference between safe and dangerous is scope. Least privilege means the trust policy names exactly who may assume the role and the permission policy names exactly one action on one resource ARN — nothing wildcarded.
Your task: Write an aws_iam_role that only bedrock.amazonaws.com can assume, plus an aws_iam_role_policy granting just bedrock:InvokeModel on a single model ARN.
Requirements:
- Trust policy allows
sts:AssumeRoleonly for the Bedrock service principal - Permission policy allows a single action:
bedrock:InvokeModel Resourceis one specific foundation-model ARN, not a wildcard- Use
jsonencodefor both policy documents - HCL config, reviewed not run
💡 Hint: Two separate concerns: the assume-role policy answers who, the role policy answers what — keep both as narrow as the task allows.
Show solution
Least privilege: the trust policy names the Bedrock service, and the permission policy scopes the Action and Resource narrowly.
resource "aws_iam_role" "bedrock" {
name = "bedrock-invoke-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "bedrock.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "invoke" {
role = aws_iam_role.bedrock.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "bedrock:InvokeModel"
Resource = "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0"
}]
})
}
Context: A Knowledge Base ties together an embedding model, a vector store, and a role — and the order they must be created in is a dependency graph. Terraform derives that graph automatically from attribute references, so you never hand-order the creates.
Your task: Write an aws_bedrockagent_knowledge_base with a Titan embedding model and an OpenSearch Serverless store, referencing the role and collection by attribute rather than hard-coded strings.
Requirements:
knowledge_base_configurationisVECTORwith a Titanembedding_model_arnstorage_configurationisOPENSEARCH_SERVERLESS- Reference
role_arnand the collection ARN via resource attributes (e.g.aws_iam_role.kb.arn) - Provide the OpenSearch
field_mapping(vector, text, metadata fields) - HCL config, reviewed not run
💡 Hint: Prefer aws_iam_role.kb.arn-style references over literal ARNs — that is what lets Terraform order the creates for you.
Show solution
Terraform builds a dependency graph from references like role_arn = aws_iam_role.kb.arn, so ordering is automatic.
resource "aws_bedrockagent_knowledge_base" "kb" {
name = "support-kb"
role_arn = aws_iam_role.kb.arn
knowledge_base_configuration {
type = "VECTOR"
vector_knowledge_base_configuration {
embedding_model_arn = "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0"
}
}
storage_configuration {
type = "OPENSEARCH_SERVERLESS"
opensearch_serverless_configuration {
collection_arn = aws_opensearchserverless_collection.kb.arn
vector_index_name = "kb-index"
field_mapping {
vector_field = "v"
text_field = "t"
metadata_field = "m"
}
}
}
}
Context: Some shops standardize on CDK rather than Terraform. CDK synthesizes CloudFormation, and its L1 constructs mirror the raw resource shape: the same guardrail becomes an aws_bedrock.CfnGuardrail with nested property classes standing in for the HCL blocks.
Your task: Express the beginner guardrail as an aws_bedrock.CfnGuardrail construct inside a CDK Stack subclass in Python.
Requirements:
- Define a
Stacksubclass whose__init__instantiates the construct - Use
CfnGuardrailwithnameand the two blocked-messaging arguments - Nest
ContentPolicyConfigPropertycontaining aContentFilterConfigPropertyfor HATE at HIGH - Property-class nesting mirrors the Terraform blocks one-to-one
- Python CDK config, reviewed not run
💡 Hint: L1 (Cfn*) constructs are the closest thing to raw CloudFormation — the nested *Property classes replace the HCL sub-blocks.
Show solution
CDK generates CloudFormation; nested L1 property classes mirror the Terraform blocks.
from aws_cdk import Stack, aws_bedrock as bedrock
from constructs import Construct
class AiStack(Stack):
def __init__(self, scope: Construct, cid: str, **kw):
super().__init__(scope, cid, **kw)
bedrock.CfnGuardrail(
self, "SupportGuardrail",
name="support-guardrail",
blocked_input_messaging="I can't help with that.",
blocked_outputs_messaging="Response withheld by policy.",
content_policy_config=bedrock.CfnGuardrail.ContentPolicyConfigProperty(
filters_config=[
bedrock.CfnGuardrail.ContentFilterConfigProperty(
type="HATE", input_strength="HIGH", output_strength="HIGH"),
],
),
)
Context: When many filters share a shape, hand-writing each HCL block invites copy-paste drift. Generating the filters_config blocks from a Python spec keeps a single source of truth and turns policy edits into data edits.
Your task: Write an offline filters_hcl(filters) generator that turns a list of content-filter dicts into the corresponding filters_config HCL blocks.
Requirements:
- Input is a list of dicts with type and input/output strengths
- Emit one
filters_config { ... }block per filter - Quote string values correctly inside the generated HCL
- Join the blocks into one string suitable to drop into a resource
- Runs offline; verify by printing the output for a two-filter spec
💡 Hint: Build each block with an f-string and remember to escape the embedded quotes so the emitted HCL is syntactically valid.
Show solution
Templating IaC from data keeps a single source of truth and avoids copy-paste drift.
def filters_hcl(filters):
blocks = []
for f in filters:
blocks.append(
" filters_config {\n"
f" type = \"{f['type']}\"\n"
f" input_strength = \"{f['in']}\"\n"
f" output_strength = \"{f['out']}\"\n"
" }")
return "\n".join(blocks)
spec = [{"type":"HATE","in":"HIGH","out":"HIGH"},
{"type":"VIOLENCE","in":"MEDIUM","out":"HIGH"}]
print(filters_hcl(spec))
Context: Platform teams must ship byte-identical guardrail+KB stacks to dev, stage, and prod, and catch anything changed out-of-band. IaC's whole value is that desired state is code; a drift check flags when reality no longer matches it.
Your task: Model an offline drift(desired, actual) checker that compares a deployed config dict against the IaC-declared desired state and returns the differing keys.
Requirements:
- Iterate the desired config and compare each key against the actual config
- For any mismatch, record both the desired and actual values
- Keys present in desired but missing from actual count as drift
- Return an empty result (and report in-sync) when everything matches
- Demonstrate a drifted field and a clean run; runs offline
💡 Hint: A dict of {key: {desired, actual}} for mismatches makes the report self-explanatory; emptiness means in-sync.
Show solution
IaC's value is that desired state is code; a drift check flags anything changed out-of-band.
def drift(desired, actual):
diffs = {}
for k, v in desired.items():
if actual.get(k) != v:
diffs[k] = {"desired": v, "actual": actual.get(k)}
return diffs
desired = {"guardrail_name": "house", "hate_strength": "HIGH",
"embedding_model": "amazon.titan-embed-text-v2:0"}
actual = {"guardrail_name": "house", "hate_strength": "MEDIUM", # drifted!
"embedding_model": "amazon.titan-embed-text-v2:0"}
d = drift(desired, actual)
print(d) # {'hate_strength': {'desired':'HIGH','actual':'MEDIUM'}}
print("drift detected" if d else "in sync")
✓ Checkpoint — you can move on when you can…
- Explain why AWS AI resources specifically benefit from IaC (dependency order, reproducibility).
- Write a Terraform resource for a guardrail and a KB.
- Write the least-privilege IAM role a KB needs.
- Express the same stack in CDK and pick one tool for a team.
Knowledge check check yourself
The boto3 KB setup in W4 was tripped up by provisioning order. How does Terraform solve this, and what expresses the dependency in the code?
Show answer
What does least privilege mean for the KB execution role in the example, and how is it enforced?