RSS

How to deploy a serverless application with Terraform on AWS Lambda

Terraform AWS Infrastructure Infrastructure As Code

Serverless promises a simple world where you write code, deploy it, and watch it scale automatically without thinking about servers. It’s an attractive idea: your function handles one or thousands of requests using the same deployment, billing you only for actual execution time instead of idle server hours. However, the catch is that serverless still requires infrastructure configuration, albeit of a different type.

Manual console clicking initially feels productive, but creates environments that drift over time as developers make slightly different choices during setup. Your staging environment works perfectly, while production fails because someone selected different subnet settings three months ago. Terraform solves this by treating serverless infrastructure as declarative code that deploys identically every time, and it works with serverless too.

This blog guides you on building a complete serverless application, comprising a Lambda function with an API Gateway endpoint, proper IAM security following the principle of least privilege, and S3 trigger integration for event-driven processing.

What is a serverless application?

Serverless applications run on infrastructure you never see or manage, which is the entire point. The name misleads because servers obviously exist somewhere, but your relationship with them changes completely. You write code that executes in response to specific triggers, then the cloud provider handles everything else: provisioning compute resources, scaling them up or down, patching operating systems, and managing availability across data centers.

The execution model is fundamentally different from traditional hosting, where servers run continuously, regardless of whether anyone uses them or not. Your function sits dormant until something invokes it. That trigger might be an HTTP request hitting an API Gateway, a file landing in an S3 bucket, a record changing in a DynamoDB table, or a CloudWatch Events schedule firing at midnight. Each invocation starts fresh with no memory of previous executions, which means you can't rely on variables persisting between runs or connections staying open between requests.

This stateless architecture shapes how you build software:

What are the benefits of serverless applications?

The cost model alone justifies serverless for many types of workload. Traditional hosting bills you for every hour a server runs, regardless of traffic, which means a low-traffic API costs $20 monthly even when it sits idle 99% of the time. Serverless charges only for execution time and requests, making that same API cost just a few dollars per month or less.

The economics reverse at scale, though, because high-traffic applications executing millions of times daily can quickly exceed the cost of dedicated servers running continuously.

Scaling happens automatically without any configuration on your part. Your function handles thousands of concurrent requests using the same deployment, because AWS spins up execution environments as needed and tears them down when traffic subsides. You skip the entire world of capacity planning, autoscaling groups, and load balancer configuration that traditionally dominate infrastructure work.

For example, traffic spikes from marketing campaigns or unexpected viral content are handled without preparation. However, you'll encounter cold starts (brief delays when new execution environments spin up) as a trade-off for this automatic elasticity.

You stop managing You still manage
Server patching and updates Application code and dependencies
Capacity planning IAM permissions and security
Infrastructure monitoring Function configuration and limits
Load balancing Application monitoring and logging
Operating system security Cost optimization

On the operations side of things, complexity shifts rather than disappears. You no longer face infrastructure problems, but application architecture problems take their place. You focus on code and business logic instead of server maintenance, which accelerates development when you can deploy individual functions without coordinating infrastructure changes. In addition, infrastructure as code makes experimentation cheap because spinning up a new function to test an idea costs nothing until someone uses it.

How do you build serverless applications?

Before writing any code, you need a plan. You need to identify what becomes a function by looking for single-responsibility units that respond to specific events, like processing an uploaded image or validating form data. Each function requires clear triggers (e.g., HTTP request, file upload, schedule), and you need to map out how events flow between components when one function's output becomes another's input. Data storage also requires upfront decisions because functions don't persist state between invocations, which means databases or object storage handle everything that needs to survive past a single execution.

Also, function boundaries matter more than you'd expect: If you go too granular, you'll be orchestrating dozens of tiny functions that mostly call each other, adding latency and complexity; too coarse and you lose the independent scaling benefits that make serverless worthwhile.

Here's an example Lambda function at the right granularity:

def lambda_handler(event, context):
    image_key = event['Records'][0]['s3']['object']['key']
    resized = resize_image(image_key)
    save_thumbnail(resized)
    return {'statusCode': 200}

Local development works for function logic, but cloud testing eventually becomes necessary because local environments can't perfectly simulate IAM permissions, network timeouts, or service integrations. A good practice is to package dependencies alongside your code and use environment variables for configuration that changes between environments.

To build your infrastructure, use IaC and avoid clicking around the console. Terraform makes infrastructure repeatable across development, staging, and production by using the same workflow as application code: change files, commit to Git, review diffs, and deploy. Proper Terraform code organization becomes important as your serverless applications grow beyond a single function.

How do you deploy a serverless application with Terraform on AWS Lambda?

To deploy a serverless application with Terraform on AWS Lambda, you'll need an AWS account with credentials configured locally (typically through the AWS CLI or environment variables), Terraform installed (version 1.5 or later works well), and a project structure that separates your infrastructure code from your function code.

