Terraform #

In modern cloud architecture, Serverless and Infrastructure as Code (IaC) are two pillars that are almost inseparable. Serverless offers the advantage of freeing us from physical server management tasks, while IaC provides certainty that all those serverless resources are defined declaratively, consistently, reproducibly across environments, and recorded in a version control system (Git).

In the AWS environment, although Amazon provides native tools like AWS CloudFormation and the AWS Serverless Application Model (SAM), Terraform by HashiCorp remains the top choice for many enterprise organizations. Terraform offers multi-provider flexibility, the expressive HashiCorp Configuration Language (HCL) syntax, and a very extensive ecosystem. This article will deeply discuss how to operate Terraform to build and manage serverless architecture on AWS professionally.


Why Terraform Is Relevant for Serverless? #

There’s a common misconception that because serverless “has no servers,” we don’t need complex IaC tools. In reality, serverless architecture produces far more resources than traditional architecture.

A simple serverless API needs at least:

  • One Lambda function.
  • One IAM Execution Role with precise security policies.
  • One log group in Amazon CloudWatch.
  • Routes on Amazon API Gateway.
  • Explicit permission for API Gateway to invoke that Lambda function.

If we create all these components manually through the AWS Web Console, our system becomes very vulnerable to human error, hard to document, and impossible to replicate identically to new environments (like staging or production). Terraform automates the entire creation of these resources and manages resource dependencies automatically.

flowchart TD
    TF["Terraform Code (HCL)"] -->|terraform apply| Backend["S3 State Backend + DynamoDB Lock"]
    TF -->|Creates| Role["AWS IAM Role (Execution Role)"]
    TF -->|Creates| Lambda["AWS Lambda Function"]
    TF -->|Creates| APIGW["Amazon API Gateway (HTTP API)"]
    TF -->|Creates| Permission["AWS Lambda Permission (Allow API Gateway)"]
    
    APIGW -->|Route Request| Lambda
    Lambda -->|Runs under| Role

State Management at Production Scale #

Terraform uses a state file to record the mapping between real AWS resources and the HCL code we write. For team collaboration at production scale, we must configure a Remote State Backend that’s secure and supports state locking.

Remote State Configuration (S3 & DynamoDB Lock) #

We must store the state file in encrypted Amazon S3, and use Amazon DynamoDB to handle the locking mechanism (preventing two developers from running terraform apply simultaneously, which could corrupt the state file).

# CORRECT: Secure remote backend configuration at the project level
terraform {
  required_version = ">= 1.5.0"
  
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket         = "mycompany-terraform-states"
    key            = "serverless-app/prod/terraform.tfstate"
    region         = "ap-southeast-1"
    encrypt        = true
    dynamodb_table = "terraform-lock-table" //  Used for state locking
  }
}

Complete HCL Code Example: API Gateway & Lambda #

Here’s a functional, ready-to-use Terraform configuration example for deploying the API Gateway HTTP API -> AWS Lambda (Node.js) -> CloudWatch Logs architecture.

1. File: lambda.tf (Defining the Function and IAM Role) #

# Automatically archiving JavaScript source code into a ZIP file
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/src"
  output_path = "${path.module}/dist/lambda.zip"
}

