Lambda with Terraform: a practical Terraform & IaC guide
Serverless functions have become a backbone of modern cloud architectures, letting teams scale compute resources without managing infrastructure. When you combine AWS Lambda with Terraform, you gain the ability to define, version, and deploy serverless workloads as infrastructure code. This article walks through the essentials of packaging Lambda functions, configuring runtime and permissions, and wiring up event triggers—all through a repeatable, declarative Terraform workflow.
Why Terraform for Lambda
Defining Lambda functions directly in the AWS Console is quick for prototyping, but it breaks down in production. You lose auditability, struggle with team collaboration, and risk configuration drift. Terraform lets you version your function code, environment variables, memory settings, and IAM roles in a single source of truth. When a colleague reviews a pull request, they see exactly what will change before it hits AWS.
The pattern we cover here applies whether you're building a small utility function or orchestrating dozens of Lambdas across multiple services. The core principle remains: treat serverless infrastructure exactly like you treat application code.
Package — archive_file or build artifacts in CI
Lambda requires your function as a ZIP archive. You have two main paths: archive locally and commit to version control (simple but not recommended for large codebases), or generate the archive in CI and reference it from Terraform (the production approach).
For a Node.js function, the typical structure looks like this:
data "archive_file" "function_zip" {
type = "zip"
source_dir = "${path.module}/src/lambda/my_function"
output_path = "${path.module}/build/my_function.zip"
}
resource "aws_lambda_function" "my_function" {
filename = data.archive_file.function_zip.output_path
source_code_hash = data.archive_file.function_zip.output_base64sha256
function_name = "my-function"
role = aws_iam_role.lambda_role.arn
handler = "index.handler"
runtime = "nodejs20.x"
}The source_code_hash attribute is critical: Terraform watches for changes to your function code and redeploys only when the hash differs. This prevents unnecessary Lambda updates and keeps your deployments efficient.
For larger or compiled languages like Go or Rust, delegate the build step to CI:
resource "aws_lambda_function" "my_function" {
filename = "/tmp/function.zip"
source_code_hash = filebase64sha256("/tmp/function.zip")
function_name = "my-function"
role = aws_iam_role.lambda_role.arn
handler = "bootstrap"
runtime = "provided.al2"
}
Your CI pipeline (GitHub Actions, GitLab CI, etc.) builds the artifact and places it where Terraform expects it. This scales better than committing large binaries to your repository.
A practical tip: store Lambda code and Terraform definitions in the same repository, but use separate directories. This keeps infrastructure code and application code cleanly separated while staying in sync with version control.
Function — Runtime, handler, memory and timeout
After packaging, you configure the function's behavior. The runtime determines which language environment AWS provides; handler tells Lambda which function to invoke; memory and timeout control resource allocation and execution time.
Here's a real-world scenario: your team processes image uploads via API Gateway. A synchronous function shouldn't run longer than 30 seconds (the API Gateway timeout), and processing a typical 5 MB image might need 512 MB of memory. Setting timeout to 60 seconds and memory to 256 MB would likely cause timeouts or out-of-memory errors.
resource "aws_lambda_function" "process_image" {
filename = data.archive_file.function_zip.output_path
source_code_hash = data.archive_file.function_zip.output_base64sha256
function_name = "process-image-${var.environment}"
role = aws_iam_role.lambda_role.arn
handler = "index.handler"
runtime = "nodejs20.x"
timeout = 30
memory_size = 1024
environment {
variables = {
BUCKET_NAME = aws_s3_bucket.uploads.id
LOG_LEVEL = var.log_level
}
}
}
Environment variables are passed to your function at runtime. Store sensitive values like database passwords in AWS Secrets Manager, then reference them in your Lambda code—never store secrets directly in Terraform.
Lambda allocates CPU proportionally to memory: 128 MB gets ~0.08 vCPUs, 1024 MB gets ~0.5 vCPUs. For compute-intensive work, you'll need higher memory allocations even if you don't strictly need the RAM, just to gain CPU bandwidth.
Permissions — Execution role with least privilege
Every Lambda function runs under an IAM execution role. This role determines what AWS services and resources your function can access. Following least privilege, you grant only the permissions the function actually needs.
Consider a function that reads from an S3 bucket and writes logs to CloudWatch:
resource "aws_iam_role" "lambda_role" {
name = "lambda-execution-role-${var.environment}"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy" "lambda_s3_policy" {
name = "lambda-s3-read"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [
"s3:GetObject"
]
Effect = "Allow"
Resource = "${aws_s3_bucket.uploads.arn}/*"
}
]
})
}
resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
role = aws_iam_role.lambda_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
The AWSLambdaBasicExecutionRole is a managed policy that grants permission to write logs to CloudWatch. Custom policies handle service-specific access like S3 reads.
If your Lambda triggers another Lambda, calls DynamoDB, or writes to SQS, add explicit actions for each. Over-permissive roles (like "Action": "*") are a security risk and audit red flag. Your compliance team will ask questions.
Triggers — API Gateway, SQS or EventBridge
A Lambda function isolated from event sources is useless. You need triggers—mechanisms that invoke your function in response to real events. The three most common are API Gateway (synchronous HTTP requests), SQS (asynchronous queue processing), and EventBridge (event routing).
API Gateway Trigger
For synchronous HTTP endpoints, API Gateway invokes Lambda directly and waits for a response:
resource "aws_apigatewayv2_api" "api" {
name = "my-api-${var.environment}"
protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda_integration" {
api_id = aws_apigatewayv2_api.api.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.my_function.invoke_arn
}
resource "aws_apigatewayv2_route" "post_item" {
api_id = aws_apigatewayv2_api.api.id
route_key = "POST /items"
target = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}"
}
resource "aws_lambda_permission" "api_gateway" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.my_function.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.api.id
name = var.environment
auto_deploy = true
}
The aws_lambda_permission resource grants API Gateway permission to invoke your function. Without it, the integration fails silently.
SQS Trigger
For background work, SQS decouples the producer from the consumer. Lambda polls the queue and processes messages:
resource "aws_sqs_queue" "task_queue" {
name = "task-queue-${var.environment}"
message_retention_seconds = 86400
}
resource "aws_lambda_event_source_mapping" "sqs_trigger" {
event_source_arn = aws_sqs_queue.task_queue.arn
function_name = aws_lambda_function.process_task.function_name
batch_size = 10
}
resource "aws_iam_role_policy" "lambda_sqs_policy" {
name = "lambda-sqs-consume"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueAttributes"
]
Effect = "Allow"
Resource = aws_sqs_queue.task_queue.arn
}
]
})
}
Lambda automatically polls SQS every 20 seconds (configurable via maximum_batching_window_in_seconds), batches up to 10 messages by default, and invokes your function. If processing fails, messages return to the queue.
EventBridge Trigger
EventBridge is AWS's event bus. It routes events from any service to Lambda based on rules you define:
resource "aws_cloudwatch_event_rule" "daily_cleanup" {
name = "daily-cleanup-${var.environment}"
schedule_expression = "cron(0 2 * * ? *)"
}
resource "aws_cloudwatch_event_target" "lambda_target" {
rule = aws_cloudwatch_event_rule.daily_cleanup.name
target_id = "CleanupLambda"
arn = aws_lambda_function.cleanup.arn
}
resource "aws_lambda_permission" "eventbridge" {
statement_id = "AllowEventBridgeInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.cleanup.function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.daily_cleanup.arn
}
This example runs a cleanup function every day at 2 AM UTC. EventBridge also routes events from S3, DynamoDB, application events, and more.
Common mistakes
- Forgetting
source_code_hash: Terraform won't detect function code changes without it, leaving old code in production. - Over-permissive IAM roles: Granting wildcard actions or resources makes your Lambda a liability. Audit roles during code review.
- Hardcoded timeout/memory: Test your function under real workload before tuning these. Undersized functions fail; oversized ones waste money.
- Missing Lambda permissions: Forgetting
aws_lambda_permissionfor triggers is a classic gotcha. The resource won't invoke silently. - No dead-letter queues (DLQ): For async triggers like SQS, attach a DLQ to capture failed messages rather than losing them.
- Environment variables for secrets: Never store API keys or database passwords in plain-text environment variables. Use Secrets Manager.
Key takeaways
- Use
archive_filedata source or CI artifacts to package Lambda functions repeatably; always capture the source code hash for change detection. - Configure runtime, handler, memory, and timeout based on your function's actual requirements; measure before tuning.
- Apply least-privilege IAM policies; grant only the specific actions and resources your function needs.
- Wire triggers through Terraform: API Gateway for sync workloads, SQS for async processing, EventBridge for event routing; always include the
aws_lambda_permissionresource. - Store secrets in Secrets Manager, not environment variables; manage them as separate resources, never hardcode them.
- Test function behavior, error handling, and resource usage before promoting to production; Terraform makes it easy to replicate environments for validation.






Comments
Loading comments…