Recipe: Serverless Lambda
AWS Lambda lets you run code without provisioning servers, but a production-ready function is more than just the handler: it needs an IAM execution role, a CloudWatch log group, a deployment package, and a trigger that invokes it. Terraform is an excellent fit because it wires all of these together declaratively and packages your source code reproducibly. This recipe builds a complete HTTP-triggered Lambda end to end, using the archive_file data source for zero-dependency packaging and a Lambda Function URL for the simplest possible trigger (with an API Gateway variant shown afterward).
What we are building
The stack has four pieces that always travel together for a real Lambda:
| Resource | Purpose |
|---|---|
aws_iam_role + policy attachment | The execution role Lambda assumes at runtime. |
aws_cloudwatch_log_group | Explicit log group so retention is managed, not implicit. |
aws_lambda_function | The function itself, fed by a zipped artifact. |
aws_lambda_function_url or aws_apigatewayv2_* | The public HTTP trigger. |
Creating the log group yourself (rather than letting Lambda auto-create it on first invoke) means you control retention and the group is destroyed cleanly with terraform destroy.
Packaging the function
The archive_file data source zips your source at plan time, and its output_base64sha256 becomes the source_code_hash. That hash is what tells Terraform to redeploy when — and only when — your code actually changes.
Put your handler in src/:
// src/index.mjs
export const handler = async (event) => {
const name = event.queryStringParameters?.name ?? "world";
return {
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ message: `Hello, ${name}!` }),
};
};
data "archive_file" "lambda" {
type = "zip"
source_dir = "${path.module}/src"
output_path = "${path.module}/build/function.zip"
}
Tip: Generated artifacts under
build/should be git-ignored. The zip is rebuilt deterministically fromsrc/on every plan, so it never needs to live in version control.
IAM role and logging
Lambda needs a role it can assume (the trust policy) plus permission to write logs. The AWS-managed AWSLambdaBasicExecutionRole policy grants exactly the CloudWatch Logs actions a function requires.
data "aws_iam_policy_document" "assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "lambda" {
name = "hello-lambda-role"
assume_role_policy = data.aws_iam_policy_document.assume.json
}
resource "aws_iam_role_policy_attachment" "logs" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_cloudwatch_log_group" "lambda" {
name = "/aws/lambda/hello-lambda"
retention_in_days = 14
}
The function and its trigger
The function name must match the log group path (/aws/lambda/<name>) so Lambda writes to the group you created. A Function URL gives you a dedicated HTTPS endpoint with no extra infrastructure.
resource "aws_lambda_function" "hello" {
function_name = "hello-lambda"
role = aws_iam_role.lambda.arn
runtime = "nodejs20.x"
handler = "index.handler"
filename = data.archive_file.lambda.output_path
source_code_hash = data.archive_file.lambda.output_base64sha256
timeout = 10
memory_size = 128
depends_on = [
aws_iam_role_policy_attachment.logs,
aws_cloudwatch_log_group.lambda,
]
}
resource "aws_lambda_function_url" "hello" {
function_name = aws_lambda_function.hello.function_name
authorization_type = "NONE"
}
output "function_url" {
value = aws_lambda_function_url.hello.function_url
}
The depends_on ensures the role’s permissions and the log group exist before the function is created, avoiding the race where an early invocation auto-creates a conflicting log group.
Apply and test
terraform init
terraform apply
Output:
data.archive_file.lambda: Reading...
data.archive_file.lambda: Read complete after 0s [id=a1b2c3...]
Terraform will perform the following actions:
# aws_lambda_function.hello will be created
# aws_lambda_function_url.hello will be created
...
Plan: 5 to add, 0 to change, 0 to destroy.
Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
Outputs:
function_url = "https://abc123def456.lambda-url.us-east-1.on.aws/"
Invoke it over HTTP:
curl "$(terraform output -raw function_url)?name=DevCraftly"
Output:
{"message":"Hello, DevCraftly!"}
Note:
authorization_type = "NONE"makes the URL publicly callable. For internal services use"AWS_IAM"and sign requests with SigV4, or front the function with API Gateway and an authorizer.
API Gateway variant
If you need custom domains, request validation, or usage plans, swap the Function URL for an HTTP API. The two extra resources are the API and a $default route integration; you also grant API Gateway permission to invoke the function.
resource "aws_apigatewayv2_api" "http" {
name = "hello-http-api"
protocol_type = "HTTP"
target = aws_lambda_function.hello.arn
}
resource "aws_lambda_permission" "apigw" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.hello.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*"
}
The target shortcut auto-creates the integration, the $default route, and an auto-deploy stage. The invoke URL is exposed as aws_apigatewayv2_api.http.api_endpoint.
This entire recipe is provider-agnostic at the tooling level — every resource and data source here works identically under OpenTofu (tofu init && tofu apply), since both consume the same AWS provider and the hashicorp/archive provider.
Best Practices
- Pin both the AWS and
hashicorp/archiveproviders inrequired_providersso artifact packaging is reproducible across machines. - Create the CloudWatch log group explicitly with a
retention_in_daysvalue; the implicit group Lambda creates never expires and never gets destroyed. - Drive redeploys with
source_code_hashfromarchive_filerather than committing zip files — it changes only when source changes. - Keep
timeoutandmemory_sizeexplicit and tuned; the 3-second default and 128 MB minimum are rarely what production needs. - Prefer least-privilege custom IAM policies over broad managed ones once your function touches other AWS services beyond logs.
- For real workloads, store state remotely (S3 + DynamoDB lock or an OpenTofu backend) so the function’s lifecycle is shared safely across the team.