AWS Builder Center
Private Lambdas, No NAT Gateway: Secure Document Intake on AWS

Private Lambdas, No NAT Gateway: Secure Document Intake on AWS

A document upload pipeline where the Lambda functions can't reach the internet at all. It uses free gateway endpoints instead of a NAT gateway, costs about $9 a month, and comes with tested CDK code you can deploy.

Principal Cloud & AI Platform Architect | 5x AWS Community Builder (Serverless)
If your Lambda functions handle passports, contracts or bank statements, ask whether they need the internet. This article shows how to build a document upload pipeline where they can't reach it at all, and how that also removes the NAT gateway from your bill. You get the design, the cost math, and tested AWS Cloud Development Kit (CDK) code you can deploy today.
 
Key takeaways
  • Gateway endpoints for Amazon S3 and Amazon DynamoDB are free. With them, Lambda functions in isolated subnets need no NAT gateway.
  • Endpoint policies limit what code inside the VPC can reach, even with stolen credentials.
  • Presigned S3 uploads keep file bytes out of Lambda and API Gateway.
  • The whole pipeline costs about $9 a month at 10,000 documents. Two NAT gateways alone would cost $73.
 
The basics, at a glance:
LevelIntermediate
Time to read10 minutes
Monthly costAbout $9 at 10,000 documents (us-east-1)
CodeInfraTales/aws-cdk-private-lambda-document-intake 
Tested withNode.js 24, AWS CDK 2, 35 automated tests

Why skip the NAT gateway

The usual serverless setup puts Lambda functions in a virtual private cloud (VPC) and adds a network address translation (NAT) gateway so they can reach the internet. You need one per Availability Zone (AZ). Two of them cost about $73 a month in us-east-1 before a single file arrives.
 
The cost isn't the only problem. A NAT gateway gives code that reads identity documents a route to anywhere. If someone slips bad code into one of your dependencies, that route is how your data leaves.

Decide if you need a VPC at all

You might not. A Lambda function can call S3 and DynamoDB without a VPC. By default it runs in a network AWS manages, and IAM decides what it can touch.
 
I use a VPC here for one reason: control over where data can go. In isolated subnets, a function has no internet route. The only way out is through the gateway endpoints, and their policies point at one bucket and one table.
 
For two AZs in us-east-1, the options compare like this:
OptionFixed monthly costWhere functions can send data
NAT gateway per AZAbout $73, plus $0.045 per GBAnywhere on the internet
Interface endpoints for S3 and DynamoDBAbout $29, plus $0.01 per GBOnly the services you add
Gateway endpoints for S3 and DynamoDB$0Only S3 and DynamoDB, limited by policy
 
Gateway endpoints exist only for S3 and DynamoDB. That's the main limit of this design, and the last section covers what it rules out.

How a document moves through it

A client asks to upload a document, sends the file, and later checks whether we accepted it. Six AWS services do the work:
  • Amazon API Gateway serves POST /documents and GET /documents/{id}, with AWS WAF (web application firewall) in front.
  • A Lambda authorizer checks the caller's API key against a hash in DynamoDB.
  • The API function writes a PENDING record and returns a presigned S3 upload form.
  • Amazon S3 stores the file, encrypted with an AWS Key Management Service (KMS) key.
  • The processor function checks the file's real type and marks it VALIDATED or REJECTED.
  • Amazon Simple Queue Service (SQS) holds anything that still fails after two retries.
 
Figure 1 shows how the pieces connect. The Lambda functions' only paths out are the two gateway endpoints:
Three private Lambdas reach S3 and DynamoDB only through gateway endpoints, with no NAT

Build the network with no way out

The VPC has two isolated subnets and no NAT gateway. I set restrictDefaultSecurityGroup myself, because CDK only turns it on by default in projects that carry a newer feature flag.
 
Here's the network part of the stack:
1
2
3
4
5
6
7
8
const vpc = new ec2.Vpc(this, 'Vpc', {
maxAzs: 2,
natGateways: 0,
subnetConfiguration: [{ name: 'isolated', subnetType: ec2.SubnetType.PRIVATE_ISOLATED, cidrMask: 24 }],
restrictDefaultSecurityGroup: true, // Security Hub EC2.2
});
const s3Endpoint = vpc.addGatewayEndpoint('S3Endpoint', { service: ec2.GatewayVpcEndpointAwsService.S3 });
const ddbEndpoint = vpc.addGatewayEndpoint('DynamoDbEndpoint', { service: ec2.GatewayVpcEndpointAwsService.DYNAMODB });
 
Notice natGateways: 0. Without it, CDK creates one NAT gateway per AZ by default.

Lock the endpoints to one bucket and table

IAM decides what a role may do. An endpoint policy decides what anything inside the VPC can reach. With both in place, stolen credentials used from inside this VPC still can't copy files to another bucket.
 
This policy allows reads from one prefix of one bucket, and nothing else:
1
2
3
4
5
s3Endpoint.addToPolicy(new iam.PolicyStatement({
principals: [new iam.AnyPrincipal()],
actions: ['s3:GetObject', 's3:GetObjectVersion'],
resources: [documents.arnForObjects('incoming/*')],
}));
 
The DynamoDB endpoint gets the same treatment, with GetItem, PutItem and UpdateItem on one table.

Send uploads straight to S3

If you stream files through API Gateway and Lambda, you pay Lambda to move bytes and hit the API Gateway payload limit. The document also sits in function memory. A presigned POST avoids all three.
 
The API function signs an upload form that lasts 5 minutes:
1
2
3
4
5
6
7
8
9
10
const upload = await createPresignedPost(s3, {
Bucket: BUCKET_NAME!,
Key: key, // incoming/<clientId>/<documentId>
Expires: 300,
Conditions: [
['content-length-range', 1, Number(MAX_BYTES)], // 10 MB
['eq', '$Content-Type', contentType],
],
Fields: { 'Content-Type': contentType },
});
 
S3 checks the size and content type against the signature before it accepts the file. The object key carries the client ID, and a request for someone else's document gets the same 404 as a missing one.

Make processing safe to run twice

S3 delivers event notifications at least once, so the processor sometimes sees the same file twice. It reads only the first 8 bytes to check the real file type. The client chooses the file name and Content-Type header, but it can't fake the bytes.
 
The update only moves a record forward while it's still PENDING:
1
2
3
4
5
6
7
8
9
10
11
12
13
await db.send(new UpdateCommand({
TableName: process.env.TABLE_NAME,
Key: { pk: `DOC#${documentId}` },
ConditionExpression: '#s = :pending',
UpdateExpression: 'SET #s = :status, sizeBytes = :size, checkedAt = :now REMOVE expiresAt',
ExpressionAttributeNames: { '#s': 'status' },
ExpressionAttributeValues: {
':pending': 'PENDING',
':status': valid ? 'VALIDATED' : 'REJECTED',
':size': record.s3.object.size,
':now': new Date().toISOString(),
},
}));
 
A ConditionalCheckFailedException means another run already did the work, so the function logs it and moves on. Any other error goes back to Lambda, which retries twice and then sends the event to SQS. A CloudWatch alarm fires on the first message.

Protect the storage and the API

The bucket uses SSE-KMS (server-side encryption with KMS) plus an S3 Bucket Key, which AWS says can cut KMS request costs by up to 99%. It refuses plain HTTP and keeps old versions. The DynamoDB table has point-in-time recovery (PITR), deletion protection, and a 24-hour time to live (TTL) on uploads that never happen.
 
The API protects itself in five ways:
  • The authorizer result stays cached for 5 minutes, so each key costs one DynamoDB read per 5 minutes.
  • The table stores only a SHA-256 hash of each API key.
  • Stage throttling caps callers at 50 requests a second, with a burst of 100.
  • A JSON schema accepts only fileName and one of three content types.
  • Cross-origin resource sharing (CORS) allows one named origin, never *.

What I fixed from the first version

This started as an older InfraTales repo that already skipped the NAT gateway. I reviewed it like a fintech system before go-live and found ten problems:
Problem in the first versionFix
An interface endpoint nobody used (about $14.60 a month)Removed
Authorizer cache TTL of 05-minute cache
A usage plan that never appliedStage throttling and a WAF rate rule
CORS open to every originOne named origin
API keys stored in plain textSHA-256 hashes
Dead-letter queues (DLQs) on synchronous functionsOne failure queue, on the async processor
Default security group allowed trafficLocked
No Bucket Key, plain HTTP allowedBucket Key and TLS-only policy
Tables deleted on cdk destroyRetained, with deletion protection
Logs kept forever30-day retention
 
