Skip to main content

Command Palette

Search for a command to run...

Deploying Serverless Architectures on AWS with Terraform

Updated
10 min read
Deploying Serverless Architectures on AWS with Terraform
H

Exploring DevOps tools and practices.

Infrastructure as Code (IaC) has become the backbone of modern DevOps. Among the IaC tools, Terraform stands out because of its modularity, state management, and cloud-agnostic design. In this post, I’ll walk through a Terraform project where I provision a serverless application on AWS, covering networking, storage, compute, database, and API exposure.

This project showcases how to design, modularize, and secure cloud infrastructure in a way that scales, follows best practices, and is production-ready.


Why Serverless with Terraform?

  • Serverless: You don’t manage servers; you focus on business logic. AWS Lambda + API Gateway + DynamoDB + S3 = a powerful, pay-per-use architecture.

  • Terraform: Gives version-controlled, repeatable deployments, and allows you to abstract


Architecture Overview

Here’s what we’re building:

  • Networking: Custom VPC with public and private subnets, NAT Gateway, and Application Load Balancer.

  • Storage: S3 bucket for object storage (can hold static files or Lambda deployment packages).

  • Database: DynamoDB table for a fast, serverless NoSQL database.

  • Compute: AWS Lambda function as the core business logic.

  • API: API Gateway to expose the Lambda via HTTPS.

  • Security: IAM policies for least privilege and security groups at the network layer.

End-to-End Flow:
👉 Client request → API Gateway → Lambda → DynamoDB/S3 → Response back to client.


Breakdown of Each Module

Understanding VPC and Its Core Components

An Amazon VPC (Virtual Private Cloud) provides a logically isolated section of the AWS cloud where you can launch and manage resources. A VPC spans an entire AWS Region, and within it, we create subnets across multiple Availability Zones (AZs) to ensure high availability and fault tolerance.

Subnets are generally classified into two types:

  • Public Subnets – exposed to the internet, used for resources such as load balancers or bastion hosts.

  • Private Subnets – internal-only, used for application servers, databases, and sensitive workloads.

To enable connectivity, the VPC relies on an Internet Gateway (IGW), which acts as the entry and exit point for internet-bound traffic. Public subnets direct their traffic through the IGW, while private subnets rely on a NAT Gateway for outbound connections. The NAT Gateway is deployed in a public subnet but securely allows resources in private subnets to reach the internet (for example, to download patches or updates) without exposing them publicly.

The flow of traffic inside the VPC is managed by Route Tables. Each subnet must be explicitly associated with a route table:

  • Public route tables map internet-bound traffic (0.0.0.0/0) to the IGW.

  • Private route tables send outbound traffic to the NAT Gateway.

This routing setup ensures a clear separation between public-facing and private workloads, while still allowing controlled connectivity where required.

When handling inbound requests from the internet, a Load Balancer (ALB or NLB) is typically deployed in public subnets. It receives client traffic and forwards it to backend services running in private subnets, thereby maintaining both scalability and security.

To protect workloads, AWS offers two layers of security:

  • Security Groups – stateful firewalls attached to resources, controlling inbound and outbound traffic at the instance or service level.

  • Network ACLs (NACLs) – stateless firewalls applied at the subnet level, providing broader traffic filtering for all resources within a subnet.

Together, the VPC, Subnets, Route Tables, Gateways, Load Balancer, and Security controls form the foundation of a secure, scalable, and highly available AWS network design.

Below is the Terraform VPC module, where each of the components have been created.

resource "aws_vpc" "main" {
  cidr_block       =   var.vpc_cidr//"10.0.0.0/16"
  instance_tenancy = "default"
  enable_dns_support   = true
  enable_dns_hostnames = true
  tags = {
    Name = "main"
  }
}

resource "aws_security_group" "lb_sg" {
  name   = "lb-sg"
  vpc_id = aws_vpc.main.id

  ingress {
    description = "Allow HTTP"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "Allow HTTPS"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "lb-sg"
  }
}


resource "aws_internet_gateway" "gw" {
  vpc_id = aws_vpc.main.id

  tags = {
    Env = var.env
  }
}

resource "aws_subnet" "public" {
  for_each=var.public_subnets
  vpc_id     = aws_vpc.main.id
  cidr_block = each.value.cidr
  availability_zone = each.value.az
  tags = {
    Env = var.env
  }
}

resource "aws_subnet" "private" {
  for_each=var.private_subnets
  vpc_id     = aws_vpc.main.id
  cidr_block = each.value.cidr
  availability_zone = each.value.az
  tags = {
     Env = var.env
  }
}



resource "aws_lb" "test" {
  name               = var.lb_name//"test-lb-tf"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.lb_sg.id]
  subnets            = [for subnet in aws_subnet.public : subnet.id]
  //enable_deletion_protection = true

#   access_logs {
#     bucket  = aws_s3_bucket.lb_logs.id
#     prefix  = "test-lb"
#     enabled = true
#   }
  tags = {
     Env = var.env
  }
}

