Quick take — Build a reusable Terraform module that provisions an Amazon Bedrock agent (aws_bedrockagent_agent) pre-attached to a content-filtering guardrail, so every GenAI deployment ships with safety controls by default. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "bedrock" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-bedrock?ref=v1.0.0"
name = "..." # Base name for the agent, guardrail and IAM role (1-40 c…
instruction = "..." # System instruction for the agent (min 40 chars).
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Amazon Bedrock is AWS’s fully-managed service for building generative-AI applications on top of foundation models (FMs) from Anthropic, Meta, Mistral, Amazon (Nova/Titan) and others — without provisioning a single GPU. A Bedrock agent goes a step further than raw model inference: it takes natural-language instructions, reasons over them, and orchestrates calls to action groups, knowledge bases and APIs to actually do something on a user’s behalf.
The problem with rolling Bedrock out across an organisation is that an agent on its own has no opinion about what it should refuse to say. That is the job of a Bedrock Guardrail — a policy layer that filters harmful content, blocks denied topics, masks PII, and rejects prompt-injection attempts before and after the model runs. In practice these two resources belong together: a naked agent is a compliance incident waiting to happen.
This module wires aws_bedrockagent_agent and aws_bedrock_guardrail into one opinionated unit. It creates the guardrail with sensible content-filter strengths and PII handling, publishes a guardrail version, provisions the agent against a chosen foundation model with a least-privilege IAM execution role, and associates the published guardrail version to the agent. The result is that any team consuming the module gets a governed agent — you cannot accidentally ship one without safety controls.
When to use it
- You are standardising GenAI on AWS and want every Bedrock agent in the estate to carry a baseline guardrail (denied topics, PII masking, prompt-attack filtering) with no opt-out.
- A platform/landing-zone team owns the safety policy centrally, while product teams only supply the agent’s
instruction, model ID and resource names. - You need the agent, its IAM execution role, and a versioned guardrail created and associated atomically, so drift between “agent exists” and “guardrail attached” is impossible.
- You want guardrail and agent configuration captured in code for audit (SOC 2 / ISO 27001 evidence) rather than clicked together in the console.
Reach for a simpler aws_bedrock_model_invocation_logging_configuration plus direct InvokeModel calls if you only need raw inference. This module is for the agentic, governed path.
Module structure
terraform-module-aws-bedrock/
├── versions.tf # provider + Terraform version pins
├── main.tf # guardrail, guardrail version, IAM role, agent, association
├── variables.tf # var-driven inputs with validations
└── outputs.tf # agent + guardrail identifiers and ARNs
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
locals {
# Bedrock agents need an execution role whose name begins with this prefix.
agent_role_name = "AmazonBedrockExecutionRoleForAgents_${var.name}"
model_arn = "arn:aws:bedrock:${data.aws_region.current.name}::foundation-model/${var.foundation_model}"
}
# ---------------------------------------------------------------------------
# Guardrail: the safety policy applied to the agent.
# ---------------------------------------------------------------------------
resource "aws_bedrock_guardrail" "this" {
name = "${var.name}-guardrail"
description = "Content + PII guardrail for the ${var.name} Bedrock agent."
blocked_input_messaging = var.blocked_input_messaging
blocked_outputs_messaging = var.blocked_output_messaging
kms_key_arn = var.kms_key_arn
content_policy_config {
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
action = var.pii_action
}
}
}
}
dynamic "topic_policy_config" {
for_each = length(var.denied_topics) > 0 ? [1] : []
content {
dynamic "topics_config" {
for_each = var.denied_topics
content {
name = topics_config.value.name
definition = topics_config.value.definition
examples = topics_config.value.examples
type = "DENY"
}
}
}
}
tags = var.tags
}
# A guardrail must be versioned before an agent can reference a stable version.
resource "aws_bedrock_guardrail_version" "this" {
guardrail_arn = aws_bedrock_guardrail.this.guardrail_arn
description = "Published version managed by Terraform."
# Re-publish whenever the guardrail policy changes.
lifecycle {
create_before_destroy = true
}
}
# ---------------------------------------------------------------------------
# IAM execution role assumed by the agent at runtime.
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "agent_trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["bedrock.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "aws:SourceAccount"
values = [data.aws_caller_identity.current.account_id]
}
}
}
data "aws_iam_policy_document" "agent_permissions" {
statement {
sid = "InvokeFoundationModel"
effect = "Allow"
actions = ["bedrock:InvokeModel"]
resources = [local.model_arn]
}
statement {
sid = "ApplyGuardrail"
effect = "Allow"
actions = ["bedrock:ApplyGuardrail"]
resources = [aws_bedrock_guardrail.this.guardrail_arn]
}
}
resource "aws_iam_role" "agent" {
name = local.agent_role_name
assume_role_policy = data.aws_iam_policy_document.agent_trust.json
tags = var.tags
}
resource "aws_iam_role_policy" "agent" {
name = "${var.name}-agent-inline"
role = aws_iam_role.agent.id
policy = data.aws_iam_policy_document.agent_permissions.json
}
# ---------------------------------------------------------------------------
# The Bedrock agent itself, with the published guardrail attached.
# ---------------------------------------------------------------------------
resource "aws_bedrockagent_agent" "this" {
agent_name = var.name
agent_resource_role_arn = aws_iam_role.agent.arn
foundation_model = var.foundation_model
instruction = var.instruction
description = var.description
idle_session_ttl_in_seconds = var.idle_session_ttl_in_seconds
prepare_agent = var.prepare_agent
guardrail_configuration {
guardrail_identifier = aws_bedrock_guardrail.this.guardrail_id
guardrail_version = aws_bedrock_guardrail_version.this.version
}
tags = var.tags
}
variables.tf
variable "name" {
description = "Base name for the agent and derived resources (guardrail, IAM role)."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9_-]{1,40}$", var.name))
error_message = "name must be 1-40 chars: letters, numbers, hyphen or underscore."
}
}
variable "instruction" {
description = "System instruction telling the agent its role and behaviour (min 40 chars per the Bedrock API)."
type = string
validation {
condition = length(var.instruction) >= 40
error_message = "instruction must be at least 40 characters."
}
}
variable "description" {
description = "Human-readable description of the agent."
type = string
default = "Bedrock agent provisioned via Terraform."
}
variable "foundation_model" {
description = "Bedrock foundation model ID the agent runs on (e.g. anthropic.claude-3-5-sonnet-20241022-v2:0)."
type = string
default = "anthropic.claude-3-5-sonnet-20241022-v2:0"
}
variable "idle_session_ttl_in_seconds" {
description = "How long Bedrock retains an idle session before timing it out."
type = number
default = 600
validation {
condition = var.idle_session_ttl_in_seconds >= 60 && var.idle_session_ttl_in_seconds <= 3600
error_message = "idle_session_ttl_in_seconds must be between 60 and 3600."
}
}
variable "prepare_agent" {
description = "Whether Terraform should prepare (build) the agent after changes so a DRAFT alias is usable."
type = bool
default = true
}
variable "content_filters" {
description = "Content-policy filters applied by the guardrail. Strengths: NONE, LOW, MEDIUM, HIGH."
type = list(object({
type = string
input_strength = string
output_strength = string
}))
default = [
{ type = "HATE", input_strength = "HIGH", output_strength = "HIGH" },
{ type = "INSULTS", input_strength = "HIGH", output_strength = "HIGH" },
{ type = "SEXUAL", input_strength = "HIGH", output_strength = "HIGH" },
{ type = "VIOLENCE", input_strength = "HIGH", output_strength = "HIGH" },
{ type = "MISCONDUCT", input_strength = "MEDIUM", output_strength = "MEDIUM" },
{ type = "PROMPT_ATTACK", input_strength = "HIGH", output_strength = "NONE" }
]
validation {
condition = alltrue([
for f in var.content_filters :
contains(["NONE", "LOW", "MEDIUM", "HIGH"], f.input_strength) &&
contains(["NONE", "LOW", "MEDIUM", "HIGH"], f.output_strength)
])
error_message = "Each filter strength must be one of NONE, LOW, MEDIUM, HIGH."
}
}
variable "pii_entities" {
description = "PII entity types the guardrail acts on (e.g. EMAIL, PHONE, CREDIT_DEBIT_CARD_NUMBER, US_SOCIAL_SECURITY_NUMBER)."
type = list(string)
default = ["EMAIL", "PHONE", "CREDIT_DEBIT_CARD_NUMBER", "US_SOCIAL_SECURITY_NUMBER"]
}
variable "pii_action" {
description = "What the guardrail does with detected PII: ANONYMIZE (mask) or BLOCK (reject the request)."
type = string
default = "ANONYMIZE"
validation {
condition = contains(["ANONYMIZE", "BLOCK"], var.pii_action)
error_message = "pii_action must be ANONYMIZE or BLOCK."
}
}
variable "denied_topics" {
description = "Topics the agent must refuse to engage with."
type = list(object({
name = string
definition = string
examples = list(string)
}))
default = []
}
variable "blocked_input_messaging" {
description = "Message returned to the caller when an input is blocked by the guardrail."
type = string
default = "Sorry, your request was blocked by our usage policy."
}
variable "blocked_output_messaging" {
description = "Message returned when a model response is blocked by the guardrail."
type = string
default = "Sorry, the response was withheld by our usage policy."
}
variable "kms_key_arn" {
description = "Optional customer-managed KMS key ARN for encrypting the guardrail. Null = AWS-owned key."
type = string
default = null
}
variable "tags" {
description = "Tags applied to all taggable resources."
type = map(string)
default = {}
}
outputs.tf
output "agent_id" {
description = "Unique ID of the Bedrock agent."
value = aws_bedrockagent_agent.this.agent_id
}
output "agent_arn" {
description = "ARN of the Bedrock agent."
value = aws_bedrockagent_agent.this.agent_arn
}
output "agent_name" {
description = "Name of the Bedrock agent."
value = aws_bedrockagent_agent.this.agent_name
}
output "agent_version" {
description = "Currently prepared version of the agent (e.g. DRAFT)."
value = aws_bedrockagent_agent.this.agent_version
}
output "agent_role_arn" {
description = "ARN of the IAM execution role assumed by the agent."
value = aws_iam_role.agent.arn
}
output "guardrail_id" {
description = "ID of the guardrail attached to the agent."
value = aws_bedrock_guardrail.this.guardrail_id
}
output "guardrail_arn" {
description = "ARN of the guardrail."
value = aws_bedrock_guardrail.this.guardrail_arn
}
output "guardrail_version" {
description = "Published guardrail version associated with the agent."
value = aws_bedrock_guardrail_version.this.version
}
How to use it
module "bedrock" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-bedrock?ref=v1.0.0"
name = "support-copilot"
foundation_model = "anthropic.claude-3-5-sonnet-20241022-v2:0"
instruction = <<-EOT
You are a customer-support assistant for an e-commerce platform. Answer questions
about orders, returns and shipping. Never reveal internal pricing logic, and never
process refunds above 5000 INR without escalating to a human agent.
EOT
# Tighten the centrally-owned safety baseline for this workload.
pii_action = "ANONYMIZE"
denied_topics = [
{
name = "Legal advice"
definition = "Requests for binding legal opinions or representation."
examples = ["Can I sue the seller?", "Draft a legal notice for me."]
}
]
tags = {
Environment = "prod"
Team = "customer-experience"
CostCenter = "cx-genai"
}
}
# Downstream: create a stable, named alias that your application invokes,
# pointing at the agent this module produced.
resource "aws_bedrockagent_agent_alias" "live" {
agent_alias_name = "live"
agent_id = module.bedrock.agent_id
description = "Production alias for the support copilot."
}
output "support_agent_endpoint" {
description = "Agent + alias the support app calls via InvokeAgent."
value = "${module.bedrock.agent_id}:${aws_bedrockagent_agent_alias.live.agent_alias_id}"
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/bedrock/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-bedrock?ref=v1.0.0"
}
inputs = {
name = "..."
instruction = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/bedrock && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
| name | string | — | Yes | Base name for the agent, guardrail and IAM role (1-40 chars, [a-zA-Z0-9_-]). |
| instruction | string | — | Yes | System instruction for the agent (min 40 chars). |
| description | string | "Bedrock agent provisioned via Terraform." |
No | Human-readable agent description. |
| foundation_model | string | "anthropic.claude-3-5-sonnet-20241022-v2:0" |
No | Foundation model ID the agent runs on. |
| idle_session_ttl_in_seconds | number | 600 |
No | Idle-session timeout (60-3600s). |
| prepare_agent | bool | true |
No | Build the agent after changes so its DRAFT alias is usable. |
| content_filters | list(object) | HATE/INSULTS/SEXUAL/VIOLENCE/MISCONDUCT/PROMPT_ATTACK | No | Content-policy filters and per-direction strengths (NONE/LOW/MEDIUM/HIGH). |
| pii_entities | list(string) | ["EMAIL","PHONE","CREDIT_DEBIT_CARD_NUMBER","US_SOCIAL_SECURITY_NUMBER"] |
No | PII entity types the guardrail acts on. |
| pii_action | string | "ANONYMIZE" |
No | ANONYMIZE (mask) or BLOCK detected PII. |
| denied_topics | list(object) | [] |
No | Topics the agent must refuse, each with name/definition/examples. |
| blocked_input_messaging | string | "Sorry, your request was blocked by our usage policy." |
No | Reply when an input is blocked. |
| blocked_output_messaging | string | "Sorry, the response was withheld by our usage policy." |
No | Reply when an output is blocked. |
| kms_key_arn | string | null |
No | Customer-managed KMS key for the guardrail; null uses an AWS-owned key. |
| tags | map(string) | {} |
No | Tags applied to all taggable resources. |
Outputs
| Name | Description |
|---|---|
| agent_id | Unique ID of the Bedrock agent. |
| agent_arn | ARN of the Bedrock agent. |
| agent_name | Name of the Bedrock agent. |
| agent_version | Currently prepared agent version (e.g. DRAFT). |
| agent_role_arn | ARN of the agent’s IAM execution role. |
| guardrail_id | ID of the attached guardrail. |
| guardrail_arn | ARN of the guardrail. |
| guardrail_version | Published guardrail version associated with the agent. |
Enterprise scenario
A retail bank’s platform team publishes this module at v1.0.0 with the content filters and PII baseline locked to HIGH/BLOCK to satisfy regulatory data-handling rules. Each product squad — mortgages, cards, onboarding — instantiates the module with only its own instruction and denied_topics (for example, the cards team denies “investment advice”), so a dozen Bedrock agents go live with identical, audited safety controls. When the bank’s risk function later adds a new denied topic, the team bumps the module to v1.1.0; because the guardrail is versioned and associated in code, every agent picks up the change through a normal terraform apply with a clear plan diff and a CloudTrail record.
Best practices
- Pin the model and the guardrail version explicitly. Foundation model IDs (e.g.
anthropic.claude-3-5-sonnet-20241022-v2:0) and the publishedguardrail_versionshould be pinned, not floated, so a model deprecation or guardrail edit never silently changes agent behaviour in production. - Keep the execution role least-privilege. The inline policy here grants only
bedrock:InvokeModelon the specific model ARN andbedrock:ApplyGuardrailon the specific guardrail — scope it further when you add action groups or knowledge bases rather than reaching forbedrock:*. - Control cost at the model tier. Bedrock bills per input/output token; default product squads to a cheaper model (Claude Haiku or Amazon Nova Lite) and reserve Sonnet-class models for workloads that genuinely need the reasoning, and set a realistic
idle_session_ttl_in_secondsto avoid paying for stale context. - Enable model-invocation logging separately and ship it to CloudWatch/S3. The guardrail blocks bad content, but you still need
aws_bedrock_model_invocation_logging_configurationfor an audit trail of prompts and completions — pair it with this module in the same landing zone. - Use a customer-managed KMS key for regulated data by passing
kms_key_arn, so guardrail configuration and any retained data are encrypted under a key whose rotation and access you control. - Standardise naming and tagging through
nameandtags. Deriving the guardrail and IAM role names from a singlenamekeeps resources discoverable, and mandatoryCostCenter/Environmenttags make per-team Bedrock spend attributable in Cost Explorer.