
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.
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:
| Level | Intermediate |
| Time to read | 10 minutes |
| Monthly cost | About $9 at 10,000 documents (us-east-1) |
| Code | InfraTales/aws-cdk-private-lambda-document-intake |
| Tested with | Node.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:
| Option | Fixed monthly cost | Where functions can send data |
|---|---|---|
| NAT gateway per AZ | About $73, plus $0.045 per GB | Anywhere on the internet |
| Interface endpoints for S3 and DynamoDB | About $29, plus $0.01 per GB | Only the services you add |
| Gateway endpoints for S3 and DynamoDB | $0 | Only 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 /documentsandGET /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
PENDINGrecord 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
VALIDATEDorREJECTED. - 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:

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
fileNameand 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 version | Fix |
|---|---|
| An interface endpoint nobody used (about $14.60 a month) | Removed |
| Authorizer cache TTL of 0 | 5-minute cache |
| A usage plan that never applied | Stage throttling and a WAF rate rule |
| CORS open to every origin | One named origin |
| API keys stored in plain text | SHA-256 hashes |
| Dead-letter queues (DLQs) on synchronous functions | One failure queue, on the async processor |
| Default security group allowed traffic | Locked |
| No Bucket Key, plain HTTP allowed | Bucket Key and TLS-only policy |
Tables deleted on cdk destroy | Retained, with deletion protection |
| Logs kept forever | 30-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 endsREJECTED. - 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:
| Item | Math | Monthly |
|---|---|---|
| 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 S3 | 10 GB × $0.023, plus 20,000 requests | $0.29 |
| Amazon CloudWatch | 2 alarms × $0.10, plus some logs | About $0.25 |
| API Gateway | 30,000 requests × $3.50/M | $0.11 |
| Lambda and DynamoDB | Mostly inside the free tier | About $0.05 |
| VPC gateway endpoints | No charge | $0.00 |
| Total | About $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.comThen 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.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article