Skip to content
Infrastructure as Code iac cloud 4 min read

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:

ResourcePurpose
aws_iam_role + policy attachmentThe execution role Lambda assumes at runtime.
aws_cloudwatch_log_groupExplicit log group so retention is managed, not implicit.
aws_lambda_functionThe 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 from src/ 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/archive providers in required_providers so artifact packaging is reproducible across machines.
  • Create the CloudWatch log group explicitly with a retention_in_days value; the implicit group Lambda creates never expires and never gets destroyed.
  • Drive redeploys with source_code_hash from archive_file rather than committing zip files — it changes only when source changes.
  • Keep timeout and memory_size explicit 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.
Last updated June 14, 2026
Was this helpful?