Create two directories: one for Terraform files that define infrastructure, another for your Lambda function source code.

Creating the Lambda function

The Lambda function resource sits at the center of everything. You point Terraform at your function code, specify a runtime, and configure execution parameters that affect both cost and reliability:

resource "aws_lambda_function" "api_handler" {
  filename         = "function.zip"
  function_name    = "serverless-api-handler"
  role            = aws_iam_role.lambda_exec.arn
  handler         = "index.handler"
  runtime         = "python3.11"
  timeout         = 30
  memory_size     = 256

  environment {
    variables = {
      ENVIRONMENT = "production"
      TABLE_NAME  = "user-data"
    }
  }
}

The filename points to a zip archive containing your function code and dependencies, which you'll need to create before running Terraform (something like zip -r function.zip index.py for a simple Python function).

The handler specifies which function gets called using the format file.function_name, so index.handler means Terraform calls the handler function in index.py. Runtime selection determines which language version executes your code, for example, Python, Node.js, and Go.

Memory allocation affects more than just RAM availability because AWS ties CPU power to memory size, which means a 256MB function runs slower than a 1024MB function even if neither uses that much memory. Timeout prevents runaway executions from billing you indefinitely. Environment variables inject configuration without hardcoding values into your function code, which becomes essential when the same function deploys to different environments with different database names or API endpoints.

IAM roles and permissions

Lambda functions need an IAM role because they access other AWS services on your behalf, whether that's writing logs to CloudWatch, reading files from S3, or querying DynamoDB tables. You need to establish a trust relationship that lets the Lambda service assume the role:

resource "aws_iam_role" "lambda_exec" {
  name = "serverless-lambda-role"

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

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"
}

resource "aws_iam_role_policy" "lambda_s3" {
  name = "lambda-s3-access"
  role = aws_iam_role.lambda_exec.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "s3:GetObject",
        "s3:PutObject"
      ]
      Resource = "arn:aws:s3:::my-upload-bucket/*"
    }]
  })
}

The trust policy appears in the assume_role_policy block and declares that lambda.amazonaws.com can assume this role. The AWSLambdaBasicExecutionRole provides CloudWatch Logs permissions that allow you to view what your function does, which is useful for debugging. The custom policy grants S3 access following the least privilege principle, where you only permit actions the function needs on specific resources rather than broad wildcards.

Forgetting CloudWatch Logs permissions is the most common mistake here, leading to successful deployments that appear to do nothing because error messages vanish into the void.

API Gateway integration

API Gateway makes your Lambda function an HTTP endpoint that browsers and apps can call. The setup involves multiple Terraform resources because API Gateway separates concerns into distinct components:

resource "aws_api_gateway_rest_api" "api" {
  name = "serverless-api"
}

resource "aws_api_gateway_resource" "resource" {
  rest_api_id = aws_api_gateway_rest_api.api.id
  parent_id   = aws_api_gateway_rest_api.api.root_resource_id
  path_part   = "process"
}

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   = "POST"
  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                     = aws_lambda_function.api_handler.invoke_arn
}

resource "aws_lambda_permission" "apigw" {
  statement_id  = "AllowAPIGatewayInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api_handler.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_api_gateway_rest_api.api.execution_arn}/*/*"
}

resource "aws_api_gateway_deployment" "deployment" {
  depends_on = [aws_api_gateway_integration.integration]
  rest_api_id = aws_api_gateway_rest_api.api.id
  stage_name  = "prod"
}

output "api_endpoint" {
  value = "${aws_api_gateway_deployment.deployment.invoke_url}/process"
}

The REST API resource creates the container, then resources define URL paths, methods specify HTTP verbs, and integration connects everything to your Lambda function. The lambda_permission resource authorizes API Gateway to invoke your function, which AWS enforces separately from IAM roles. Deployment makes your changes live, and the stage name creates versioned endpoints, such as https://api-id.execute-api.us-east-1.amazonaws.com/prod/process.

This complexity exists because API Gateway offers granular control over routing, authentication, rate limiting, and transformations. The payoff comes when you get an HTTPS endpoint without managing certificates, load balancers, or web servers. The output block exposes your API URL so you know where to send requests after deployment.

Event triggers with S3

S3 bucket notifications trigger Lambda functions whenever files land in storage, turning uploads into immediate processing without polling or scheduled checks:

resource "aws_s3_bucket" "uploads" {
  bucket = "my-serverless-uploads"
}

resource "aws_s3_bucket_notification" "trigger" {
  bucket = aws_s3_bucket.uploads.id

  lambda_function {
    lambda_function_arn = aws_lambda_function.api_handler.arn
    events              = ["s3:ObjectCreated:*"]
    filter_prefix       = "incoming/"
  }
}