Then the new version had a bug of its own, and only a test caught it. CDK's NodejsFunction leaves @aws-sdk/* packages out of the bundle, because the Lambda runtime ships the SDK. It doesn't ship @aws-sdk/s3-presigned-post, so the API function would have crashed in AWS.
 
The fix is one line in the bundling settings:
1
bundling: { minify: true, sourceMap: false, target: 'node24', externalModules: [] },
 
The repo now has a test that loads each bundled function the way Lambda does. I put the bug back on purpose, and the test failed with the same error.

How the code is tested

npm run check runs the same steps as CI on every push:
  • Unit tests for each handler, including hashed keys, upload limits and duplicate events.
  • A flow test through the real handler code with in-memory S3 and DynamoDB. A PDF ends VALIDATED, and a renamed zip ends REJECTED.
  • Template tests that check there's no NAT or internet gateway, and that encryption, throttling and log retention are on.
  • cdk-nag with the AWS Solutions rules. The few findings that are there by design have a written reason in the code.
 
After you deploy, scripts/smoke-test.ts pushes a real PDF through your API and waits for VALIDATED.

What it costs

These are on-demand prices in us-east-1 from the AWS price lists, checked on October 14, 2026. The month assumes 10,000 documents of about 1 MB, with three API calls each:
ItemMathMonthly
AWS WAF$5 web access control list (ACL) + $2 for two rules + 30,000 requests × $0.60/M$7.02
AWS KMS$1 per key; Bucket Keys keep requests in the free tier$1.00
Amazon S310 GB × $0.023, plus 20,000 requests$0.29
Amazon CloudWatch2 alarms × $0.10, plus some logsAbout $0.25
API Gateway30,000 requests × $3.50/M$0.11
Lambda and DynamoDBMostly inside the free tierAbout $0.05
VPC gateway endpointsNo charge$0.00
TotalAbout $8.70
 
WAF is 80% of that, and it costs the same at 30,000 requests as at 3 million. If only a few partners can reach your API, you can turn WAF off with one flag and pay under $2. I leave it on, because bots go after public upload endpoints first.
 
For malware scanning, Amazon GuardDuty Malware Protection for S3 adds about $2.75 at this volume. That's $0.09 per GB scanned plus $0.000215 per object, after a free tier of 1 GB and 1,000 objects.

When this isn't the right fit

This design trades flexibility for control. Skip it in these cases:
  • Your functions call third-party APIs, or AWS services with no gateway endpoint, such as AWS Secrets Manager or Amazon Bedrock. Each interface endpoint costs about $14.60 a month for two AZs.
  • You have no private resources. If you also have no outbound rules, drop the VPC and let IAM and KMS do the work.
  • Your files run to hundreds of MB. Use multipart uploads and move processing to AWS Step Functions.
  • You need to catch malware. The byte check proves the format and nothing more, so turn on GuardDuty Malware Protection for S3.

Try it yourself

You need Node.js 20 or newer and an AWS account with CDK bootstrapped. Clone the repo, run the checks and deploy:
1
2
3
4
5
git clone https://github.com/InfraTales/aws-cdk-private-lambda-document-intake.git
cd aws-cdk-private-lambda-document-intake
npm ci
npm run check
npx cdk deploy -c allowedOrigin=https://your-portal.example.com
 
Then create a client key with scripts/add-client.ts and run scripts/smoke-test.ts against the API URL the deploy prints. The README covers each step.

Clean up

Run npx cdk destroy to remove the stack. The bucket, table and KMS key stay behind on purpose, because they hold customer files. Delete them yourself once you no longer need the data.

Your turn

Private subnets and free gateway endpoints cover a lot of serverless workloads that think they need a NAT gateway. The repo  has everything you need to try it.
 
Do you run document uploads through Lambda today? I'd like to know whether your functions sit in a VPC, and what the NAT gateway line looks like on your bill.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article