resource "aws_route_table" "example" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.gw.id
  }

#   route {
#     ipv6_cidr_block        = "::/0"
#     egress_only_gateway_id = aws_egress_only_internet_gateway.example.id
#   }

  tags = {
     Env = var.env
  }
}
resource "aws_route_table_association" "public" {
  for_each = aws_subnet.public
  subnet_id = each.value.id
  route_table_id = aws_route_table.example.id
}

resource "aws_nat_gateway" "nat" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public["a"].id   # Pick one public subnet
  tags = {  Env = var.env}
}

resource "aws_eip" "nat" {
  domain = "vpc"
  tags   = {  Env = var.env }
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.nat.id
  }

  tags = {  Env = var.env }
}

resource "aws_route_table_association" "private" {
  for_each       = aws_subnet.private
  subnet_id      = each.value.id
  route_table_id = aws_route_table.private.id
}

Traffic Flow

  • Inbound:

    1. Request enters via the Internet Gateway.

    2. Routed to public subnetLoad Balancer.

    3. Load Balancer forwards request to application servers in private subnets.

    4. Security Groups on these servers control which ports/IPs are allowed.

  • Outbound:

    1. Application servers in private subnets initiate requests (e.g., to external APIs).

    2. Traffic is routed to a NAT Gateway in a public subnet.

    3. NAT Gateway sends traffic out via the Internet Gateway while keeping private resources hidden.


Lamda and Api Gateway

The Lambda function serves as the core compute for application logic. To enable it to run within AWS, we first create an execution role with the basic Lambda execution policy, which allows the function to write logs to CloudWatch. Additional policies can be attached to this role depending on the resources the Lambda must access (e.g., S3, DynamoDB).

The application code is zipped and uploaded to an S3 bucket. Using Terraform, the Lambda function resource points to the S3 object, ensuring infrastructure and code remain version-controlled and repeatable.

Once the Lambda is ready, we expose it through API Gateway:

  1. Create a REST API → defines the API Gateway service container.

  2. Add a Resource → creates a specific path (e.g., /hello).

  3. Define a Method → specifies the HTTP method (e.g., GET).

  4. Integration with Lambda → configures API Gateway to proxy requests to the Lambda function.

  5. API deployment and stage, without this, requests won’t be accessible via a deployed endpoint.

  6. Permissions → allow API Gateway to invoke the Lambda using aws_lambda_permission.

Inbound Request Flow for Lambda

When a client sends a request to a serverless application deployed with API Gateway + Lambda, AWS services work together to securely process and return the response. Let’s break it down step by step:

1. Client Sends Request

The journey begins when a user (via a web browser, mobile app, or API client like Postman) makes an HTTP request to the API endpoint, for example:

https://<api-id>.execute-api.<region>.amazonaws.com/dev/hello

This request contains information such as the HTTP method (GET/POST), headers, query parameters, and optionally a request body.

2. API Gateway Entry Point

The request first reaches API Gateway, which acts as a fully managed reverse proxy.

  • It identifies the request by checking:

    • Stage (e.g., dev)

    • Resource Path (e.g., /hello)

    • HTTP Method (e.g., GET)

API Gateway now knows which backend service (in this case, Lambda) should handle the request.

3. Resource & Method Matching

API Gateway looks at its configuration and matches the incoming request against:

  • aws_api_gateway_resource → defines the path (/hello).

  • aws_api_gateway_method → defines the method (GET).

If no match is found, API Gateway returns a 403 (Forbidden) or 404 (Not Found) error.

4. Integration Mapping

Once a match is found, API Gateway checks how this method integrates with backend services.

  • In our setup, the integration is type AWS_PROXY (Lambda proxy integration).

  • API Gateway automatically transforms the raw HTTP request into a structured JSON event payload containing headers, query parameters, body, and request context.

This event is now ready to be sent to the Lambda function.

5. Permission Validation

Before API Gateway can invoke Lambda, it must be authorized to do so.

  • The aws_lambda_permission resource explicitly grants API Gateway (principal = apigateway.amazonaws.com) permission to call the Lambda.

  • If this permission is missing or misconfigured, API Gateway immediately blocks the request with a 403 error.

6. Lambda Execution

Once permissions are validated, API Gateway forwards the event to AWS Lambda.

  • Lambda runs the request inside a secure, short-lived container.

  • The Lambda function executes using its IAM execution role (aws_iam_role), which controls what AWS resources (e.g., DynamoDB, S3) it can access.

  • The function’s handler code processes the request, executes logic (e.g., reads/writes DynamoDB, fetches from S3), and prepares a response.

7. Response Path

After execution, the Lambda returns a response payload in JSON format, containing:

  • Status Code (e.g., 200, 400, 500)

  • Headers (e.g., Content-Type)

  • Response Body (e.g., JSON data or error message)

API Gateway translates this payload into a proper HTTP response.

