Module 3: Infrastructure As Code For An Ai Endpoint
3. Hands-on: the `modules/bedrock-guardrail/` module
Description
Lesson 2 extracted aws_bedrock_guardrail's real schema. This lesson turns it into this guide's first reusable mold: modules/bedrock-guardrail/, the exact same pattern as terraform-and-iac-guide's modules/s3-bucket/ and modules/iam-role/ — three files (main.tf, variables.tf, outputs.tf), inputs with safe default values, validated in isolation before installing it in the real project. Everything that runs in this lesson ran for real, in this environment, while writing it.
Connection to the module
This mold is the literal foundation of Module 4: it implements two of the schema's five policies (content, sensitive information) as list inputs, leaving the other three (topics, contextual grounding, words) for Module 4, lesson 3 to add without touching what already works here — exactly the same grow-without-breaking principle terraform-and-iac-guide already demonstrated with modules/s3-bucket/.
Step 1 — modules/bedrock-guardrail/, file by file
Inside andes-cargo-infra/, create the modules/bedrock-guardrail/ folder with the three files from the standard structure.
modules/bedrock-guardrail/variables.tf — the mold's inputs:
variable "name" {
description = "Name of the Bedrock guardrail."
type = string
}
variable "blocked_input_messaging" {
description = "Message returned to the caller when an input is blocked by the guardrail."
type = string
}
variable "blocked_outputs_messaging" {
description = "Message returned to the caller when a model output is blocked by the guardrail."
type = string
}
variable "content_filters" {
description = "Content policy filters (e.g. PROMPT_ATTACK, HATE, SEXUAL). Each entry sets input/output detection strength."
type = list(object({
type = string
input_strength = string
output_strength = string
}))
default = []
}
variable "pii_entities" {
description = "PII entity types to detect in the sensitive information policy, with the action to take on each (BLOCK or ANONYMIZE)."
type = list(object({
type = string
action = string
}))
default = []
}
variable "tags" {
description = "Tags applied to the guardrail."
type = map(string)
default = {}
}
Three things to understand, all deliberate. 1) name, blocked_input_messaging, and blocked_outputs_messaging are required — no default —, exactly because lesson 2's schema marks them required at the resource's own level; no guardrail exists without those three values. 2) content_filters and pii_entities are lists of typed objects, with default = [] — the simplest case ("no active policy yet") is an empty list, not null, because main.tf's dynamic pattern (next step) needs something to iterate over, even if it's nothing. 3) Every object in those lists uses exactly the field names the real schema demands inside filters_config and pii_entities_config — type, input_strength, output_strength for one; type, action for the other —, the same "the real schema, never invented" discipline lesson 2 established.
modules/bedrock-guardrail/main.tf — the mold itself:
resource "aws_bedrock_guardrail" "this" {
name = var.name
blocked_input_messaging = var.blocked_input_messaging
blocked_outputs_messaging = var.blocked_outputs_messaging
dynamic "content_policy_config" {
for_each = length(var.content_filters) > 0 ? [1] : []
content {
dynamic "filters_config" {
for_each = var.content_filters
content {
type = filters_config.value.type
input_strength = filters_config.value.input_strength
output_strength = filters_config.value.output_strength
}
}
}
}
dynamic "sensitive_information_policy_config" {
for_each = length(var.pii_entities) > 0 ? [1] : []
content {
dynamic "pii_entities_config" {
for_each = var.pii_entities
content {
type = pii_entities_config.value.type
action = pii_entities_config.value.action
}
}
}
}
tags = var.tags
}
The new pattern here, one you haven't seen yet in modules/s3-bucket/ or modules/iam-role/, is the dynamic block nested twice: a dynamic "content_policy_config" that exists only if var.content_filters has at least one element ([1] : [], the same "create it or not" trick you already know from count, applied to a block instead of a whole resource), and inside that block, a second dynamic "filters_config" that iterates over each filter in the list. This is exactly what's needed to translate a list of Terraform objects (var.content_filters) into multiple repeated nested blocks inside the same resource — something count or for_each at the whole-resource level can't do, because here you're not creating multiple resources, you're creating multiple blocks inside the same resource.
modules/bedrock-guardrail/outputs.tf — what the module exposes:
output "guardrail_arn" {
description = "ARN of the created guardrail."
value = aws_bedrock_guardrail.this.guardrail_arn
}
output "guardrail_id" {
description = "ID of the created guardrail."
value = aws_bedrock_guardrail.this.guardrail_id
}
output "name" {
description = "Name of the created guardrail."
value = aws_bedrock_guardrail.this.name
}
Step 2 — Validating the mold in isolation
Exactly as terraform-and-iac-guide Module 5, lesson 6 did with modules/s3-bucket/: before installing this module in andes-cargo-infra/, confirm it in a separate validation folder, with its own minimal provider block:
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "us-east-1"
access_key = "test"
secret_key = "test"
s3_use_path_style = true
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
endpoints {
s3 = "http://localhost:4566"
}
}
module "smoke_test_guardrail" {
source = "./modules/bedrock-guardrail"
name = "andes-cargo-module-smoke-test-guardrail"
blocked_input_messaging = "This input is not allowed due to content policy violations."
blocked_outputs_messaging = "This output is not allowed due to content policy violations."
content_filters = [
{
type = "PROMPT_ATTACK"
input_strength = "HIGH"
output_strength = "NONE"
}
]
pii_entities = [
{
type = "EMAIL"
action = "ANONYMIZE"
}
]
tags = {
Project = "andes-cargo"
Environment = "dev"
ManagedBy = "terraform"
}
}
terraform init -input=false
What to expect (literal — executed in this environment while writing this lesson):
Initializing the backend...
Initializing modules...
- smoke_test_guardrail in modules/bedrock-guardrail
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 6.0"...
- Installing hashicorp/aws v6.60.0...
- Installed hashicorp/aws v6.60.0 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Terraform has been successfully initialized!
Step 3 — terraform fmt, finding a real misalignment
terraform fmt -check -recursive -diff
What to expect (literal — this lesson's first run, with the file exactly as written above):
main.tf
--- old/main.tf
+++ new/main.tf
@@ -26,9 +26,9 @@
module "smoke_test_guardrail" {
source = "./modules/bedrock-guardrail"
- name = "andes-cargo-module-smoke-test-guardrail"
- blocked_input_messaging = "This input is not allowed due to content policy violations."
- blocked_outputs_messaging = "This output is not allowed due to content policy violations."
+ name = "andes-cargo-module-smoke-test-guardrail"
+ blocked_input_messaging = "This input is not allowed due to content policy violations."
+ blocked_outputs_messaging = "This output is not allowed due to content policy violations."
content_filters = [
{
This isn't a file made up on purpose to demonstrate the command — it's the real misalignment writing this lesson's main.tf with hand-counted spaces instead of letting fmt calculate them produced. terraform fmt -check returns exit code 3 when it finds differences (confirm it with echo $? after the command) — the same nonzero-exit-code-means-"work pending" pattern you already saw in other tools in this ecosystem.
terraform fmt -recursive
terraform fmt -check -recursive
echo "exit: $?"
What to expect (literal):
main.tf
exit: 0
The first line confirms which file fmt rewrote; the second run — now without -diff, just -check — prints nothing because there are no more differences, and the exit code 0 confirms it.
Step 4 — terraform validate, and the isolated module's complete plan
terraform validate
What to expect (literal):
Success! The configuration is valid.
terraform plan -input=false -no-color
What to expect (literal — run for real, with no LocalStack running, no AWS account):
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# module.smoke_test_guardrail.aws_bedrock_guardrail.this will be created
+ resource "aws_bedrock_guardrail" "this" {
+ blocked_input_messaging = "This input is not allowed due to content policy violations."
+ blocked_outputs_messaging = "This output is not allowed due to content policy violations."
+ created_at = (known after apply)
+ description = (known after apply)
+ guardrail_arn = (known after apply)
+ guardrail_id = (known after apply)
+ name = "andes-cargo-module-smoke-test-guardrail"
+ region = "us-east-1"
+ status = (known after apply)
+ tags = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
+ "Project" = "andes-cargo"
}
+ tags_all = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
+ "Project" = "andes-cargo"
}
+ updated_at = (known after apply)
+ version = (known after apply)
+ content_policy_config {
+ tier_config = (known after apply)
+ filters_config {
+ input_strength = "HIGH"
+ output_strength = "NONE"
+ type = "PROMPT_ATTACK"
}
}
+ sensitive_information_policy_config {
+ pii_entities_config {
+ action = "ANONYMIZE"
+ input_action = (known after apply)
+ input_enabled = (known after apply)
+ output_action = (known after apply)
+ output_enabled = (known after apply)
+ type = "EMAIL"
}
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
Plan: 1 to add — a single resource, with both active policies shown exactly as main.tf's dynamic pattern translated them: content_policy_config.filters_config with the PROMPT_ATTACK filter, sensitive_information_policy_config.pii_entities_config with the EMAIL entity. Notice that input_action, input_enabled, output_action, and output_enabled from pii_entities_config show up as (known after apply): the module didn't declare them (they're optional, computed in lesson 2's schema), so AWS fills them with their real default values only when the resource is actually created — another confirmation that this plan, however complete, never needed to consult anything outside the declared HCL.
Step 5 — Installing the module in andes-cargo-infra/
With the mold already tested, lesson 4 installs it in the real project, inside bedrock.tf, alongside the least-privilege IAM role. Before that, confirm that the module itself — copied as is, with no changes — also passes fmt and validate inside the complete project:
cd andes-cargo-infra/
terraform fmt -check -recursive
terraform validate
What to expect (literal):
Success! The configuration is valid.
No output from fmt -check (exit code 0) — the module, already formatted in Step 3, enters the real project with no additional adjustment needed.
Common mistakes
Forgetting default = [] on content_filters/pii_entities, forcing every module call to declare both lists even when it doesn't need them (rigid-input-design mistake). What happens: someone removes the default from one of the two variables, thinking "something always needs to be declared." How to spot it: if a terraform plan that only needs the content policy (no PII) fails with The argument "pii_entities" is required, but no definition was found. How to fix it: the correct pattern, already used by modules/s3-bucket/ with bucket_policy_json = null and by this module with default = [], is for every policy to be genuinely optional — the simplest possible case (no list declared) must work with no error.
Using count instead of dynamic to repeat filters_config inside the same content_policy_config (confusing "repeating a resource" with "repeating a block" mistake). What happens: someone, familiar with the count = var.something ? 1 : 0 pattern from modules/s3-bucket/, tries applying count directly inside a nested block. How to spot it: a Terraform error like Blocks of type "filters_config" are not expected here, or syntax that doesn't even compile. How to fix it: count and for_each at the resource level create or don't create entire resources (separate instances in the state, each with its own index); dynamic creates or doesn't create blocks inside a single resource — different mechanisms for different problems, and filters_config repeated several times inside a single aws_bedrock_guardrail is, precisely, the second case.
Passing a filter type with a value that doesn't exist in Bedrock's real catalog (PROMPT_ATTACK, HATE, SEXUAL, VIOLENCE, INSULTS, MISCONDUCT) and expecting terraform validate to catch it (expectation-about-what-a-schema-validates mistake). What happens: someone writes type = "SPAM" — a value that isn't part of Bedrock's real content-filter type catalog — and expects terraform validate to reject it. How to spot it: validate passes with no complaint, because type, in the provider's schema, is simply string — there's no closed list of valid values encoded there. How to fix it: terraform validate confirms types (is it a string? is the block properly nested?), not valid business values for Bedrock's real API. An invalid type would pass validate and plan with no error, and would only fail on a real apply against Bedrock's real API (outside this $0 lab's scope) — the same distinction this module's lesson 1 already established between "correct syntax" and "actually works."
Exercises
Exercise 1 — Explain, without looking at main.tf, what the [1] : [] in dynamic "content_policy_config"'s for_each is for. A colleague, seeing the code for the first time, asks why not simply use for_each = var.content_filters directly on the outer block.
See solution
Because content_policy_config and content_filters don't have the same cardinality: content_policy_config is a block that exists at most once per guardrail (it's where all content filters get grouped), while filters_config, inside it, repeats once per filter in the content_filters list. The outer block's for_each = length(var.content_filters) > 0 ? [1] : [] answers a binary question — is there at least one filter to declare? — and produces, at most, a single instance of content_policy_config. The inner dynamic's for_each = var.content_filters does genuinely iterate over the whole list, once per filter, inside that single outer block.
Exercise 2 — Predict the result of a terraform plan with content_filters = [] and pii_entities = [] (both empty lists, the default values). How many policy blocks would show up in the planned aws_bedrock_guardrail?
See solution
Zero. With both lists empty, the length(var.content_filters) > 0 condition and its PII equivalent evaluate to false, so both outer for_eachs produce [] — neither content_policy_config nor sensitive_information_policy_config gets declared at all. The result would be a valid aws_bedrock_guardrail (the three required arguments at the resource level — name, the two block messages — would still be present), but with no active content or sensitive-information policy — a guardrail that exists but filters nothing, the same case already named in lesson 2's Exercise 3.
Exercise 3 — Decide whether this module, as it stands, is enough to declare the denied-topics policy (topic_policy_config) Module 4 needs. Without writing new code, does the current module support it, need a minor extension, or need a rewrite?
See solution
It needs a minor extension, following exactly the same already-established pattern. The current module has no variable or dynamic block for topic_policy_config — adding it means a new variable "denied_topics" (a list of objects with name, definition, type, and optionally examples, following lesson 2's schema), plus a dynamic "topic_policy_config" in main.tf with the same double-nesting structure (outer conditional for_each, inner for_each over the list) already used by content_policy_config and sensitive_information_policy_config. Neither of the two already-built policies would need to change — exactly the payoff of having invested in the right dynamic structure from this lesson on, the same one you already saw with modules/s3-bucket/ in terraform-and-iac-guide.
Summary and next step
In this lesson you built modules/bedrock-guardrail/: a mold with two guardrail policies (content, sensitive information) as list inputs, using dynamic blocks nested twice to translate those lists into multiple repeated blocks inside the same resource — the first new Terraform pattern this guide needed, beyond what terraform-and-iac-guide already covered. You validated the module in isolation, with fmt (finding and fixing a real misalignment), validate, and plan — all three run for real, with no LocalStack, with a literal result of Plan: 1 to add.
Before moving on you should be able to: explain the difference between dynamic and resource-level count/for_each; write from memory the for_each = length(var.something) > 0 ? [1] : [] pattern for a conditional block; and explain why terraform validate doesn't catch an invalid type value for Bedrock's real API.
Lesson 4 builds this module's second piece: BedrockManifestExtractorRole, the least-privilege IAM role that gives extract-shipment-manifest-fields permission to invoke exactly one model, never the entire service.
Resources
- Terraform Language Docs —
dynamicblocks — official reference for this lesson's central mechanism. - Terraform Registry —
aws_bedrock_guardrail— the module's base resource, same schema cited in lesson 2. terraform-and-iac-guide, Module 5, lesson 6 (06-hands-on-building-an-s3-bucket-module.md) — the same isolated-validation pattern this lesson follows.- Terraform Docs — Command: fmt — official reference, including
-check's exit codes.