# AWS Lambda and API Gateway with Terraform: Serverless HTTP API

> Deploy a serverless HTTP API with an AWS Lambda function, IAM role, CloudWatch logs and API Gateway HTTP API using Terraform or OpenTofu.

- Source: https://www.itwonderlab.com/terraform-aws-lambda-api-gateway/
- Published: 2026-07-26
- Updated: 2026-07-26
- Author: Javier Ruiz Jiménez (https://www.javierruizjimenez.com/)
- Site: IT Wonder Lab (https://www.itwonderlab.com/)

---

## A serverless API in one Terraform configuration

[AWS Lambda](https://www.itwonderlab.com/aws-lambda/) runs code without servers, and [API Gateway](https://www.itwonderlab.com/aws-api-gateway/) exposes it over HTTPS. This tutorial uses an **HTTP API** (API Gateway v2), which is simpler and cheaper than the REST API for most cases.

### The function code

```python title="src/handler.py"
import json

def handler(event, context):
    name = (event.get("queryStringParameters") or {}).get("name", "world")
    return {
        "statusCode": 200,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps({"message": f"Hello, {name}!"}),
    }
```

### Package the code

```hcl title="lambda.tf"
data "archive_file" "lambda" {
  type        = "zip"
  source_file = "${path.module}/src/handler.py"
  output_path = "${path.module}/build/handler.zip"
}
```

The `archive` provider creates the zip during the plan. For larger projects with dependencies, build the package in CI and upload it to S3, or use a container image from [ECR](https://www.itwonderlab.com/aws-ecr/).

### IAM role and logs

```hcl title="lambda.tf"
data "aws_iam_policy_document" "lambda_assume" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "lambda" {
  name               = "ditwl-hello-lambda"
  assume_role_policy = data.aws_iam_policy_document.lambda_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/ditwl-hello"
  retention_in_days = 14
}
```

Creating the log group yourself lets you set a retention and avoids logs that are kept forever. See [IAM roles](https://www.itwonderlab.com/aws-terraform-tutorial-aws-iam-roles-policies/).

### The function

```hcl title="lambda.tf"
resource "aws_lambda_function" "hello" {
  function_name    = "ditwl-hello"
  role             = aws_iam_role.lambda.arn
  runtime          = "python3.12"
  handler          = "handler.handler"
  filename         = data.archive_file.lambda.output_path
  source_code_hash = data.archive_file.lambda.output_base64sha256
  timeout          = 10
  memory_size      = 128
  architectures    = ["arm64"]

  environment {
    variables = {
      LOG_LEVEL = "info"
    }
  }

  depends_on = [
    aws_iam_role_policy_attachment.logs,
    aws_cloudwatch_log_group.lambda,
  ]
}
```

`source_code_hash` makes Terraform redeploy when the code changes. Use a supported runtime: AWS deprecates old ones, so check the list. `arm64` (Graviton) is cheaper.

### API Gateway HTTP API

```hcl title="api.tf"
resource "aws_apigatewayv2_api" "http" {
  name          = "ditwl-hello-api"
  protocol_type = "HTTP"
}

resource "aws_apigatewayv2_integration" "hello" {
  api_id                 = aws_apigatewayv2_api.http.id
  integration_type       = "AWS_PROXY"
  integration_uri        = aws_lambda_function.hello.invoke_arn
  payload_format_version = "2.0"
}

resource "aws_apigatewayv2_route" "hello" {
  api_id    = aws_apigatewayv2_api.http.id
  route_key = "GET /hello"
  target    = "integrations/${aws_apigatewayv2_integration.hello.id}"
}

resource "aws_apigatewayv2_stage" "default" {
  api_id      = aws_apigatewayv2_api.http.id
  name        = "$default"
  auto_deploy = true
}

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

output "api_url" {
  value = aws_apigatewayv2_api.http.api_endpoint
}
```

The `aws_lambda_permission` is the piece people forget: without it, API Gateway receives `500 Internal Server Error` because it cannot invoke the function.

### Deploy and test

```shell
$ tofu init
$ tofu apply
$ curl "$(tofu output -raw api_url)/hello?name=Terraform"
{"message": "Hello, Terraform!"}
```

### Next steps

- A custom domain with an [ACM](https://www.itwonderlab.com/aws-acm/) certificate and [Route 53](https://www.itwonderlab.com/aws-route-53/).
- Authorization with a JWT authorizer ([Cognito](https://www.itwonderlab.com/aws-cognito/)) or IAM.
- Connect it to [DynamoDB](https://www.itwonderlab.com/terraform-aws-dynamodb/) or an [SQS queue](https://www.itwonderlab.com/terraform-aws-sqs-sns/).
- Monitoring with [CloudWatch alarms](https://www.itwonderlab.com/terraform-aws-cloudwatch-alarms/).

### Cost

Lambda charges for requests and execution time, and HTTP API charges per million requests. Both have a monthly free tier for Lambda, and a small API costs cents. Delete the stack when finished with `tofu destroy`.