8. Client Receives Response

Finally, the response travels back to the client. From the client’s perspective, it looks like a standard REST API response — but behind the scenes, AWS securely routed it through API Gateway, validated permissions, executed Lambda, and returned the processed result.

In addition to the core components (VPC, Lambda, and API Gateway), optional services such as S3 and DynamoDB can also be integrated depending on application requirements.

  • Amazon S3 in this setup is primarily used to store the zipped Lambda deployment package. This approach decouples code storage from execution, enabling versioning, repeatable deployments, and easier rollback.

  • Amazon DynamoDB can be used as a backend NoSQL database. For example, each time the Lambda function is invoked, it could store request metadata or application-specific data in a DynamoDB table.

This is the Terraform code to create the Lambda function.

# IAM role for Lambda execution
data "aws_iam_policy_document" "assume_role" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }

    actions = ["sts:AssumeRole"]
  }
}

resource "aws_iam_role" "example" {
  name               = "lambda_execution_role"
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

resource "aws_iam_role_policy_attachment" "basic_execution_role" {
  role= aws_iam_role.example.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"

}
# Package the Lambda function code
data "archive_file" "example" {
  type        = "zip"
  source_dir = "${path.module}/lambda"
  output_path = "${path.module}/lambda/function.zip"
}
# Upload ZIP to S3
resource "aws_s3_object" "lambda_zip" {
  bucket = var.s3_bucket
  key    = "lambda/function.zip"
  source = data.archive_file.example.output_path
  etag   = filemd5(data.archive_file.example.output_path)
}
# Lambda function
resource "aws_lambda_function" "example" {
  //filename         = data.archive_file.example.output_path // Path to the zipped code
  function_name    = var.function_name
  role             = aws_iam_role.example.arn
  handler          = var.handler
  source_code_hash = filebase64sha256(data.archive_file.example.output_path)//data.archive_file.example.output_base64sha256
  s3_bucket        = var.s3_bucket//aws_s3_object.lambda_zip.bucket
  s3_key           = aws_s3_object.lambda_zip.key//aws_s3_object.lambda_zip.key

  runtime = var.runtime

  environment {
   variables = var.app_environment
  }

  tags = {
    Environment = var.env
    Application = "example"
  }
}

Similarly, we craete the Api Gateway,

# API Gateway
resource "aws_api_gateway_rest_api" "api" {
  name = var.api_gw_name //"hello-api"
}

resource "aws_api_gateway_resource" "resource" {
  path_part   = var.path_part //"hello"
  parent_id   = aws_api_gateway_rest_api.api.root_resource_id
  rest_api_id = aws_api_gateway_rest_api.api.id
}

resource "aws_api_gateway_method" "method" {
  rest_api_id   = aws_api_gateway_rest_api.api.id
  resource_id   = aws_api_gateway_resource.resource.id
  http_method   = "GET"
  authorization = "NONE"
}

resource "aws_api_gateway_integration" "integration" {
  rest_api_id             = aws_api_gateway_rest_api.api.id
  resource_id             = aws_api_gateway_resource.resource.id
  http_method             = aws_api_gateway_method.method.http_method
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = var.lambda_invoke_arn  // Replace with your Lambda function ARN
  //aws_lambda_function.lambda.invoke_arn
}

# Lambda
resource "aws_lambda_permission" "apigw_lambda" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = var.lambda_function_name
  principal     = "apigateway.amazonaws.com"
  source_arn = "arn:aws:execute-api:${var.myregion}:${var.accountId}:${aws_api_gateway_rest_api.api.id}/*/${aws_api_gateway_method.method.http_method}${aws_api_gateway_resource.resource.path}"
}

resource "aws_api_gateway_deployment" "example" {
  rest_api_id = aws_api_gateway_rest_api.api.id
  stage_name  = var.stage_name // e.g., "dev"
  depends_on  = [aws_api_gateway_integration.integration]
}

It’s worth noting that these are only examples — the Lambda function could integrate with a variety of AWS services (e.g., SQS, SNS, or RDS) depending on the use case.

Here is the entire project with the S3 and Lambda resource templates, https://github.com/heloise-viegas/devops-sre-portfolio/tree/feat/aws-terraform


Conclusion

By combining Terraform with AWS’s serverless services, we can build scalable, cost-efficient, and production-ready applications while keeping the entire infrastructure defined as code. In this walkthrough, we provisioned a VPC for network isolation, deployed a Lambda function as the compute layer, exposed it via API Gateway, and extended the stack with optional components like S3 for code storage and DynamoDB for data persistence.

This approach not only ensures consistency and repeatability across environments but also simplifies operations by enabling version control, peer review, and automation of infrastructure changes.

The same framework can easily be extended — for example, by integrating monitoring with CloudWatch, securing APIs with Cognito or IAM authorizers, or adopting CI/CD pipelines to automate deployments. With Terraform as the foundation, teams can confidently evolve serverless applications to meet real-world production requirements.