Skip to main content

REST API with VPC Gateway Endpoints

The rest-api-vpc-endpoints project puts four independently deployed Lambda handlers in isolated VPC subnets. S3 and DynamoDB are reached privately through Gateway Endpoints, so the application does not need a NAT Gateway.

This example is about outbound connectivity from Lambda to AWS services. It does not make API Gateway private: the REST API may remain publicly accessible while its integrations run in isolated subnets. That is different from an API Gateway PRIVATE endpoint.

Architecture​

The stack creates one VPC in up to two Availability Zones, isolated subnets, no NAT Gateway, one S3 Gateway Endpoint, one DynamoDB Gateway Endpoint, one on-demand DynamoDB table, one S3 bucket, one REST API, and four independent Lambda functions. The Gateway Endpoints are associated with the route tables for the isolated subnets. They provide reachability to S3 and DynamoDB only; they do not provide arbitrary AWS-service or internet connectivity and do not grant IAM permissions.

Request and network flows are separate:

Client
|
v
API Gateway REST (public endpoint)
|
v
Lambda in isolated VPC subnet
|\
| +--> S3 Gateway Endpoint ------> Amazon S3
+----> DynamoDB Gateway Endpoint -> DynamoDB

The Lambda functions use the STAGE environment variable to derive a table name such as items-develop. The bucket name is injected through the registered objects environment mapping.

Project structure​

This is the complete project tree from the merged example:

rest-api-vpc-endpoints/
├── app.py
├── cdk.json
├── requirements.txt
├── requirements-dev.txt
├── README.md
├── lambdas/
│ ├── __init__.py
│ ├── items.py
│ ├── objects.py
│ └── requirements.txt
├── layers/
│ └── application/
│ ├── common.py
│ ├── repository.py
│ ├── service.py
│ └── requirements.txt
├── rest_api_vpc_endpoints/
│ ├── __init__.py
│ └── rest_api_vpc_endpoints_stack.py
└── tests/
├── test_handlers.py
└── test_stack.py

The application Layer is flat because its files are packaged under the layer's Python import root. It contains HTTP helpers, repository access, and service validation; it contains no route handlers.

Routes and entrypoints​

MethodPathPython handlerLambda entrypointServiceRequired grant
POST/itemscreate_item in lambdas/items.pyitems.create_itemDynamoDB write@grant_dynamodb("items", "write")
GET/items/{id}get_item in lambdas/items.pyitems.get_itemDynamoDB read@grant_dynamodb("items", "read")
POST/objectsput_object in lambdas/objects.pyobjects.put_objectS3 write@grant_s3("objects", "write")
GET/objects/{key}get_object in lambdas/objects.pyobjects.get_objectS3 read@grant_s3("objects", "read")

The modules are grouped for source organization only. CDK creates one Lambda and one execution role per handler.

CDK stack​

The complete rest_api_vpc_endpoints/rest_api_vpc_endpoints_stack.py is:

from aws_cdk import CfnOutput, RemovalPolicy, Stack
from aws_cdk import aws_dynamodb as dynamodb
from aws_cdk import aws_ec2 as ec2
from aws_cdk import aws_s3 as s3
from constructs import Construct
from lambda_api_decorators_cdk import LambdaApi, LambdaApiConfig


class RestApiVpcEndpointsStack(Stack):
def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
super().__init__(scope, construct_id, **kwargs)

vpc = ec2.Vpc(
self,
"Vpc",
max_azs=2,
nat_gateways=0,
subnet_configuration=[
ec2.SubnetConfiguration(
name="Isolated",
subnet_type=ec2.SubnetType.PRIVATE_ISOLATED,
cidr_mask=24,
)
],
)
isolated_subnets = ec2.SubnetSelection(
subnet_type=ec2.SubnetType.PRIVATE_ISOLATED
)
vpc.add_gateway_endpoint(
"DynamoDbGatewayEndpoint",
service=ec2.GatewayVpcEndpointAwsService.DYNAMODB,
subnets=[isolated_subnets],
)
vpc.add_gateway_endpoint(
"S3GatewayEndpoint",
service=ec2.GatewayVpcEndpointAwsService.S3,
subnets=[isolated_subnets],
)

