During a recent security review, I came across a familiar pattern.

A team had built a modern backend using serverless services β€” Lambda, API Gateway, and DynamoDB. It was scalable, fast, and cost-efficient.

But there was one issue:

Their "serverless" architecture was still exposed to the public internet.

Even though authentication existed, the attack surface was unnecessarily large.

That's when the shift happened β€” from serverless-first to:

πŸ” Private-by-design serverless architecture

This blog walks you through building exactly that.

🧠 What is a Fully Private Serverless Architecture?

A fully private serverless architecture ensures:

  • ❌ No public endpoints
  • ❌ No Internet Gateway
  • ❌ No public IPs
  • βœ… All traffic flows inside a VPC
  • βœ… Access only via private connectivity (VPN / Direct Connect)
  • βœ… Strict IAM + endpoint policies

πŸ—οΈ Real Architecture Diagram

Here's a real-world visual representation of what we're building:

                        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                        β”‚   Corporate Network / VPN  β”‚
                        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                     β”‚
                                     β–Ό
                          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                          β”‚   VPC (10.0.0.0/16)β”‚
                          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                    β”‚
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β–Ό               β–Ό                β–Ό
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β”‚ Private Subnet β”‚ β”‚ Private Subnetβ”‚ β”‚ Private Subnetβ”‚
          β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                 β”‚                  β”‚                β”‚
                 β–Ό                  β–Ό                β–Ό
         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
         β”‚ API Gateway  β”‚   β”‚   Lambda     β”‚  β”‚ CloudWatch   β”‚
         β”‚ (PRIVATE)    │──▢│ (in VPC)     β”‚  β”‚ Logs (VPCE)  β”‚
         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                   β”‚
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β–Ό               β–Ό                     β–Ό               β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ DynamoDB β”‚   β”‚   S3     β”‚       β”‚ Secrets Mgr  β”‚  β”‚  Other AWS   β”‚
   β”‚ (Gateway β”‚   β”‚ (Gateway β”‚       β”‚ (Interface)  β”‚  β”‚  Services    β”‚
   β”‚ Endpoint)β”‚   β”‚ Endpoint)β”‚       β”‚              β”‚  β”‚              β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

              ←────── All Traffic via VPC Endpoints ──────→

πŸ”‘ Core Building Blocks

None

πŸ› οΈ Step-by-Step Implementation

🧱 Step 1: Create VPC (No Internet Gateway)

🌐 Step 2: Create VPC Endpoints

  • Gateway β†’ S3, DynamoDB
  • Interface β†’ Lambda, API Gateway, Secrets Manager, Logs

βš™οΈ Step 3: Deploy Lambda in Private Subnets

πŸ”Œ Step 4: Create Private API Gateway

πŸ—„οΈ Step 5: Secure Backend Services

  • Block S3 public access
  • Use endpoint-based policies

πŸ” Step 6: Secrets & Logging

  • Secrets Manager via VPCE
  • CloudWatch via VPCE

πŸ§ͺ Terraform Implementation (Production-Ready)

Below is a modular and clean Terraform setup.

πŸ“ providers.tf

provider "aws" {
  region = "ap-south-1"
}

πŸ“ vpc.tf

resource "aws_vpc" "private_vpc" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_subnet" "private_subnet_1" {
  vpc_id            = aws_vpc.private_vpc.id
  cidr_block        = "10.0.1.0/24"
  availability_zone = "ap-south-1a"
}

resource "aws_subnet" "private_subnet_2" {
  vpc_id            = aws_vpc.private_vpc.id
  cidr_block        = "10.0.2.0/24"
  availability_zone = "ap-south-1b"
}

πŸ“ endpoints.tf

resource "aws_vpc_endpoint" "s3" {
  vpc_id       = aws_vpc.private_vpc.id
  service_name = "com.amazonaws.ap-south-1.s3"
  vpc_endpoint_type = "Gateway"
}

resource "aws_vpc_endpoint" "dynamodb" {
  vpc_id       = aws_vpc.private_vpc.id
  service_name = "com.amazonaws.ap-south-1.dynamodb"
  vpc_endpoint_type = "Gateway"
}

πŸ“ security_groups.tf

resource "aws_security_group" "lambda_sg" {
  vpc_id = aws_vpc.private_vpc.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/16"]
  }
}

πŸ“ lambda.tf

resource "aws_iam_role" "lambda_role" {
  name = "lambda_private_role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [{
      Action = "sts:AssumeRole",
      Effect = "Allow",
      Principal = {
        Service = "lambda.amazonaws.com"
      }
    }]
  })
}

resource "aws_lambda_function" "private_lambda" {
  function_name = "private_lambda"

  role    = aws_iam_role.lambda_role.arn
  handler = "index.handler"
  runtime = "nodejs18.x"

  filename = "lambda.zip"

  vpc_config {
    subnet_ids         = [
      aws_subnet.private_subnet_1.id,
      aws_subnet.private_subnet_2.id
    ]
    security_group_ids = [aws_security_group.lambda_sg.id]
  }
}

πŸ“ api_gateway.tf

resource "aws_api_gateway_rest_api" "private_api" {
  name = "private-api"

  endpoint_configuration {
    types = ["PRIVATE"]
  }
}

πŸ“ dynamodb.tf

resource "aws_dynamodb_table" "app_table" {
  name         = "private-table"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"

  attribute {
    name = "id"
    type = "S"
  }
}

πŸ“ s3.tf

resource "aws_s3_bucket" "private_bucket" {
  bucket = "my-private-serverless-bucket"

  force_destroy = true
}

πŸ” Testing the Setup

Since nothing is public, access via:

  • VPN
  • Bastion host (temporary)
  • AWS Client VPN

⚠️ Common Mistakes

  • ❌ Forgetting endpoints β†’ services fail
  • ❌ Using NAT unnecessarily
  • ❌ Weak API policies
  • ❌ Leaving S3 public

πŸ’‘ Advanced Enhancements

  • Add WAF (private integration)
  • Zero Trust (IAM + mTLS)
  • Audit using CloudTrail Lake
  • Private ALB integration

πŸ“ˆ Benefits

None

🎯 Real Use Cases

  • Fintech APIs
  • Healthcare systems
  • Internal enterprise tools
  • Secure AI workloads

🧾 Final Thoughts

Most teams adopt serverless for speed.

But mature teams design for security first.

πŸ” A truly modern architecture is not just serverless β€” it's private, controlled, and intentional.

✍️ Closing Line

"In AWS, the safest endpoint… is the one that doesn't exist publicly."