resource "aws_lambda_permission" "s3" {
  statement_id  = "AllowS3Invoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api_handler.function_name
  principal     = "s3.amazonaws.com"
  source_arn    = aws_s3_bucket.uploads.arn
}

The notification watches for object creation events matching the filter prefix, then invokes your Lambda function with event data containing the bucket name and object key. The separate permission grants S3 the right to invoke your function, following the same pattern as API Gateway permissions.

Testing and verification

Terraform outputs provide the information you need to test your deployment. The API endpoint from earlier lets you send requests with curl or a browser, while CloudWatch Logs show execution details, including print statements, errors, and performance metrics. To test the S3 trigger, upload a test file that matches your filter prefix and monitor the logs for automatic execution.

This immediate feedback loop confirms everything works before you build additional features on top.

Automating deployments

GitHub Actions and similar CI/CD tools automate Terraform runs when you push code changes, though you'll want to separate infrastructure updates from Lambda code updates. Infrastructure changes run terraform plan and terraform apply, while function code changes just repackage the zip file and update the Lambda function directly.

Follow best practices for CI/CD pipelines to make sure your deployments remain reliable as complexity grows.

Serverless vs. containers and PaaS

Choosing between serverless, containers, and platform-as-a-service depends on your traffic patterns, operational preferences, and application requirements. Each approach trades off different concerns. The following table should give you a good starting point:

Serverless Containers PaaS
Execution model Functions run on demand, scale to zero Always-running processes in isolated environments Framework-specific applications with automatic deployment
Scaling approach Automatic based on events Manual configuration or autoscaling rules Platform handles scaling within framework constraints
Cost structure Pay per execution and duration Pay for running containers regardless of traffic Flat rate or tiered pricing based on resources
Operational complexity Minimal infrastructure management Orchestration, networking, and deployment pipelines Almost zero operations, managed entirely by the provider
Best use cases Event-driven workloads, APIs with variable traffic Long-running processes, complex dependencies Standard web applications, teams prioritizing speed

Serverless wins decisively for irregular traffic patterns where your API sits idle most of the day but needs to handle occasional bursts. Event-driven processing, like file uploads triggering image resizing or database changes spawning notifications, fits perfectly because you're only paying when events actually occur. Rapid prototyping becomes cost-effective when spinning up infrastructure incurs no costs until it’s used, and parallel processing of independent tasks scales automatically without managing worker pools.

Containers make more sense when processes need to maintain long-running connections, such as WebSockets or database connection pools. Predictable, steady traffic often costs less with containers, as you're not paying per execution. Complex dependencies that exceed Lambda's 250MB package size limit or 15-minute timeout simply won't work in serverless environments. Applications requiring persistent state in memory between requests need the continuity containers provide.

Platform-as-a-service appeals most to teams without infrastructure expertise that want to push code and let someone else handle everything. Applications built with standard frameworks like Rails or Django deploy with minimal configuration, automatically obtaining SSL certificates, DNS management, and CDN integration without requiring changes to infrastructure code. You're trading control and flexibility for convenience and speed.

Alternative ways to deploy a serverless application

AWS Lambda dominates the serverless landscape, but several alternatives offer different trade-offs. Google Cloud Functions offers similar functionality with tighter integration into GCP services, which is particularly beneficial if you're already using BigQuery or Cloud Storage extensively. Azure Functions brings Microsoft's enterprise focus, with strong support for hybrid cloud scenarios where some workloads run on-premises, and others reside in Azure. Terraform supports AWS Lambda and the other platforms through provider-specific resources that follow similar patterns, which means the infrastructure-as-code skills transfer even when the cloud provider changes.

Open source serverless platforms let you avoid vendor lock-in at the cost of taking on operational burden. OpenFaaS runs on your own Kubernetes clusters and gives you serverless capabilities without sending data to external clouds, which appeals to organizations with strict data residency requirements or existing Kubernetes investments. Knative takes a similar approach with auto-scaling containers on Kubernetes, blurring the line between traditional containers and serverless functions.

Start with whichever cloud provider you already use rather than evaluating all options simultaneously. Pricing models and free-tier offerings vary significantly, but the differences matter less than becoming comfortable with one platform's patterns. Multi-cloud strategies may sound appealing, but they introduce substantial complexity due to different APIs, deployment patterns, and operational quirks. Focus on making one platform work well before considering diversification.

Conclusion

You've built a serverless application with HTTP endpoints, proper IAM security, and event-driven S3 processing, all defined as infrastructure as code that deploys identically across environments. Production needs monitoring, alerting, and cost optimization through reserved concurrency and memory tuning, but the foundation is solid.

Managing Terraform for serverless across teams introduces complexity when multiple developers work on shared environments, and infrastructure changes need coordination with application updates. Stategraph Orchestration automates plan and apply workflows, enforces policies, detects drift, and handles the relationship between infrastructure and application deployments without custom scripting.