stage = self.node.try_get_context("stage") or "develop"
table_name = f"items-{stage}"

table = dynamodb.Table(
self,
"Items",
table_name=table_name,
partition_key=dynamodb.Attribute(
name="id", type=dynamodb.AttributeType.STRING
),
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
removal_policy=RemovalPolicy.DESTROY,
)
bucket = s3.Bucket(
self,
"Objects",
removal_policy=RemovalPolicy.DESTROY,
auto_delete_objects=True,
)

config = LambdaApiConfig(
default_runtime="python3.14",
vpc=vpc,
vpc_subnets=isolated_subnets,
vpc_registry={"private": vpc},
environment_registry={
"stage": {"STAGE": stage},
"objects": {"BUCKET_NAME": bucket.bucket_name},
},
dynamodb_table_registry={"items": table},
s3_bucket_registry={"objects": bucket},
)
api = LambdaApi(
self,
"Api",
lambda_path="lambdas",
layers_path="layers",
config=config,
)

CfnOutput(self, "ApiUrl", value=api.api.url)

nat_gateways=0 and PRIVATE_ISOLATED create the no-NAT network. Each add_gateway_endpoint call associates the endpoint with the isolated subnet route tables. dynamodb_table_registry and s3_bucket_registry make the named resources selectable by decorators; vpc_registry exposes the named VPC for the published configuration model, while vpc and vpc_subnets apply it by default to every discovered function. environment_registry supplies the stage and bucket environment values. LambdaApi discovers the handlers and the application Layer, and ApiUrl is the only CloudFormation output.

The example pins lambda-api-decorators==0.3.4 for the Lambda package and lambda-api-decorators-cdk==0.4.7 for the CDK integration. Its AWS CDK and Constructs dependencies remain the ranges declared in requirements.txt, and the layer requirements rely on the Lambda runtime's bundled boto3.

Lambda handlers​

The complete handler files are:

lambdas/items.py

from lambda_api_decorators import GET, POST, environment, grant_dynamodb, layer

from common import path_parameter, request_json, response
from service import (
InvalidRequestError,
NotFoundError,
StorageError,
create_item as create_item_service,
get_item as get_item_service,
)


@layer("application")
@environment("stage")
@grant_dynamodb("items", "write")
@POST("/items")
def create_item(event, context):
item, error = request_json(event)
if error:
return error
try:
return response(201, create_item_service(item))
except InvalidRequestError as error:
return response(400, {"error": str(error), "fields": ["id"]})
except StorageError:
return response(502, {"error": "DynamoDB request failed"})


@layer("application")
@environment("stage")
@grant_dynamodb("items", "read")
@GET("/items/{id}")
def get_item(event, context):
identifier = path_parameter(event, "id")
if not identifier:
return response(400, {"error": "Missing required path parameter"})
try:
return response(200, get_item_service(identifier))
except NotFoundError:
return response(404, {"error": "Item not found"})
except StorageError:
return response(502, {"error": "DynamoDB request failed"})

lambdas/objects.py

from lambda_api_decorators import GET, POST, environment, grant_s3, layer

from common import path_parameter, request_json, response
from service import (
InvalidRequestError,
NotFoundError,
StorageError,
get_object as get_object_service,
put_object as put_object_service,
)


@layer("application")
@environment("stage", "objects")
@grant_s3("objects", "write")
@POST("/objects")
def put_object(event, context):
payload, error = request_json(event)
if error:
return error
try:
key = put_object_service(payload)
except InvalidRequestError:
return response(400, {"error": "Object key and body are required"})
except StorageError:
return response(502, {"error": "S3 request failed"})
return response(201, {"key": key})


@layer("application")
@environment("stage", "objects")
@grant_s3("objects", "read")
@GET("/objects/{key}")
def get_object(event, context):
key = path_parameter(event, "key")
if not key:
return response(400, {"error": "Missing required path parameter"})
try:
stored = get_object_service(key)
except NotFoundError:
return response(404, {"error": "Object not found"})
except StorageError:
return response(502, {"error": "S3 request failed"})
return response(200, {"key": key, "body": stored})

