I wanted a small project that shows what I can actually do with AWS: an HTTPS endpoint that takes a prompt and returns an answer from a model on Amazon Bedrock, with every resource defined in Terraform and permissions tight enough that I'd be comfortable leaving it running.
Most Bedrock tutorials stop at a Python script that calls a model from your laptop. That's fine for a demo. A real application needs more: an endpoint other code can call, credentials that never leave AWS, permissions that allow one model and nothing else, logs, and a cap on how fast anyone can spend your money.
So that's what we'll build:
- An HTTPS endpoint (
POST /chat) on Amazon API Gateway - An AWS Lambda function in Python that calls the Bedrock Converse API
- A least-privilege IAM policy that allows one model through one inference profile
- Throttling and log retention, so the endpoint is safe to leave running
- All of it deployed and destroyed with Terraform
The project also taught me something I didn't plan to learn. My own AWS account blocked me three different ways before I understood why, and the reason wasn't in my code. If your account is new, the Lessons from a locked-down AWS account section near the end could save you a day.
I used Amazon Nova 2 Lite, a low-cost model that doesn't need any extra sign-up in Bedrock. Switching to another model means changing three variables.
The complete code is on GitHub: MyWorkPortfolio/bedrock-serverless-api.
Architecture
A request travels through four managed services, and none of them run while idle.
Request path and supporting resources
The client sends a prompt to POST /chat. API Gateway throttles it and passes it to Lambda, which validates it and calls Bedrock through a US cross-Region inference profile. Bedrock serves the request from whichever of three US Regions has capacity. The Lambda role can reach only that profile and its own log group.
Prerequisites
You need four things before you start:
- An AWS account with credentials configured locally (
aws configureor AWS IAM Identity Center) - Terraform 1.5 or newer
- Python 3 (only to read and tweak the function; Terraform packages it for you)
- curl or any HTTP client
A note on Bedrock model access
You may remember having to request access to each model on a "Model access" page. That step is gone. Since October 2025, Amazon Bedrock enables serverless foundation models by default in all commercial Regions. Access is now controlled with ordinary IAM policies, which is exactly what this tutorial does.
Two exceptions are worth knowing. Anthropic models still need a one-time usage form before first use. Some third-party models are sold through AWS Marketplace, and the first call in an account needs Marketplace permissions to create the subscription (details). Amazon Nova models need neither, which keeps this tutorial simple.
Check your Bedrock quota before you start
I wish someone had told me this first. Newer AWS accounts can have Bedrock token quotas of zero, and when they do, every request fails no matter how good your code is. It takes ten seconds to check (swap in your Region):
aws service-quotas list-service-quotas --service-code bedrock --region us-east-1 \
--query "Quotas[?contains(QuotaName, 'Nova 2 Lite') && contains(QuotaName, 'per day')].[QuotaName,Value]" \
--output table
If the tokens-per-day values show 0.0, read Lessons from a locked-down AWS account near the end before you build anything.
Project structure
Create a folder with this layout:
bedrock-serverless-api/
├── src/
│ └── handler.py # Lambda function
├── versions.tf # Terraform and provider versions
├── variables.tf # Region, model and throttling settings
├── iam.tf # Lambda role and Bedrock permissions
├── lambda.tf # Function, packaging and log group
├── api.tf # HTTP API and route
└── outputs.tf # Endpoint URL
I split the Terraform by concern so each file stays short, and so the IAM policy can be reviewed on its own. When someone asks "what can this thing access?", the answer is one file.
Step 1: Write the Lambda function
The function validates the request, sends the prompt to Bedrock, and returns the model's reply with token usage.
It uses the Converse API rather than InvokeModel. Converse gives every model the same request and response shape, so you can switch models without rewriting your parsing code.
Create src/handler.py:
"""Lambda handler: forwards a prompt to Amazon Bedrock via the Converse API."""
import base64
import json
import logging
import os
import boto3
from botocore.exceptions import ClientError
logger = logging.getLogger()
logger.setLevel(logging.INFO)
MODEL_ID = os.environ["MODEL_ID"]
MAX_TOKENS = int(os.environ.get("MAX_TOKENS", "512"))
MAX_PROMPT_CHARS = int(os.environ.get("MAX_PROMPT_CHARS", "4000"))
# Created once per execution environment and reused across invocations.
# BEDROCK_REGION lets the function call Bedrock outside its own Region.
bedrock = boto3.client("bedrock-runtime", region_name=os.environ.get("BEDROCK_REGION"))
def _response(status, body):
return {
"statusCode": status,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(body),
}
def _parse_body(event):
raw = event.get("body") or "{}"
if event.get("isBase64Encoded"):
raw = base64.b64decode(raw).decode("utf-8")
return json.loads(raw)
def lambda_handler(event, context):
try:
payload = _parse_body(event)
except (json.JSONDecodeError, UnicodeDecodeError):
return _response(400, {"error": "Request body must be valid JSON."})
prompt = payload.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
return _response(400, {"error": "Field 'prompt' is required."})
if len(prompt) > MAX_PROMPT_CHARS:
return _response(
400, {"error": f"Prompt is longer than {MAX_PROMPT_CHARS} characters."}
)
try:
result = bedrock.converse(
modelId=MODEL_ID,
messages=[{"role": "user", "content": [{"text": prompt}]}],
inferenceConfig={"maxTokens": MAX_TOKENS, "temperature": 0.5},
)
except ClientError as err:
code = err.response["Error"]["Code"]
logger.error("Bedrock call failed: %s", code)
status = 429 if code == "ThrottlingException" else 502
return _response(status, {"error": "Model request failed.", "code": code})
text = "".join(
block.get("text", "") for block in result["output"]["message"]["content"]
)
usage = result.get("usage", {})
logger.info(
"model=%s inputTokens=%s outputTokens=%s",
MODEL_ID,
usage.get("inputTokens"),
usage.get("outputTokens"),
)
return _response(
200,
{
"response": text,
"model": MODEL_ID,
"stopReason": result.get("stopReason"),
"usage": usage,
},
)
A few of these choices are deliberate:
- The client is created outside the handler. Lambda reuses the execution environment between invocations, so warm requests skip client setup.
-
The model ID and Bedrock Region come from environment variables. Terraform sets them, so the code never hardcodes a model, and the function can call Bedrock in a different Region from where it runs. When
BEDROCK_REGIONis unset, boto3 uses the function's own Region. - Prompt length and output tokens are capped. Both limit what a single request can cost.
-
Errors map to useful status codes. Bedrock throttling returns
429so clients know to retry; other model failures return502. - Token usage is logged. Every request writes input and output token counts to CloudWatch, which you will use later to estimate cost.
boto3 is already included in the Lambda Python runtime, so there are no dependencies to package.
Step 2: Define the infrastructure with Terraform
Providers and variables
versions.tf pins Terraform and the providers, and tags every resource so you can find it in Cost Explorer later:
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
archive = {
source = "hashicorp/archive"
version = "~> 2.4"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
}
variables.tf holds everything you might change. Note the three model variables:
variable "aws_region" {
description = "Region for Lambda, API Gateway and logs."
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Prefix used for resource names."
type = string
default = "bedrock-ai-api"
}
variable "bedrock_region" {
description = "Region Lambda sends Bedrock requests to. Leave null to use aws_region."
type = string
default = null
}
variable "foundation_model_id" {
description = "Base Bedrock model ID."
type = string
default = "amazon.nova-2-lite-v1:0"
}
variable "inference_profile_id" {
description = "Cross-Region inference profile ID that Lambda calls. Its prefix (us., eu.) must match bedrock_region's geography."
type = string
default = "us.amazon.nova-2-lite-v1:0"
}
variable "max_tokens" {
description = "Maximum tokens the model may generate per request."
type = number
default = 512
}
variable "throttle_rate_limit" {
description = "Steady-state requests per second allowed by API Gateway."
type = number
default = 5
}
variable "throttle_burst_limit" {
description = "Burst requests allowed by API Gateway."
type = number
default = 10
}
Why a model ID and an inference profile ID? Nova 2 Lite is called through a cross-Region inference profile (us.amazon.nova-2-lite-v1:0), which lets Bedrock route your request to whichever US Region has capacity. According to the Nova 2 Lite model card, requests from us-east-1 can be served in us-east-1, us-east-2 or us-west-2. That matters for the IAM policy.
bedrock_region is optional. Leave it unset and Lambda calls Bedrock in its own Region. Set it when your compute and your Bedrock access live in different Regions. Lessons from a locked-down AWS account, near the end, shows the real case that made me add it.
IAM: least privilege, done properly
This is the part I care about most, because it's where most tutorials cut corners with "Resource": "*". Here is what an inference profile actually needs. Per the AWS documentation on geographic cross-Region inference, the caller needs permission on the inference profile, plus the foundation model in the source Region and in every destination Region.
iam.tf grants permission on that one profile, and on the foundation model only when the call comes through that profile:
data "aws_caller_identity" "current" {}
locals {
bedrock_region = coalesce(var.bedrock_region, var.aws_region)
inference_profile_arn = "arn:aws:bedrock:${local.bedrock_region}:${data.aws_caller_identity.current.account_id}:inference-profile/${var.inference_profile_id}"
# Any Region the profile routes to, but only through that profile (see the condition below).
foundation_model_arn = "arn:aws:bedrock:*::foundation-model/${var.foundation_model_id}"
}
data "aws_iam_policy_document" "lambda_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "lambda" {
name = "${var.project_name}-lambda-role"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
data "aws_iam_policy_document" "lambda_permissions" {
# Allow calling this one inference profile.
statement {
sid = "InvokeInferenceProfile"
actions = ["bedrock:InvokeModel"]
resources = [local.inference_profile_arn]
}
# Allow the underlying model only when the call comes through that profile.
statement {
sid = "InvokeModelViaProfile"
actions = ["bedrock:InvokeModel"]
resources = [local.foundation_model_arn]
condition {
test = "StringEquals"
variable = "bedrock:InferenceProfileArn"
values = [local.inference_profile_arn]
}
}
# Write logs only to this function's log group.
statement {
sid = "WriteLogs"
actions = ["logs:CreateLogStream", "logs:PutLogEvents"]
resources = ["${aws_cloudwatch_log_group.lambda.arn}:*"]
}
}
resource "aws_iam_role_policy" "lambda" {
name = "${var.project_name}-lambda-policy"
role = aws_iam_role.lambda.id
policy = data.aws_iam_policy_document.lambda_permissions.json
}
Three things to notice:
-
Only
bedrock:InvokeModelis granted. The Converse API is authorized throughInvokeModel, so no extra action is needed. -
The model ARN uses a wildcard Region, but the condition does the real work. The
bedrock:InferenceProfileArncondition means the role can only reach the model through this one profile, never directly. The wildcard means the policy keeps working if AWS adds a destination Region to the profile, so you don't have to track each model card's Region list by hand. -
No
AWSLambdaBasicExecutionRole. Instead of that broad managed policy, the function can only write to its own log group, which Terraform creates.
Note that the profile ARN uses the Bedrock Region, not the Lambda Region. An inference profile belongs to the Region you call it from.
The Lambda function
lambda.tf zips the src folder, creates a log group with 14-day retention, and deploys the function:
data "archive_file" "lambda_zip" {
type = "zip"
source_dir = "${path.module}/src"
output_path = "${path.module}/build/lambda.zip"
}
resource "aws_cloudwatch_log_group" "lambda" {
name = "/aws/lambda/${var.project_name}"
retention_in_days = 14
}
resource "aws_lambda_function" "chat" {
function_name = var.project_name
role = aws_iam_role.lambda.arn
runtime = "python3.13"
handler = "handler.lambda_handler"
filename = data.archive_file.lambda_zip.output_path
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
timeout = 29
memory_size = 256
environment {
variables = {
MODEL_ID = var.inference_profile_id
BEDROCK_REGION = local.bedrock_region
MAX_TOKENS = tostring(var.max_tokens)
}
}
depends_on = [
aws_cloudwatch_log_group.lambda,
aws_iam_role_policy.lambda,
]
}
The timeout is 29 seconds because API Gateway HTTP APIs stop waiting for an integration after 30 seconds. source_code_hash makes Terraform redeploy whenever handler.py changes. MODEL_ID is set to the inference profile ID, because that is what you pass to Converse.
The HTTP API
api.tf creates an API Gateway HTTP API with one route, POST /chat, and default throttling:
resource "aws_apigatewayv2_api" "http" {
name = "${var.project_name}-http"
protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
api_id = aws_apigatewayv2_api.http.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.chat.invoke_arn
payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "chat" {
api_id = aws_apigatewayv2_api.http.id
route_key = "POST /chat"
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.http.id
name = "$default"
auto_deploy = true
default_route_settings {
throttling_rate_limit = var.throttle_rate_limit
throttling_burst_limit = var.throttle_burst_limit
}
}
resource "aws_lambda_permission" "api_gateway" {
statement_id = "AllowHttpApiInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.chat.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*/chat"
}
An HTTP API is cheaper and simpler than a REST API, and it is enough here. The source_arn on the Lambda permission restricts invocation to this API's /chat route only.
Outputs
outputs.tf prints the URL you will call:
output "chat_endpoint" {
description = "POST your prompt to this URL."
value = "${aws_apigatewayv2_stage.default.invoke_url}chat"
}
output "lambda_function_name" {
description = "Use this to find the function's logs in CloudWatch."
value = aws_lambda_function.chat.function_name
}
Step 3: Deploy and test
From the project folder, initialize and apply:
terraform init
terraform plan
terraform apply
Review the plan, type yes, and wait for Terraform to print the endpoint:
aws_lambda_function.chat: Creation complete after 20s [id=bedrock-ai-api]
aws_lambda_permission.api_gateway: Creating...
aws_apigatewayv2_integration.lambda: Creating...
aws_lambda_permission.api_gateway: Creation complete after 0s [id=AllowHttpApiInvoke]
aws_apigatewayv2_integration.lambda: Creation complete after 0s [id=5yzpmrc]
aws_apigatewayv2_route.chat: Creating...
aws_apigatewayv2_route.chat: Creation complete after 1s [id=n3b5no3]
Apply complete! Resources: 9 added, 0 changed, 0 destroyed.
Outputs:
chat_endpoint = "https://o8t72pe7fa.execute-api.eu-north-1.amazonaws.com/chat"
lambda_function_name = "bedrock-ai-api"
This run used the eu-north-1 settings from the lessons section below; with the defaults, your endpoint will be in us-east-1.
Now send a prompt:
curl -s -X POST "$(terraform output -raw chat_endpoint)" \
-H "Content-Type: application/json" \
-d '{"prompt": "Explain AWS Lambda cold starts in two sentences."}'
The response has this shape:
{
"response": "<the model's answer>",
"model": "us.amazon.nova-2-lite-v1:0",
"stopReason": "end_turn",
"usage": {"inputTokens": 0, "outputTokens": 0, "totalTokens": 0}
}
Now the honest part. When I ran this, here's what came back:
{
"error": "Model request failed.",
"code": "ThrottlingException"
}
That's not a bug in the stack. The request made it through API Gateway, Lambda and IAM all the way to Bedrock, which refused it because my account's Bedrock token quota is zero. Lesson 4 below shows exactly how I proved that. In an account with normal Bedrock quotas, you'll get a response in the shape shown above. I'll add my own live response here as soon as I have one.
Whatever Bedrock says, you can still test the guardrails. An empty body should return 400:
curl -s -X POST "$(terraform output -raw chat_endpoint)" \
-H "Content-Type: application/json" -d '{}'
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
terraform apply fails with "explicit deny in a service control policy" |
Your AWS Organization blocks that service or Region | IAM can't override it; see the lessons section below |
502 with "code": "AccessDeniedException"
|
The profile ARN's Region doesn't match bedrock_region, or an SCP blocks Bedrock there |
Check both variables, then the CloudWatch logs for the denied ARN |
502 with "code": "ValidationException"
|
Wrong model or profile ID, or a profile prefix (us., eu.) that doesn't match the Region |
Copy the IDs exactly from the model card |
429 with "code": "ThrottlingException"
|
Per-minute or per-day token quota reached | Wait and retry. For "Too many tokens per day", check your quota (see Prerequisites); a zero won't fix itself |
503 or timeout from API Gateway |
The model took longer than 29 seconds | Lower max_tokens or use streaming (see below) |
Logs are in CloudWatch under /aws/lambda/bedrock-ai-api.
Lessons from a locked-down AWS account
I built this in my own AWS account, one I'd opened myself a few weeks earlier. It failed three different ways before I worked out why, and the answer turned out to be the account itself, not my code.
1. An SCP blocked what IAM allowed
My first terraform apply created the IAM role and log group, then stopped:
Error: creating Lambda Function (bedrock-ai-api): ... AccessDeniedException:
User: arn:aws:iam::111122223333:user/deployer is not authorized to perform:
lambda:CreateFunction ... with an explicit deny in a service control policy
The user had AdministratorAccess, and it didn't matter. A service control policy (SCP) comes from an AWS Organization and caps what everyone in a member account can do, admins included. No IAM policy can override an explicit deny.
That confused me, because I'd never joined an organization. So I asked AWS directly:
aws organizations describe-organization
The management account was registered to my own email address. I'd signed up through AWS's new sign-up experience, which doesn't create a single standalone account. It creates a "project" inside an organization that AWS sets up for you, on a Free plan with guardrail SCPs, and keeps the management account out of reach until you upgrade.
2. I found out what was allowed by testing, not guessing
I couldn't read the policy, so I wrote a short script, probe-account.sh, that creates and immediately deletes a throwaway HTTP API and Lambda function in several Regions, and makes one tiny Bedrock call in each:
| Region | Bedrock call | API Gateway | Lambda |
|---|---|---|---|
| us-east-1 | Access denied | Blocked by SCP | Blocked by SCP |
| us-west-2 | Daily token limit | Blocked by SCP | Blocked by SCP |
| eu-west-1 | Worked | Blocked by SCP | Blocked by SCP |
| eu-central-1 | Access denied | Blocked by SCP | Blocked by SCP |
| eu-north-1 | Daily token limit | Allowed | Allowed |
Once I knew about the new sign-up, the pattern made sense. Each project gets one home Region (us-east-2, eu-north-1 or ap-southeast-2, depending on your contact address), and resources can only be created there. Mine is Stockholm. Bedrock calls that don't create resources are the exception, which is why one worked from Ireland.
3. Compute in one Region, Bedrock in another
Nothing requires Lambda and Bedrock to share a Region. That's why the function reads BEDROCK_REGION and Terraform has a separate bedrock_region variable. My fix was a three-line terraform.tfvars:
aws_region = "eu-north-1"
bedrock_region = "eu-west-1"
inference_profile_id = "eu.amazon.nova-2-lite-v1:0"
Two details caught me out. The inference profile ARN in the IAM policy must use the Bedrock Region (eu-west-1), not the Lambda Region. And the profile prefix must match the geography: eu. for EU Regions, us. for US ones.
With that, terraform apply created all nine resources and the endpoint came up.
4. My Bedrock quota was zero
The endpoint returned 429. Calling Bedrock directly gave the full message:
ThrottlingException: Too many tokens per day, please wait before trying again.
I waited a day and tried again. Same result. I tried Nova Micro, Nova Lite and the global profile from two Regions, including models the account had never called. Same result. So I checked the quotas themselves in my home Region:
aws service-quotas list-service-quotas --service-code bedrock --region eu-north-1 \
--query "Quotas[?contains(QuotaName, 'Nova 2 Lite')].[QuotaName,Value,Adjustable]" --output table
| Quota | Value | Adjustable |
|---|---|---|
| Model invocation max tokens per day for Amazon Nova 2 Lite | 0 | No |
| Cross-region model inference requests per minute for Amazon Nova 2 Lite | 0 | No |
| Cross-region model inference tokens per minute for Amazon Nova 2 Lite | 0 | Yes |
| Global cross-region model inference tokens per day for Amazon Nova 2 Lite | 0 | No |
Zero, and not adjustable through Service Quotas. No code change, Region or model was ever going to fix that. The remaining options are a support case, upgrading to the Paid plan, or an account created with Sign up for AWS (advanced). AWS doesn't document whether upgrading raises Bedrock quotas, so I haven't assumed it does.
What I'd check next time, before writing any code
- [ ] Run
aws organizations describe-organization. If it returns an organization under your own email, you're probably on the new sign-up - [ ] Find your home Region at settings.aws.com under Project → Overview
- [ ] Check your Bedrock quotas with the command above. A zero means stop and fix that first
- [ ] Search any
AccessDeniederror for "service control policy" before you spend an hour debugging IAM
Taking it to production
The stack already caps prompt length, output tokens and request rate, and logs token usage. Before real users touch it, add these:
- Authentication. The endpoint is public. Add a JWT authorizer on the HTTP API (Amazon Cognito or any OIDC provider), or use IAM authorization for service-to-service calls.
-
Cost alerts. Set an AWS Budgets alert on the
Project = bedrock-ai-apitag. Bedrock bills per token, so a leaked endpoint costs money until you notice. Check current Nova 2 Lite prices on the Bedrock pricing page. -
Guardrails. Attach an Amazon Bedrock Guardrail to filter harmful content and sensitive data. Converse accepts a
guardrailConfigparameter, and the role needsbedrock:ApplyGuardrailon the guardrail ARN. - Remote state. Move Terraform state to an S3 backend with state locking before anyone else deploys this.
-
Streaming. For long answers, switch to
ConverseStreamso users see text as it is generated and you avoid the 30-second API Gateway limit. Lambda response streaming through a function URL is natively supported in the Node.js runtime; from Python, a WebSocket API is a common alternative. The role then also needsbedrock:InvokeModelWithResponseStream. - Alarms. Add CloudWatch alarms on Lambda errors and API Gateway 5xx responses.
Clean up
Remove everything this tutorial created so you aren't billed for it:
terraform destroy
The stack has no always-on resources, so idle cost is close to zero. Destroying it still removes the public endpoint, which is the real risk.
Conclusion and next steps
You now have a serverless AI endpoint that's defined in code, scoped to one model with least-privilege IAM, throttled, logged, and gone with one command. Changing the model is a three-variable edit.
What I'm taking away from building it: the code was the easy part. Most of my time went into understanding the account I was deploying into, and I suspect that's true in most real jobs too. Read error messages to the very end, check quotas before you build, and don't assume your AWS account is a blank slate.
If I take this further, these are next on my list:
- Retrieval-augmented generation (RAG): connect an Amazon Bedrock Knowledge Base so the model answers from your own documents.
- Conversation history: store messages in DynamoDB and send them back to Converse for multi-turn chat.
- A frontend: a small chat page on S3 and CloudFront that calls this endpoint.
The full code is on GitHub: MyWorkPortfolio/bedrock-serverless-api. If you build on it, or you hit a wall I didn't, I'd like to hear about it.

Top comments (0)