IaC AWS

Terraform Module: AWS Bedrock — a governed agent with guardrails baked in

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

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 configlive/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 configlive/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

TerraformAWSBedrockModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading