A Terraform Module for a Bedrock-Backed Lambda Function
10 min read · updated August 11, 2026
A Lambda function that calls Bedrock is three resources that only work as a set: the function, a role it assumes, and a policy naming the models it may invoke. Splitting them across a codebase is how you end up with a deployed function that returns AccessDeniedException on its first request. This is the module that keeps them together.
What the module owns
Keep the boundary tight. The module should own the function, the execution role, the inline invoke policy, the log group and nothing else. It should not own the VPC, the queue that triggers it, or the model choice — those belong to the caller, because the whole point of a module is that the same code produces a summarisation function and a classification function without editing the module.
That gives a small variable surface: a name, a list of model identifiers the function is allowed to invoke, a path to the packaged artefact, and the usual timeout and memory knobs. Everything else is derived.
# modules/bedrock-lambda/variables.tf
variable "name" {
type = string
description = "Function name; also prefixes the role and log group."
}
variable "model_ids" {
type = list(string)
description = "Foundation model or inference profile IDs this function may invoke."
}
variable "source_dir" {
type = string
}
variable "timeout" {
type = number
default = 60
}
variable "memory_size" {
type = number
default = 512
}The default timeout matters more here than in a typical module. AWS documents the Lambda default timeout as 3 seconds with a maximum of 900 seconds, in increments of one second. A single Bedrock generation call routinely runs longer than three seconds, so a module that leaves the default in place ships a function that times out mid-generation and looks like a Bedrock fault. Sixty is a defensible starting default; anything streaming a long answer wants more.
The execution role and the invoke policy
Two policies attach to the role and they are different in kind. The logging permissions are boilerplate and come from the AWS-managed AWSLambdaBasicExecutionRole. The Bedrock permissions are specific to this function and belong inline, generated from var.model_ids, so that adding a model to the list is the same edit as granting permission to it.
data "aws_iam_policy_document" "assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "this" {
name = "${var.name}-role"
assume_role_policy = data.aws_iam_policy_document.assume.json
}
resource "aws_iam_role_policy_attachment" "logs" {
role = aws_iam_role.this.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "aws_region" "current" {}
data "aws_caller_identity" "current" {}
data "aws_iam_policy_document" "invoke" {
statement {
sid = "InvokeNamedModels"
actions = [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream",
"bedrock:Converse",
"bedrock:ConverseStream",
]
resources = local.model_arns
}
}
resource "aws_iam_role_policy" "invoke" {
name = "${var.name}-bedrock-invoke"
role = aws_iam_role.this.id
policy = data.aws_iam_policy_document.invoke.json
}Those four action strings are the ones AWS lists in its service authorization reference for Bedrock. Converse and ConverseStream are separate actions from InvokeModel and InvokeModelWithResponseStream — a policy that grants only the invoke pair will deny a function written against the Converse API, and the error names the action, so read it. If the function applies a guardrail, bedrock:ApplyGuardrail is a fifth action on the guardrail ARN.
Why the resource ARN is the hard part
This is where hand-written policies fail. Bedrock foundation models are not account-scoped resources. AWS documents the foundation-model ARN as arn:aws:bedrock:region::foundation-model/model-id — note the double colon, because the account field is empty. An inference profile is account-scoped and looks like arn:aws:bedrock:region:account-id:inference-profile/profile-id, with a separate application-inference-profile form for the ones you create for cost attribution.
A module that builds one ARN shape for both will silently fail whenever the caller passes a cross-region inference profile ID, which is increasingly the normal way to reach a model. Branch on the prefix instead of guessing:
locals {
region = data.aws_region.current.name
account_id = data.aws_caller_identity.current.account_id
model_arns = [
for id in var.model_ids :
startswith(id, "arn:")
? id
: (
# Cross-region inference profiles are prefixed with a geo code.
can(regex("^(us|eu|apac)\\.", id))
? "arn:aws:bedrock:${local.region}:${local.account_id}:inference-profile/${id}"
: "arn:aws:bedrock:${local.region}::foundation-model/${id}"
)
]
}Invoking through an inference profile needs both: permission on the profile ARN and permission on the underlying foundation models in every region the profile can route to. That is the single most common reason a policy that looks right produces a denial — the profile is allowed, the model in the region it actually routed to is not. If you are granting profile access, grant the foundation-model ARN in each member region too, and consult AWS’s service authorization reference for Bedrock for the exact resource types rather than copying an ARN from a blog post.
The function resource
The function itself is unremarkable, which is the point — the value of the module is the wiring above, not this block. Package the code with the archive_file data source so the hash changes when the code does, and create the log group explicitly so that its retention is managed rather than infinite.
data "archive_file" "src" {
type = "zip"
source_dir = var.source_dir
output_path = "${path.module}/.build/${var.name}.zip"
}
resource "aws_cloudwatch_log_group" "this" {
name = "/aws/lambda/${var.name}"
retention_in_days = 30
}
resource "aws_lambda_function" "this" {
function_name = var.name
role = aws_iam_role.this.arn
handler = "handler.handler"
runtime = "python3.12"
filename = data.archive_file.src.output_path
source_code_hash = data.archive_file.src.output_base64sha256
timeout = var.timeout
memory_size = var.memory_size
environment {
variables = {
MODEL_ID = var.model_ids[0]
}
}
depends_on = [aws_cloudwatch_log_group.this]
}The depends_on is deliberate. Without it Lambda creates the log group implicitly on first invocation, with no retention, and Terraform’s own log group resource then collides with it. Order them and the problem does not exist.
The two failures on first invoke
AccessDeniedExceptionwith a correct policy. Bedrock requires model access to be enabled per account and per region before any principal can invoke a model, and that is an account setting, not an IAM one. Terraform will happily apply a perfect policy for a model your account has never been granted. Check model access in the Bedrock console for the target region — this is one of the few places the console is genuinely the surface, and it is the volatile part of this page.UnknownServiceErroror a missingconversemethod. The Python managed runtime bundles a version of boto3 and botocore that lags the SDK release stream, and Bedrock’s newer clients and methods arrive in the SDK before they arrive in the runtime. AWS’s own knowledge-centre article on upgrading boto3 and botocore in Lambda documents the fix: ship boto3 and botocore in a layer, or vendor them into the deployment package. Add the layer ARN as an optional module variable so callers can opt in without forking the module.- A throttle that looks like a bug. Bedrock returns
ThrottlingExceptionagainst per-account, per-model requests-per-minute and tokens-per-minute quotas. Do not encode a number for these in the module; the values differ by model and region and change. Look them up under Amazon Bedrock in the Service Quotas console and set your concurrency below them.
Once the module applies cleanly, the natural next step is putting a queue in front of it so a throttle becomes a retry rather than a dropped request — see SQS-triggered asynchronous inference. And if some of the Bedrock estate already exists because somebody built it in the console, importing it rather than recreating it is a separate and slightly delicate job.