A Lambda-less config API on AWS
Rationale
Most "config service" APIs are absurdly read-heavy. A handful of operators edit values; thousands of clients fetch them on every page load, every cold start, every reconnect. The actual logic is one line — "look up this key, return its value" — wrapped in a couple hundred lines of Lambda boilerplate, an IAM role, a deployment package, and the long tail of cold starts, log groups, retries, and concurrency tuning that comes with running code you didn't really need.
If the only thing the function does is shape a DynamoDB response and stick a Cache-Control header on it, you can do both at the API Gateway layer and skip the function entirely. The result is a stack with three things in it — API Gateway, a DynamoDB table, and an IAM role — and an end-to-end latency that's almost entirely network and cache.
This post walks through the design that came out of that thinking: API Gateway → DynamoDB direct integration, per-method response cache, per-item TTL stored as a row attribute, and a stage-variable kill switch. No Lambda, no containers, no scheduler. The Terraform fits in two small modules.
Shape of the system
One REST API with two methods:
GET /v1/content— list all items (DynamoDBScan).GET /v1/content/{id}— fetch one (DynamoDBGetItem).
Both methods are wired to AWS service integrations that talk to DynamoDB directly. API Gateway holds an IAM role with dynamodb:GetItem and dynamodb:Scan on a single table; clients never see DynamoDB and never hold AWS credentials. Cache-Control headers are emitted by the integration response itself, so a downstream CDN or browser cache honors them without anything else in the path.
The full data path:
client ──GET /v1/content/{id}──> API Gateway
│ cache HIT → response served from API GW cache
│ cache MISS:
├──> DynamoDB GetItem (via AWS svc integration)
│<── Item { id, title, body, ttl, ... }
└── VTL response template:
- reshape Item → API JSON
- set Cache-Control: max-age=$item.ttl
→ response (also stored in API GW cache)
1. The DynamoDB table
One table, one hash key, pay-per-request. Each item carries a ttl attribute that the API later reads to compute the Cache-Control header. The ttl here is not DynamoDB's expiration TTL — it's a plain numeric attribute we control:
resource "aws_dynamodb_table" "content" {
name = var.table_name
billing_mode = "PAY_PER_REQUEST"
hash_key = "content_id"
attribute {
name = "content_id"
type = "S"
}
tags = var.tags
}
An example row:
{
"content_id": {"S": "welcome"},
"title": {"S": "Welcome"},
"body": {"S": "..."},
"version": {"S": "1.0.0"},
"ttl": {"N": "60"}
}
Editing a config value is a single UpdateItem against the table. Editing how aggressively the CDN caches that value is the same thing — change ttl. No redeploy, no cache invalidation; the next miss picks up the new value and emits a new Cache-Control header.
2. API Gateway → DynamoDB, no Lambda
The integration uses type = "AWS" with a DynamoDB action ARN. The IAM role attached to the integration scopes API Gateway down to two actions on this table only:
resource "aws_iam_role_policy" "apigw_dynamodb_policy" {
name = "${var.api_name}-apigw-dynamodb-policy"
role = aws_iam_role.apigw_dynamodb_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["dynamodb:GetItem", "dynamodb:Scan"]
Resource = var.dynamodb_arn
}]
})
}
The GET /content/{id} integration translates the path parameter into a DynamoDB GetItem request via a request VTL template:
resource "aws_api_gateway_integration" "get_content" {
rest_api_id = aws_api_gateway_rest_api.main.id
resource_id = aws_api_gateway_resource.content_id.id
http_method = "GET"
integration_http_method = "POST"
type = "AWS"
uri = "arn:aws:apigateway:${data.aws_region.current.name}:dynamodb:action/GetItem"
credentials = aws_iam_role.apigw_dynamodb_role.arn
passthrough_behavior = "NEVER"
request_parameters = {
"integration.request.path.id" = "method.request.path.id"
}
request_templates = {
"application/json" = <<-EOT
{
"TableName": "${var.dynamodb_table}",
"Key": {
"content_id": {"S": "$input.params('id')"}
}
}
EOT
}
cache_key_parameters = ["method.request.path.id"]
}
Three things deserve a callout:
passthrough_behavior = "NEVER": anything we don't explicitly template is rejected, so the API can't be tricked into forwarding a body shape we didn't design for.cache_key_parameters: the path id is what scopes the cache entry, so two different ids never collide on the same cached response.- The integration always uses
POSTto DynamoDB regardless of the public HTTP method — that's the DynamoDB Query Protocol, not the API's verb.
3. Cache-Control from the row itself
The response template reshapes the DynamoDB item into the public JSON and pulls ttl off the row to set Cache-Control: max-age. If the row is missing ttl, we fall back to a default. If the cache is disabled at the stage level, we emit a no-cache directive instead:
response_templates = {
"application/json" = <<-EOT
#set($item = $input.path('$.Item'))
#if("$!{item.content_id.S}" != "")
#if($stageVariables.cacheEnabled == "true")
#set($ttl = "300")
#if("$!{item.ttl.N}" != "")#set($ttl = $item.ttl.N)#end
#set($context.responseOverride.header["Cache-Control"] = "public, max-age=$ttl")
#else
#set($context.responseOverride.header["Cache-Control"] = "no-cache, no-store, must-revalidate")
#end
{
"id": "$item.content_id.S",
"title": "$util.escapeJavaScript($item.title.S)",
"body": "$util.escapeJavaScript($item.body.S)",
"version": "$item.version.S",
"ttl": #if("$!{item.ttl.N}" != "")$item.ttl.N#{else}null#end,
"found": true
}
#else
#set($context.responseOverride.header["Cache-Control"] = "no-cache, no-store, must-revalidate")
{"found": false}
#end
EOT
}
Two pieces of behavior to notice. First, a missing item still returns 200 with {"found": false} rather than a 404; a small choice that keeps clients on the success path and avoids client-side error handling for an expected-empty case. Second, we never cache a "not found" — a future UpdateItem that creates the row should be visible immediately.
4. Two layers of caching, one source of truth
API Gateway exposes per-method cache settings on a stage that has a cache cluster attached. Each method gets its own TTL — the list endpoint is cheap to scan but rarely changes, so it gets a longer cache; the by-id endpoint is keyed per id and gets a shorter one:
resource "aws_api_gateway_method_settings" "get_by_id" {
rest_api_id = aws_api_gateway_rest_api.main.id
stage_name = aws_api_gateway_stage.main.stage_name
method_path = "content/{id}/GET"
settings {
caching_enabled = true
cache_ttl_in_seconds = 60
}
}
resource "aws_api_gateway_method_settings" "list" {
rest_api_id = aws_api_gateway_rest_api.main.id
stage_name = aws_api_gateway_stage.main.stage_name
method_path = "content/GET"
settings {
caching_enabled = true
cache_ttl_in_seconds = 30
}
}
That's the server-side cache (API Gateway). On top of it, the Cache-Control: max-age header tells every downstream cache — browsers, the CDN — how long they can hold the response. Both caches read from the same ttl attribute on the row, so changing one number on the item changes both layers in a coherent way.
5. The kill switch
The stage carries a cacheEnabled stage variable that the response templates read. Flipping it to "false" makes every response carry no-cache, no-store, must-revalidate and forces fresh reads end-to-end. Combined with FlushStageCache, it's a one-call escape hatch when something is poisoned:
# Disable cache (stage variable)
aws apigateway update-stage \
--rest-api-id "$API_ID" \
--stage-name v1 \
--patch-operations op=replace,path=/variables/cacheEnabled,value=false
# Flush server-side cache
aws apigateway flush-stage-cache \
--rest-api-id "$API_ID" \
--stage-name v1
# Detach the cache cluster entirely (saves money, kills latency floor)
aws apigateway update-stage \
--rest-api-id "$API_ID" \
--stage-name v1 \
--patch-operations op=replace,path=/cacheClusterEnabled,value=false
The Terraform stage resource has ignore_changes on those three fields so an operator can flip them out-of-band without Terraform fighting them back on the next apply. Cache cluster size and the on/off bit are operational levers, not source-controlled config.
6. CORS without a Lambda either
The OPTIONS preflights are MOCK integrations — API Gateway responds with the right CORS headers without calling anything:
resource "aws_api_gateway_integration" "content_options" {
rest_api_id = aws_api_gateway_rest_api.main.id
resource_id = aws_api_gateway_resource.content.id
http_method = aws_api_gateway_method.content_options.http_method
type = "MOCK"
request_templates = {
"application/json" = "{\"statusCode\": 200}"
}
}
Same trick as before: the layer that needs to answer the request has all the information it needs, so we let it answer.
What you give up
VTL is the load-bearing tradeoff. It is awkward to read, awkward to debug, and effectively untestable outside the running gateway. It earns its place because the templates here are short and stable: reshape an item, optionally set a header. As soon as the response logic gets branchy — fan-out, cross-table joins, conditional auth — VTL stops being charming and starts being a liability. That's the line where a Lambda earns its keep.
The other ceiling is the data model. Scan on a content table is fine when the table is small and bounded; it is not fine when "list everything" silently grows past a few hundred items. If the listing endpoint needs filtering, sorting, or pagination beyond what DynamoDB returns natively, you either move to Query against a GSI (still doable in VTL) or accept that the list endpoint wants a real handler.
And there's no per-request authorization beyond what API Gateway does natively — usage plans, API keys, IAM, or a Cognito/JWT authorizer. If your config has row-level access rules, that logic has to live somewhere, and "somewhere" is a Lambda authorizer or a small handler in front.
Why this shape works for config
Config endpoints fit this design unusually well: read-heavy, write-rare, item-shaped, and tolerant of stale-by-seconds. The ttl-per-row gives owners a way to say "this value is volatile, cache it briefly" or "this value is set-and-forget, cache it for an hour" without involving infrastructure. Operators get a kill switch they can hit from the console. Engineers get an integration surface that's small enough to read in one sitting and easy enough to extend when the next read-shaped endpoint shows up.
And on the bill: API Gateway requests + DynamoDB on-demand + an optional cache cluster, with no Lambda invocations and no idle compute. For a service whose hottest hour is "everyone's app starting up at 9am," that profile is hard to beat.