@POST, @GET, @environment, and @layer declare the route and select configuration. @grant_dynamodb and @grant_s3 add IAM permissions to the corresponding function role. Registering a table or bucket only makes it available for lookup; it does not authorize access. Gateway Endpoints solve network reachability, while IAM grants solve authorization.

IAM roles and grants​

The stack does not configure a shared execution role. The CDK integration therefore creates independent execution roles for the four application Lambdas. Read grants are attached only to the read handlers; native cumulative write grants are attached to the create/put handlers. The S3 cleanup provider created for auto_delete_objects=True is CDK-managed and is not placed in the application VPC.

If multiple handlers are deliberately configured to share one execution role, their grants accumulate on that role and become available to every function using it. A shared role does not provide per-handler IAM isolation.

Tests​

tests/test_handlers.py uses fake DynamoDB and S3 clients to verify stage-derived table lookup, item create/read behavior, object put/get behavior, validation, not-found responses, and controlled AWS failures. It also checks that every handler has exactly one route and uses the application Layer.

tests/test_stack.py parses the source and synthesizes the CDK stack to verify the published registries and VPC selection, isolated subnets, zero NAT Gateways, two Gateway Endpoints with route-table associations, four VPC-configured functions, the stage environment, Layer attachment, four distinct roles, all four API routes, table/bucket deletion policies, resource-scoped IAM grants, read versus write actions, and that the S3 cleanup provider is outside the application VPC.

Prerequisites and installation​

Use Python 3.14, Node.js 22, the AWS CDK CLI, Docker, the AWS CLI, and AWS credentials configured for a target Region. Docker must be running for PythonFunction bundling. Install from the example's requirements-dev.txt, which includes the project requirements and local test dependencies.

Run these commands from rest-api-vpc-endpoints:

python3.14 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-dev.txt
npm install --global aws-cdk

Test and synthesize​

pytest
cdk synth --quiet

The tests and synthesis invoke CDK's Python Lambda bundling, so Docker must be available. The synthesized template should contain one application VPC, zero NAT Gateways, two Gateway Endpoints, four application Lambdas and roles, the REST API, the table, and the bucket.

Bootstrap and deploy​

Bootstrap the target account and Region once if necessary, then deploy:

cdk bootstrap
cdk deploy

The stack context defaults stage to develop, so the default table is named items-develop. To deploy another stage, pass for example cdk deploy -c stage=qa; the table name becomes items-qa. CloudFormation prints the ApiUrl output, which is the base URL for all four routes.

API requests​

The handlers accept an item object with a non-empty id, and an object payload with a non-empty key and string body.

API_URL="https://...execute-api..."
curl -i -X POST "$API_URL/items" -H 'Content-Type: application/json' -d '{"id":"1","value":"Ada"}'
curl -i "$API_URL/items/1"
curl -i -X POST "$API_URL/objects" -H 'Content-Type: application/json' -d '{"key":"greeting.txt","body":"hello from a private Lambda"}'
curl -i "$API_URL/objects/greeting.txt"

Successful item creation returns 201 with the stored item; item reads return 200 or 404. Object creation returns 201 with {"key":"greeting.txt"}; object reads return 200 with the key and body or 404. Invalid requests return 400, and handled AWS failures return 502.

Cleanup​

cdk destroy
danger

This is destructive. The DynamoDB table uses RemovalPolicy.DESTROY, and the S3 bucket uses RemovalPolicy.DESTROY with auto_delete_objects=True. Destroying the stack permanently removes the stage-specific table, its data, the bucket, and all objects in the bucket. Back up anything important first.

Costs​

S3 and DynamoDB Gateway Endpoints have no hourly endpoint charge. The example can still incur charges for DynamoDB, S3, Lambda, API Gateway, CloudFormation cleanup, data transfer, and other AWS service usage. It is not an entirely free deployment.

See the complete rest-api-vpc-endpoints directory for the runnable source and README.