AI EngineeringZero to ProductionHome·About·Contact
AWS AI Automation · Chapter W7

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.

⏱️ ~2 hours🧪 3 labs🎯 Advanced
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need:
  • 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
Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.

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.
▶ Runnable companionEvery code block in this lesson is also a standalone file under 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

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Lab W7.1
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"
      }
    }
  }
}
▶ How this works

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.

  1. 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. Here aws_bedrock_guardrail nicknamed support is the guardrail.
  2. Inside the guardrail, content_policy_config turns on a filter: block HATE content at HIGH strength on both the user's input and the model's output. sensitive_information_policy_config tells it to ANONYMIZE (hide) any EMAIL it detects.
  3. The second resource, aws_bedrockagent_knowledge_base nicknamed docs, is the document store. role_arn = aws_iam_role.kb.arn hands it a set of permissions — that aws_iam_role.kb is defined in the next lab; Terraform will link them automatically.
  4. knowledge_base_configuration picks the embedding model (the AI that turns text into searchable number-vectors), and storage_configuration points at an OpenSearch collection to hold those vectors. field_mapping just 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.

Terraform resolves the dependency orderNotice 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

Lab W7.2
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/*" },
    ]
  })
}
▶ How this works

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.

  1. The first resource, aws_iam_role nicknamed kb, creates the badge itself. Its assume_role_policy answers who is allowed to wear it: only the bedrock.amazonaws.com service. jsonencode({...}) just converts the HCL block into the JSON text AWS expects.
  2. The second resource, aws_iam_role_policy, attaches the actual permissions to that badge via role = aws_iam_role.kb.id (linking back to the role above).
  3. Each item in Statement is one permission: Effect = "Allow", a list of Actions, and the Resource they 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).
  4. 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.

Lab W7.3
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
▶ How this works

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.

  1. The import lines pull in CDK's building blocks — Stack and the Bedrock resource library.
  2. class AiStack(Stack): defines your stack by inheriting from CDK's Stack. 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".
  3. bedrock.CfnGuardrail(self, "SupportGuardrail", ...) creates the guardrail. self attaches 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.
  4. The ContentPolicyConfigProperty/ContentFilterConfigProperty objects are CDK's typed way of nesting the HATE filter — the Python equivalent of the nested content_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/diff before applying
  • After apply, confirm the live resources match the declarations
  • destroy tears the stack down cleanly
  • A second apply reproduces 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.

Exercise 1 · A guardrail in TerraformBeginner

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_guardrail resource block
  • Set blocked_input_messaging and blocked_outputs_messaging
  • One content_policy_config with a filters_config for HATE
  • Filter sets input_strength and output_strength to 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"
    }
  }
}
Exercise 2 · A least-privilege IAM roleIntermediate

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:AssumeRole only for the Bedrock service principal
  • Permission policy allows a single action: bedrock:InvokeModel
  • Resource is one specific foundation-model ARN, not a wildcard
  • Use jsonencode for 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"
    }]
  })
}
Exercise 3 · A Knowledge Base resourceAdvanced

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_configuration is VECTOR with a Titan embedding_model_arn
  • storage_configuration is OPENSEARCH_SERVERLESS
  • Reference role_arn and 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"
      }
    }
  }
}
Exercise 4 · The same guardrail in CDK (Python)Expert

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 Stack subclass whose __init__ instantiates the construct
  • Use CfnGuardrail with name and the two blocked-messaging arguments
  • Nest ContentPolicyConfigProperty containing a ContentFilterConfigProperty for 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"),
                ],
            ),
        )
Exercise 5 · Generate HCL from a policy specProfessional

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))
Exercise 6 · Reproducible multi-env stack with a drift checkIndustry scenario

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

✓ Knowledge check

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
Terraform builds a dependency graph from references like role_arn = aws_iam_role.kb.arn and the OpenSearch collection reference, so it creates the IAM role and collection before the KB automatically -- you don't hand-order the steps.
✓ Knowledge check

What does least privilege mean for the KB execution role in the example, and how is it enforced?

Show answer
The role grants only the three abilities the KB actually needs: read the S3 data source (s3:GetObject/ListBucket), call the embedding model (bedrock:InvokeModel), and write to the OpenSearch collection (aoss:APIAccessAll). There is no 'allow everything' statement, so any ungranted action (e.g. deleting a bucket) is refused.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in