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 configure or 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_REGION is 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 429 so clients know to retry; other model failures return 502.
  • 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:

  1. Only bedrock:InvokeModel is granted. The Converse API is authorized through InvokeModel, so no extra action is needed.
  2. The model ARN uses a wildcard Region, but the condition does the real work. The bedrock:InferenceProfileArn condition 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.
  3. 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": "ValidationExcept...Source:https://dev.to/gideonige/build-a-serverless-ai-api-on-aws-with-bedrock-lambda-and-terraform-1c53

Related posts