Building Production-Ready Serverless APIs
Building a serverless API requires more than just stitching together Lambda, API Gateway, and DynamoDB—it demands intentional design choices around security, billing, and visibility. Serverless API with Terraform: best practices explained means treating infrastructure as code with the same rigor we apply to application code, ensuring every permission is minimal, every cost is predictable, and every failure is observable.
In this guide, we'll walk through a complete serverless API stack using Terraform, covering DynamoDB configuration, Lambda function permissions, API Gateway routing, and observability from the ground up. By the end, you'll have a blueprint for deploying APIs that scale without surprise bills and fail gracefully with proper monitoring.
DynamoDB with On-Demand Billing
DynamoDB offers two billing modes: provisioned capacity and on-demand. For most serverless workloads, especially those with unpredictable traffic patterns, on-demand billing avoids the need to predict peak throughput and eliminates the risk of throttling during unexpected spikes.
Here's how we define a DynamoDB table with on-demand billing in Terraform:
resource "aws_dynamodb_table" "api_data" {
name = "api-data-table"
billing_mode = "PAY_PER_REQUEST"
hash_key = "id"
attribute {
name = "id"
type = "S"
}
attribute {
name = "created_at"
type = "N"
}
global_secondary_index {
name = "created_at-index"
hash_key = "created_at"
projection_type = "ALL"
}
point_in_time_recovery {
enabled = true
}
ttl {
attribute_name = "expires_at"
enabled = true
}
tags = {
Environment = "production"
ManagedBy = "terraform"
}
}
The PAY_PER_REQUEST billing mode charges per read/write unit consumed, scaling automatically with traffic. Notice we've also enabled point-in-time recovery for data protection and TTL for automatic cleanup of time-sensitive records. Both are cheap insurance policies in production.
For cost predictability, on-demand works well up to roughly 40,000 write capacity units per second. Beyond that, provisioned capacity becomes more economical. For a typical API handling hundreds or thousands of requests per second, on-demand keeps things simple.
Lambda Function with Least-Privilege IAM Access
Lambda functions must access DynamoDB, but granting broad permissions violates the principle of least privilege. We create an IAM role specific to this function, then attach a policy that allows only the exact operations needed on the exact table.
resource "aws_iam_role" "lambda_role" {
name = "api-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}]
})
}
resource "aws_iam_role_policy" "lambda_dynamodb" {
name = "lambda-dynamodb-policy"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:Query"
]
Resource = aws_dynamodb_table.api_data.arn
},
{
Effect = "Allow"
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:*"
}
]
})
}
resource "aws_lambda_function" "api_handler" {
filename = "lambda.zip"
function_name = "api-handler"
role = aws_iam_role.lambda_role.arn
handler = "index.handler"
runtime = "nodejs.18.x"
timeout = 30
memory_size = 256
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.api_data.name
}
}
layers = [aws_lambda_layer_version.dependencies.arn]
}
This approach grants only GetItem, PutItem, and Query—not DeleteItem, Scan, or UpdateItem. If the function is compromised, an attacker cannot delete data or scan the entire table. The Lambda also receives the table name via environment variables, avoiding hardcoding.
The logging permissions are essential: Lambda needs to write logs to CloudWatch. Without them, you'll see cryptic "unable to write logs" errors in production. Keeping logs in the same statement makes the intent clear.
HTTP API Routes to the Lambda Function
API Gateway offers two flavors: REST API and HTTP API. For greenfield serverless APIs, HTTP API is simpler, cheaper, and sufficient for most workloads. It handles routing, authentication, and CORS with less overhead.
resource "aws_apigatewayv2_api" "main" {
name = "api-handler"
protocol_type = "HTTP"
cors_configuration {
allow_origins = ["*"]
allow_methods = ["GET", "POST", "OPTIONS"]
allow_headers = ["Content-Type", "Authorization"]
}
}
resource "aws_apigatewayv2_stage" "main" {
api_id = aws_apigatewayv2_api.main.id
name = "prod"
auto_deploy = true
access_log_settings {
destination_arn = aws_cloudwatch_log_group.api_logs.arn
format = jsonencode({
requestId = "$context.requestId"
ip = "$context.identity.sourceIp"
requestTime = "$context.requestTime"
httpMethod = "$context.httpMethod"
resourcePath = "$context.resourcePath"
status = "$context.status"
protocol = "$context.protocol"
responseLength = "$context.responseLength"
error = "$context.error.message"
})
}
default_route_settings {
throttle_settings {
burst_limit = 2000
rate_limit = 1000
}
}
}
resource "aws_apigatewayv2_integration" "lambda" {
api_id = aws_apigatewayv2_api.main.id
integration_type = "AWS_PROXY"
integration_method = "POST"
payload_format_version = "2.0"
target = "arn:aws:apigatewayv2:${data.aws_region.current.name}:lambda:path/2015-03-31/functions/${aws_lambda_function.api_handler.arn}/invocations"
}
resource "aws_apigatewayv2_route" "get_item" {
api_id = aws_apigatewayv2_api.main.id
route_key = "GET /items/{id}"
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_route" "post_item" {
api_id = aws_apigatewayv2_api.main.id
route_key = "POST /items"
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_lambda_permission" "api_invoke" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.api_handler.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.main.execution_arn}/*/*"
}
The key points: we use AWS_PROXY integration so the Lambda receives the full request and must return a properly formatted response. Throttling is set at 1,000 requests per second sustained, with a burst allowance of 2,000—adjust these based on your Lambda's concurrency limits and expected traffic. The lambda:InvokeFunction permission is scoped to API Gateway, preventing other services from invoking the function.
Observability: Log Retention and Error Alarms
A serverless API without observability is a liability. Logs grow indefinitely, costs climb, and errors go unnoticed. We address this with log groups that auto-delete old logs and CloudWatch alarms that page the team when something breaks.
resource "aws_cloudwatch_log_group" "lambda_logs" {
name = "/aws/lambda/api-handler"
retention_in_days = 14
depends_on = [aws_iam_role_policy.lambda_dynamodb]
}
resource "aws_cloudwatch_log_group" "api_logs" {
name = "/aws/apigateway/api-handler"
retention_in_days = 7
}
resource "aws_cloudwatch_metric_alarm" "lambda_errors" {
alarm_name = "api-handler-errors"
comparison_operator = "GreaterThanOrEqualToThreshold"
evaluation_periods = 1
metric_name = "Errors"
namespace = "AWS/Lambda"
period = 60
statistic = "Sum"
threshold = 5
alarm_description = "Alert when Lambda errors exceed 5 in a minute"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
FunctionName = aws_lambda_function.api_handler.function_name
}
}
resource "aws_cloudwatch_metric_alarm" "lambda_duration" {
alarm_name = "api-handler-duration"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "Duration"
namespace = "AWS/Lambda"
period = 60
statistic = "Average"
threshold = 10000
alarm_description = "Alert when average execution time exceeds 10 seconds"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
FunctionName = aws_lambda_function.api_handler.function_name
}
}
resource "aws_cloudwatch_metric_alarm" "dynamodb_throttling" {
alarm_name = "api-table-throttling"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "ConsumedWriteCapacityUnits"
namespace = "AWS/DynamoDB"
period = 60
statistic = "Sum"
threshold = 40000
alarm_description = "Alert if write throttling is imminent on on-demand table"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
TableName = aws_dynamodb_table.api_data.name
}
}
resource "aws_sns_topic" "alerts" {
name = "api-alerts"
}
resource "aws_sns_topic_subscription" "email" {
topic_arn = aws_sns_topic.alerts.arn
protocol = "email"
endpoint = "ops@example.com"
}
Log retention is set to 14 days for Lambda (balances retention and cost) and 7 days for API Gateway (mostly for debugging request/response issues). The three alarms catch the most common failure modes: exceptions in code, slow responses, and DynamoDB exhaustion. SNS delivers alerts via email or integrates with PagerDuty for on-call workflows.
Common Mistakes
- Granting wildcard permissions: Using
Resource: "*"in IAM policies defeats the purpose of least privilege. Always scope to specific table ARNs and actions. - Forgetting CloudWatch logs IAM permissions: Lambda needs explicit permission to write logs. If omitted, logs silently fail and you lose visibility.
- Not setting log retention: Log groups grow indefinitely, incurring storage costs. Set retention to match your operational needs (7–30 days is typical).
- Ignoring DynamoDB on-demand limits: On-demand scales well, but AWS throttles at 40,000 WCU/sec. For higher sustained throughput, switch to provisioned capacity earlier.
- Hardcoding table names in Lambda: Pass table names via environment variables. This decouples your code from infrastructure and simplifies testing.
- Missing Lambda dead-letter queues: For async invocations (SNS, SQS), attach a DLQ to capture failed invocations. Synchronous API calls return errors immediately, but async failures vanish silently.
Key Takeaways
- Use DynamoDB's on-demand billing for APIs with variable traffic; it scales automatically and avoids provisioning guesswork.
- Apply least-privilege IAM: grant only the actions (GetItem, PutItem, Query) and resources (specific table ARN) that Lambda actually needs.
- Route HTTP API requests to Lambda via AWS_PROXY integration, and always grant the API permission to invoke the function.
- Set log retention to prevent unbounded log storage costs, and create CloudWatch alarms for errors, duration, and DynamoDB throttling.
- Use environment variables for table names and other configuration; avoid hardcoding in function code.
- Test your IAM policies in a staging environment before production; overly restrictive permissions will cause silent failures.






Comments
Loading comments…