# Creating the Execution Role for Lambda
resource "aws_iam_role" "lambda_exec" {
  name = "app-lambda-execution-role"

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

# Attaching the standard policy for writing logs to CloudWatch
resource "aws_iam_role_policy_attachment" "lambda_logs" {
  role       = aws_iam_role.lambda_exec.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

# Creating the Lambda Function
resource "aws_lambda_function" "api_handler" {
  filename         = data.archive_file.lambda_zip.output_path
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  function_name    = "app-api-handler"
  role             = aws_iam_role.lambda_exec.arn
  handler          = "index.handler"
  runtime          = "nodejs20.x"
  timeout          = 10
  memory_size      = 256

  environment {
    variables = {
      NODE_ENV = "production"
    }
  }

  # Ensuring the log group is created before the function deploys
  depends_on = [
    aws_iam_role_policy_attachment.lambda_logs,
    aws_cloudwatch_log_group.lambda_log_group
  ]
}

# Explicitly creating a CloudWatch Log Group to manage log retention
resource "aws_cloudwatch_log_group" "lambda_log_group" {
  name              = "/aws/lambda/app-api-handler"
  retention_in_days = 7 //  Preventing ballooning bills from unlimited log storage
}

2. File: api_gateway.tf (Defining the Gateway and Permissions) #

# Creating an API Gateway HTTP API (Cheaper and faster than REST API)
resource "aws_apigatewayv2_api" "http_api" {
  name          = "app-http-api"
  protocol_type = "HTTP"
}

# Creating a Stage for deployment (dev, prod, etc.)
resource "aws_apigatewayv2_stage" "prod" {
  api_id      = aws_apigatewayv2_api.http_api.id
  name        = "prod"
  auto_deploy = true

  access_log_settings {
    destination_arn = aws_cloudwatch_log_group.api_gw.arn
    format          = jsonencode({
      requestId      = "$context.requestId"
      ip             = "$context.identity.sourceIp"
      requestTime    = "$context.requestTime"
      httpMethod     = "$context.httpMethod"
      routeKey       = "$context.routeKey"
      status         = "$context.status"
      protocol       = "$context.protocol"
      responseLength = "$context.responseLength"
    })
  }
}

# Creating a CloudWatch Log Group for API Gateway Access Logs
resource "aws_cloudwatch_log_group" "api_gw" {
  name              = "/aws/api-gw/app-http-api"
  retention_in_days = 7
}

# Configuring the integration between API Gateway and the Lambda function
resource "aws_apigatewayv2_integration" "lambda" {
  api_id           = aws_apigatewayv2_api.http_api.id
  integration_type = "AWS_PROXY" //  Lambda Proxy Integration

  integration_method = "POST"
  integration_uri    = aws_lambda_function.api_handler.invoke_arn
  payload_format_version = "2.0"
}

# Creating the default route (Catching all HTTP requests)
resource "aws_apigatewayv2_route" "any" {
  api_id    = aws_apigatewayv2_api.http_api.id
  route_key = "ANY /{proxy+}"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}

# ✓ IMPORTANT: Giving API Gateway explicit permission to invoke Lambda
resource "aws_lambda_permission" "api_gw" {
  statement_id  = "AllowAPIGatewayInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api_handler.function_name
  principal     = "apigateway.amazonaws.com"

  # Restricting invocation permission only to our specific API Gateway
  source_arn = "${aws_apigatewayv2_api.http_api.execution_arn}/*/*"
}

# Output for printing the API Gateway URL after terraform apply completes
output "api_url" {
  value       = "${aws_apigatewayv2_stage.prod.invoke_url}/"
  description = "Public HTTP API endpoint URL"
}

Comparison: Terraform vs. AWS SAM / Serverless Framework #

Many teams are often confused choosing between Terraform and other serverless-specific tools.

Evaluation DimensionTerraformAWS SAM / Serverless Framework
Management ScopeEntire cloud infrastructure (VPC, RDS, IAM, S3, etc.)Primary focus on serverless application resources
Code Iteration SpeedMedium (ZIP compression & upload managed manually/HCL)Very fast (has instant code deploy commands)
Syntax AbstractionLow (writes all components explicitly)High (a few lines of YAML generate many resources)
State ManagementMust be managed yourself (S3 backend)Automatically managed by CloudFormation behind the scenes

Hybrid Approach Recommendation #

For large-scale projects, the best approach is a hybrid approach:

  1. Use Terraform to build the semi-permanent, rarely-changing foundation infrastructure (like VPC, Amazon RDS Database, S3 Buckets, Amazon SQS Queues).
  2. Use the Serverless Framework or AWS SAM to deploy frequently-changing top-level applications (like Lambda functions and API Gateway routes). We can reference foundation infrastructure ARNs from Terraform using the SSM Parameter Store data source.

Summary #

  • Terraform simplifies serverless management by defining complex infrastructure declaratively and consistently (IaC).
  • Always use a Remote State Backend based on Amazon S3 with encryption enabled, plus DynamoDB to handle the state locking mechanism securely.
  • Configure CloudWatch Log Group retention explicitly in Terraform to prevent cost ballooning from unlimited log storage.
  • Give explicit permission via aws_lambda_permission so API Gateway has the access rights to trigger Lambda function execution.
  • Apply the hybrid approach: use Terraform for network/database foundations, and use SAM/Serverless Framework for dynamic daily Lambda code deployment.
← Previous: API Gateway   Next: Cloud Functions →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact