REST APIs with Terraform

Deploy a serverless REST API on AWS using Terraform as Infrastructure as Code.

Introduction

30 Second Summary

Every time you set up a cloud resource by clicking through a console, those steps exist only in your memory. If something breaks or a teammate needs the same setup, you are stuck retracing clicks and hoping nothing gets missed.

In this project, you will build a live REST API on AWS entirely through Terraform configuration files. You will go from deploying your first Lambda function to exposing it as a public URL, diagnosing and fixing a real integration error along the way.

What You'll Build

You will paste your API's public URL into a terminal, send a request with a custom name, and watch a personalized JSON greeting come back from a function you deployed and troubleshot entirely through code.

By the end of this project, you'll have:

  • Call your own Lambda function from a terminal and see a live JSON response come back instantly.
  • Share a public URL powered by API Gateway that anyone can hit to receive real-time JSON greetings, including personalized responses based on query parameters.
  • Tear down and recreate your entire API stack with a single terraform destroy command, proving your Infrastructure as Code is fully reproducible.
  • Secret Mission: Add API key authentication and a usage plan to rate-limit your API.

Are there any prerequisites?

This project assumes you have an AWS account with the AWS CLI configured, Terraform installed, and Cursor installed. Step 1 confirms your setup before you start building.

Before We Start

You're about to build real cloud infrastructure that responds to HTTP requests from anywhere on the internet.

Before writing any Terraform, let's lock in what you're building and why it matters for your skills.

Set Up Your Terraform Project

Terraform works by translating configuration files into real cloud resources. Before it can create anything on AWS, it needs two pieces of information: which provider plugin to use, and which region to deploy to.

In this step, you'll create a new project directory, write your first Terraform configuration file, and initialize the project. By the end, Terraform will be connected to AWS and ready to deploy resources.

In this step, get ready to:
  • Create a project directory and your first Terraform configuration file.
  • Initialize Terraform and download the AWS provider plugin.
  • Verify the configuration is valid with terraform plan.
Create your project directory and configuration file

Every Terraform project lives in its own directory. You'll create one on your Desktop, then write a configuration file that tells Terraform to use the AWS provider.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.
  • Click Terminal in the top menu bar, then click New Terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder by running this command:
mkdir terraform-rest-api
  • Confirm the folder was created by running:
ls

You should see terraform-rest-api listed in the output.

  • Move into your new project folder by running:
cd terraform-rest-api
  • Open the project folder in Cursor by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the terraform-rest-api folder, then click Open.

Cursor reloads with your project folder as the workspace. The Explorer sidebar on the left shows the empty terraform-rest-api folder.

Trust the authors of this workspace?

Cursor may ask whether you trust the authors of this folder. Click Yes, I trust the authors to continue.

Now you'll create the Terraform configuration file. Terraform reads files ending in .tf from your project directory to determine what infrastructure to build.

  • In the Explorer sidebar, right-click in the empty space and select New File.
  • Type main.tf and press Enter.
  • Copy the following Terraform configuration into main.tf:
# Define which providers this project needs
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

# Configure the AWS provider with the target region
provider "aws" {
  region = "us-east-1"
}

What does this code do?

  • The required_providers block tells Terraform to download the hashicorp/aws plugin. The "~> 6.0" constraint means any version from 6.0 up to (but not including) 7.0.
  • The provider "aws" block configures the plugin to deploy resources in the us-east-1 region. Terraform uses your existing AWS CLI credentials automatically.
  • Save main.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).

You should see main.tf listed in the Explorer sidebar on the left.

Don't see main.tf in the Explorer?

  • Make sure the file name is exactly main.tf with no extra spaces or characters.
  • Confirm you opened the terraform-rest-api folder (not your Desktop or another directory) in Cursor.

Help me figure out why main.tf isn't showing in my Cursor Explorer.

✔️ Awesome, I've got everything!

Double check you've saved main.tf and you're ready for the next part.

ⓧ I'd like to double check the full code

Here's the complete main.tf file for this step. Compare it against yours:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}
Initialize Terraform

Your configuration file is ready, but Terraform hasn't downloaded the AWS provider plugin yet. The terraform init command reads your configuration and fetches everything Terraform needs to work with AWS.

  • Open the integrated terminal in Cursor by clicking Terminal in the top menu bar, then New Terminal.

Before you run the next command, what do you think Terraform needs to download to make your configuration work?

  • Initialize Terraform by running this command:
terraform init

What does this command do?

terraform init reads the required_providers block in your main.tf, downloads the hashicorp/aws plugin, and stores it in a hidden .terraform directory inside your project folder.

You should see output ending with Terraform has been successfully initialized! along with a note about which provider version was installed.

Seeing an error instead of success?

  • Make sure your terminal is inside the terraform-rest-api folder. Run pwd to check.
  • Check that main.tf is saved. Unsaved changes in Cursor show a dot on the file tab.
  • Confirm your AWS CLI is configured by running aws sts get-caller-identity. You should see your AWS account details.

Help me debug my terraform init error.

Verify the configuration is valid

Terraform initialized successfully, which means the provider plugin is downloaded. Now verify that the configuration itself is valid by asking Terraform to plan what it would do.

Your main.tf only defines which provider to use. It doesn't define any resources yet. What do you think Terraform will report when there's nothing to create?

  • Verify the configuration by running this command:
terraform plan

You should see No changes. Your infrastructure matches the configuration. in the output. This confirms that Terraform can talk to AWS and that your configuration is valid.

Seeing a credentials error?

  • Terraform uses your AWS CLI credentials. Run aws sts get-caller-identity to confirm the CLI is configured with valid credentials.
  • If you recently rotated your AWS access keys, run aws configure to update them.

Help me fix my terraform plan credentials error.

Your Terraform project is initialized and connected to AWS. Next up, you'll define your first real cloud resource and deploy a Lambda function you can call from your terminal.

Deploy a Lambda Function and Invoke It

Your Terraform project is initialized and connected to AWS. Now it's time to deploy something real.

Every REST API needs a backend that processes requests and returns responses. In this step, you'll write a Lambda function, deploy it to AWS with Terraform, and invoke it from your terminal to confirm it works.

In this step, get ready to:
  • Write a Lambda function that returns a JSON response.
  • Deploy the function to AWS using Terraform.
  • Invoke the function from your terminal and confirm the response.
Write the Lambda function code

Lambda functions need their code packaged in a specific directory that Terraform can zip and upload. You'll create a dedicated folder for the function and write its handler.

  • Create the function's directory by running this command in your terminal:
mkdir hello-world
  • Inside the hello-world folder, create a new file called hello.js and paste the following code:
// Export the handler function that Lambda will invoke
module.exports.handler = async (event) => {
  console.log('Event: ', event);
  let responseMessage = 'Hello, World!';

  // Return a structured HTTP response
  return {
    statusCode: 200,
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      message: responseMessage,
    }),
  };
};

What does this code do?

  • module.exports.handler exports an async function that Lambda calls whenever the function is invoked.
  • The function returns a JSON object with a statusCode, headers, and a body. This is the standard response format for Lambda functions that sit behind an API.
  • console.log('Event: ', event) logs the incoming event to CloudWatch, which helps with debugging later.
  • Save hello.js.
  • Confirm the file was created by running:
ls hello-world

You should see hello.js in the output.

Don't see hello.js?

  • Make sure you created hello.js inside the hello-world folder, not in the project root.
  • Check that the folder name is hello-world (with a hyphen), not helloworld or hello_world.

Help me debug why my hello.js file isn't showing up in the hello-world directory.

✔️ Awesome, I've got everything!

Great. Make sure you've saved hello.js before moving on.

ⓧ I'd like to double check the full code

Here is the complete hello-world/hello.js file. Compare it against yours:

module.exports.handler = async (event) => {
  console.log('Event: ', event);
  let responseMessage = 'Hello, World!';

  return {
    statusCode: 200,
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      message: responseMessage,
    }),
  };
};
Add the Lambda resources to your Terraform configuration

Deploying a Lambda function requires more than just the code. Terraform needs to package the code into a zip file, create an IAM role that gives the function permission to run, and then create the Lambda function itself.

  • In main.tf, find the provider "aws" block. Add the following code below it:
# Zip the Lambda function code for deployment
data "archive_file" "lambda_hello_world" {
  type = "zip"

  source_dir  = "${path.module}/hello-world"
  output_path = "${path.module}/hello-world.zip"
}

# IAM role that Lambda assumes when the function runs
resource "aws_iam_role" "lambda_exec" {
  name = "serverless_lambda"

  # Policy allowing the Lambda service to use this role
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Sid    = ""
      Principal = {
        Service = "lambda.amazonaws.com"
      }
    }]
  })
}

What does this code do?

  • The archive_file data source takes your hello-world directory and packages it into a hello-world.zip file that Lambda can accept.
  • The aws_iam_role creates an IAM role named serverless_lambda. The assume_role_policy tells AWS that only the Lambda service is allowed to use this role.
  • Now add the policy attachment and the Lambda function itself below the aws_iam_role block:
# Attach the basic execution policy so Lambda can write logs
resource "aws_iam_role_policy_attachment" "lambda_policy" {
  role       = aws_iam_role.lambda_exec.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

# The Lambda function resource
resource "aws_lambda_function" "hello_world" {
  function_name = "HelloWorld"

  # Point to the zip created by the archive_file data source
  filename         = data.archive_file.lambda_hello_world.output_path
  source_code_hash = data.archive_file.lambda_hello_world.output_base64sha256

  runtime = "nodejs22.x"
  handler = "hello.handler"

  role = aws_iam_role.lambda_exec.arn
}

What does this code do?

  • aws_iam_role_policy_attachment attaches the AWSLambdaBasicExecutionRole managed policy to the role. This grants Lambda permission to write logs to CloudWatch.
  • aws_lambda_function creates the actual Lambda function named HelloWorld. It uses the nodejs22.x runtime and points to the handler function exported from hello.js.
  • source_code_hash tracks changes to your zip file. When you update your code later, Terraform detects the hash change and redeploys the function automatically.
  • Save main.tf.

✔️ Awesome, I've got everything!

Great. Make sure you've saved main.tf before running the next command.

ⓧ I'd like to double check the full code

Your main.tf should now have these blocks in order. Scroll up and confirm each one is present:

  • The terraform block with required_providers (from Step 1).
  • The provider "aws" block with region = "us-east-1" (from Step 1).
  • The archive_file data source.
  • The aws_iam_role resource with the assume-role policy.
  • The aws_iam_role_policy_attachment resource.
  • The aws_lambda_function resource.
  • Deploy your Lambda function by running:
terraform apply

What happens when you run this?

Terraform shows you a plan listing every resource it will create. At the bottom, it asks for confirmation:

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value:

Type yes and press Enter to proceed.

Terraform creates the IAM role, attaches the policy, and deploys the Lambda function. You should see a summary ending with Apply complete! and a count of resources added.

Seeing an error during apply?

  • If you see a credentials error, make sure your AWS CLI is configured. Run aws sts get-caller-identity to verify your credentials are working.
  • If you see a syntax error in main.tf, double check that every opening brace has a matching closing brace. Use the double-check tab above to compare your file.
  • If the error mentions the hello-world directory not being found, confirm that the hello-world folder is in the same directory as main.tf.

Help me debug my terraform apply error for deploying a Lambda function.

Invoke your Lambda function

Your function is live on AWS. Before you build an API around it, you should confirm it actually works by calling it directly from your terminal using the AWS CLI.

Before you run the next command, what do you think the function will return? Think about the responseMessage variable you set in hello.js.

  • Invoke the function and view its response by running this command:
aws lambda invoke --region=us-east-1 --function-name=HelloWorld response.json && cat response.json

What does this command do?

  • aws lambda invoke calls your Lambda function directly through the AWS API, bypassing any gateway or URL.
  • --function-name=HelloWorld tells the CLI which function to invoke. This matches the function_name you set in main.tf.
  • The response payload is written to response.json, and cat response.json prints it so you can read it immediately.

You should see the JSON response your function returned:

{"statusCode":200,"headers":{"Content-Type":"application/json"},"body":"{\"message\":\"Hello, World!\"}"}

The statusCode of 200 confirms the function ran successfully. The body contains your greeting message.

Not seeing the expected response?

  • If you see a ResourceNotFoundException, the function name might be misspelled. The name is case-sensitive and must be exactly HelloWorld.
  • If the response contains an error about the handler, check that hello.js exports the handler with module.exports.handler. The filename and export name must match the hello.handler value in your Terraform config.
  • If you see a region error, make sure your AWS CLI default region matches us-east-1, or use the --region=us-east-1 flag as shown.

Help me debug my Lambda invoke error.

Your Lambda function is live and responding. Next up, you'll wire it to a public URL using API Gateway so anyone on the internet can call it.

Expose Your Function as a REST API

In the previous step, you deployed a Lambda function and confirmed it returns a JSON response. Right now, only someone with AWS CLI access and the right credentials can call it.

To turn this function into a proper REST API, you need a public URL that anyone can send HTTP requests to. API Gateway is the AWS service that creates that URL and routes incoming requests to your Lambda function.

In this step, get ready to:
  • Add API Gateway resources to your Terraform configuration.
  • Deploy the gateway and test your API's public URL.
Add the API Gateway and stage

An API Gateway HTTP API needs two foundational resources: the API itself (which defines the gateway) and a stage (which is the deployed instance of it that serves traffic).

  • In main.tf in Cursor, scroll to the bottom of the file (after the closing } of the aws_lambda_function resource) and add the following code:
# Create an HTTP API gateway
resource "aws_apigatewayv2_api" "lambda" {
  name          = "serverless_lambda_gw"
  protocol_type = "HTTP"
}

# Create a stage that auto-deploys changes
resource "aws_apigatewayv2_stage" "lambda" {
  api_id = aws_apigatewayv2_api.lambda.id

  name        = "serverless_lambda_stage"
  auto_deploy = true
}

What does this code do?

These two resources create the entry point for your public API.

  • aws_apigatewayv2_api creates an HTTP API named serverless_lambda_gw. The HTTP protocol type is the lightweight, low-cost option for Lambda integrations.
  • aws_apigatewayv2_stage creates a deployment stage. Setting auto_deploy = true means any future changes to routes or integrations go live automatically.
  • Save main.tf.
Connect the Lambda integration and route

The gateway exists, but it does not know where to send requests yet. You need an integration (the bridge between the gateway and Lambda) and a route (the URL pattern that triggers the integration).

  • In main.tf, add the following code below the stage resource you just added:
# Connect the Lambda function to the API gateway
resource "aws_apigatewayv2_integration" "hello_world" {
  api_id = aws_apigatewayv2_api.lambda.id

  integration_uri    = aws_lambda_function.hello_world.invoke_arn
  integration_type   = "AWS_PROXY"
  integration_method = "POST"
}

# Route GET /hello requests to the Lambda integration
resource "aws_apigatewayv2_route" "hello_world" {
  api_id = aws_apigatewayv2_api.lambda.id

  route_key = "GET /hello"
  target    = "integrations/${aws_apigatewayv2_integration.hello_world.id}"
}

# Output the public URL for the API
output "base_url" {
  description = "Base URL for API Gateway stage."
  value       = aws_apigatewayv2_stage.lambda.invoke_url
}

What does this code do?

These resources wire your Lambda function to the public URL.

  • aws_apigatewayv2_integration uses AWS_PROXY to pass the full HTTP request straight to Lambda and return Lambda's response directly to the caller.
  • aws_apigatewayv2_route maps GET /hello requests to the Lambda integration. Any other path or method returns a 404.
  • The base_url output prints the public URL so you can test it from the terminal.
  • Save main.tf.
  • Deploy the new resources by running this command in your terminal:
terraform apply

What does Terraform show you?

Terraform displays a plan showing 4 resources to add: the API, the stage, the integration, and the route. Type yes when prompted to confirm.

After the apply finishes, look for the base_url output at the bottom. It looks something like https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/serverless_lambda_stage. This is your API's public URL.

Seeing an error during apply?

  • Check that you pasted the new resources after the closing } of aws_lambda_function, not inside it.
  • Make sure every opening { has a matching closing }. A missing brace is the most common syntax error.
  • help me debug my terraform apply error

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf and the base_url output is visible in your terminal.

ⓧ I'd like to double check the full code

Your main.tf should now contain these resource blocks in order. Scroll up and confirm each one is present:

  • The terraform block with required_providers (from Step 1).
  • The provider "aws" block (from Step 1).
  • The archive_file data source (from Step 2).
  • The aws_iam_role and aws_iam_role_policy_attachment resources (from Step 2).
  • The aws_lambda_function resource (from Step 2).
  • The aws_apigatewayv2_api and aws_apigatewayv2_stage resources (added just now).
  • The aws_apigatewayv2_integration and aws_apigatewayv2_route resources (added just now).
  • The base_url output block (added just now).

The hello-world/hello.js file has not changed in this step.

Test your public API endpoint

Your API Gateway is deployed and has a public URL. Before you test it, take a moment to think about what you expect to happen.

When you send an HTTP request to your /hello route, do you think you will get back the same JSON greeting your Lambda function returned earlier? Or could something go wrong?

  • Send a request to your API by running this command:
curl "$(terraform output -raw base_url)/hello"

The response is not what you hoped for.

{"message":"Internal Server Error"}

You got a 500 Internal Server Error instead of your greeting. This is not a mistake in your code. This is a real-world integration issue that happens all the time when connecting API Gateway to Lambda. Something is missing.

What just happened?

API Gateway knows it should forward requests to your Lambda function. But Lambda itself has not been told to accept calls from API Gateway. In AWS, every service must be explicitly granted permission to invoke another service. Without that permission, Lambda rejects the call and API Gateway returns a 500 error.

  • Verify the missing piece by running this command to list all resources Terraform is managing:
terraform state list

You should see resources like aws_apigatewayv2_api.lambda, aws_apigatewayv2_route.hello_world, and aws_lambda_function.hello_world. Notice there is no aws_lambda_permission resource anywhere in the list. That missing permission is the root cause of the 500 error.

Getting a different error?

  • If you see Not Found, check that you included /hello at the end of the URL. The base URL alone does not match any route.
  • If the command hangs or times out, confirm your terminal has internet access and that the base_url output is a valid URL starting with https://.
  • help me troubleshoot my API Gateway curl response

You have hit one of the most common API Gateway mistakes: the gateway is wired to call Lambda, but Lambda has no idea it should accept those calls. In the next step, you will add the missing permission and watch the same request succeed.

Fix the Integration and Get a Working API

In the last step, your API Gateway was fully wired to your Lambda function, but curl returned a 500 Internal Server Error. The gateway knows where to send requests, but Lambda has not been told to accept calls from API Gateway.

This is one of the most common real-world API Gateway mistakes. In this step, you will add the missing permission resource and set up CloudWatch logging so you can diagnose issues like this in the future.

In this step, get ready to:
  • Add the missing Lambda permission that fixes the 500 error.
  • Set up CloudWatch logging for your API and Lambda function.
  • Verify your API returns a successful response.
Add the Lambda permission and CloudWatch logging

AWS services follow the principle of least privilege. Even though API Gateway has an integration pointing at your Lambda function, Lambda itself has no policy saying "API Gateway is allowed to invoke me." That missing handshake is the entire cause of the 500 error.

  • Return to main.tf in Cursor.
  • Scroll to the bottom of the file and find the output "base_url" block.
  • Add the following three resource blocks above the output block:
# Log group for Lambda function execution logs
resource "aws_cloudwatch_log_group" "hello_world" {
  name = "/aws/lambda/${aws_lambda_function.hello_world.function_name}"

  retention_in_days = 30
}

# Log group for API Gateway access logs
resource "aws_cloudwatch_log_group" "api_gw" {
  name = "/aws/api_gw/${aws_apigatewayv2_api.lambda.name}"

  retention_in_days = 30
}

# Grant API Gateway permission to invoke the Lambda function
resource "aws_lambda_permission" "api_gw" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.hello_world.function_name
  principal     = "apigateway.amazonaws.com"

  source_arn = "${aws_apigatewayv2_api.lambda.execution_arn}/*/*"
}

What do these resources do?

  • aws_cloudwatch_log_group "hello_world" creates a log group at /aws/lambda/HelloWorld to store your Lambda function's execution logs. Lambda writes to this path automatically by convention.
  • aws_cloudwatch_log_group "api_gw" creates a log group for API Gateway access logs. You will connect this to the stage in the next chunk.
  • aws_lambda_permission "api_gw" is the fix. It tells Lambda to accept lambda:InvokeFunction calls from apigateway.amazonaws.com. The source_arn with /*/* scopes this to any stage and any route on your API.

Now you need to connect the API Gateway log group to your stage so access logs are actually written.

  • In main.tf, find the aws_apigatewayv2_stage resource. It currently looks like this:
resource "aws_apigatewayv2_stage" "lambda" {
  api_id = aws_apigatewayv2_api.lambda.id

  name        = "serverless_lambda_stage"
  auto_deploy = true
}
  • Add the access_log_settings block inside this resource, right after the auto_deploy = true line. The complete stage resource should now look like this:
resource "aws_apigatewayv2_stage" "lambda" {
  api_id = aws_apigatewayv2_api.lambda.id

  name        = "serverless_lambda_stage"
  auto_deploy = true

  access_log_settings {
    destination_arn = aws_cloudwatch_log_group.api_gw.arn

    format = jsonencode({
      requestId               = "$context.requestId"
      sourceIp                = "$context.identity.sourceIp"
      requestTime             = "$context.requestTime"
      protocol                = "$context.protocol"
      httpMethod              = "$context.httpMethod"
      resourcePath            = "$context.resourcePath"
      routeKey                = "$context.routeKey"
      status                  = "$context.status"
      responseLength          = "$context.responseLength"
      integrationErrorMessage = "$context.integrationErrorMessage"
    })
  }
}

What does access_log_settings do?

The access_log_settings block streams every API request to CloudWatch. The format field defines which context variables are logged, including $context.integrationErrorMessage.

This is the field that would have shown you exactly why the 500 error was happening. With this logging in place, future integration errors will be recorded with their root cause instead of a generic "Internal Server Error."

  • Save main.tf.
  • Deploy your changes by running this command in your terminal:
terraform apply
  • Terraform will prompt you to confirm. Type yes and press Enter.

Terraform should report that it is adding 3 new resources (the two log groups and the Lambda permission) and modifying 1 resource (the stage).

Seeing a validation error?

  • Make sure the access_log_settings block is inside the aws_apigatewayv2_stage resource block, not after the closing }. A misplaced brace is the most common cause.
  • Double-check that the aws_cloudwatch_log_group "api_gw" resource exists before the stage references it. Terraform resolves dependencies automatically, but the resource must be declared somewhere in the file.
  • Still stuck? Ask for help debugging your Terraform configuration.

✔️ Awesome, I've got everything!

Great. Double-check you have saved main.tf and that terraform apply completed successfully.

ⓧ I'd like to double check the full code

Your main.tf is now over 30 lines, so rather than showing the entire file, confirm these resources are present in your file in this order:

  • terraform block with required_providers for hashicorp/aws.
  • provider "aws" set to us-east-1.
  • data "archive_file" "lambda_hello_world" zipping the hello-world directory.
  • resource "aws_iam_role" "lambda_exec" with the Lambda assume-role policy.
  • resource "aws_iam_role_policy_attachment" "lambda_policy" attaching AWSLambdaBasicExecutionRole.
  • resource "aws_lambda_function" "hello_world" with runtime nodejs22.x.
  • resource "aws_cloudwatch_log_group" "hello_world" for Lambda logs.
  • resource "aws_apigatewayv2_api" "lambda" with protocol_type = "HTTP".
  • resource "aws_apigatewayv2_stage" "lambda" with auto_deploy = true and the access_log_settings block.
  • resource "aws_apigatewayv2_integration" "hello_world" with AWS_PROXY type.
  • resource "aws_apigatewayv2_route" "hello_world" with GET /hello.
  • resource "aws_cloudwatch_log_group" "api_gw" for API Gateway logs.
  • resource "aws_lambda_permission" "api_gw" granting apigateway.amazonaws.com the lambda:InvokeFunction action.
  • output "base_url" outputting the stage invoke URL.

Your hello-world/hello.js file is unchanged from the previous step.

Test the fixed API

The permission resource is deployed. Before you test, think about what you expect to happen this time.

  • Before you run the next command, predict: will the response still be a 500 error, or will it succeed now? Why?
  • Test your API by running this command:
curl "$(terraform output -raw base_url)/hello"

You should see the response:

{"message":"Hello, World!"}

That is a 200 OK. The same endpoint that returned a 500 error moments ago now works, because Lambda accepts the call from API Gateway.

What changed between the 500 and the 200?

Only one thing: the aws_lambda_permission resource. The API Gateway configuration, Lambda function code, and IAM role were all correct from the start. The missing piece was Lambda's resource-based policy allowing API Gateway to invoke it.

When you create a Lambda integration through the AWS Console, this permission is added automatically. When you use Terraform, the CLI, or CloudFormation, you must add it yourself. This is a real-world gotcha that catches even experienced engineers.

Still seeing a 500 error?

  • Confirm terraform apply completed successfully with no errors. If it failed, fix the error and re-run it.
  • Check that the principal in your aws_lambda_permission is apigateway.amazonaws.com (not a different service name).
  • Verify the source_arn uses execution_arn (not arn or invoke_url). The pattern /*/* must be at the end.
  • Still stuck? Ask for help debugging your API.

Your API is live and responding. Next up, you will add a second endpoint that accepts query parameters and returns a personalized greeting.

Add a Second Endpoint with Query Parameters

Your API is fully wired and working. Anyone can hit /hello and get a Hello, World! response back.

A single static response proves the wiring, but real APIs respond differently based on input. In this step, you'll update your Lambda function to read a query parameter from the URL and return a personalized greeting. Then you'll tear down the entire stack to prove Terraform can clean up everything it created.

In this step, get ready to:
  • Update the Lambda function to read query parameters from the request.
  • Test both the default and personalized greeting endpoints.
  • Tear down all resources with terraform destroy.
Update the Lambda function to handle query parameters

API Gateway's AWS_PROXY integration passes the full HTTP request to Lambda as the event object. Query string parameters (the ?key=value pairs in a URL) arrive in event.queryStringParameters.

  • In Cursor, open hello-world/hello.js.
  • Find this line:
  let responseMessage = 'Hello, World!';
  • Copy the following code directly below that line:

  // Check if the request includes a Name query parameter
  if (event.queryStringParameters && event.queryStringParameters['Name']) {
    responseMessage = 'Hello, ' + event.queryStringParameters['Name'] + '!';
  }

What does this code do?

The if statement first checks that event.queryStringParameters exists (it's null when no query string is provided). Then it checks for a Name key.

If both checks pass, the default Hello, World! greeting is overwritten with a personalized one. If no Name is provided, the original greeting stays.

  • Save hello.js.

✔️ Awesome, I've got everything!

Great. Double check you've saved hello.js and move on to deploy the update.

ⓧ I'd like to double check the full code

Here is the complete hello-world/hello.js file. Make sure yours matches:

module.exports.handler = async (event) => {
  console.log('Event: ', event);
  let responseMessage = 'Hello, World!';

  if (event.queryStringParameters && event.queryStringParameters['Name']) {
    responseMessage = 'Hello, ' + event.queryStringParameters['Name'] + '!';
  }

  return {
    statusCode: 200,
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      message: responseMessage,
    }),
  };
};

How does Terraform know the code changed?

The source_code_hash attribute on the aws_lambda_function resource computes a hash of the zip file. When you change hello.js, the zip changes, the hash changes, and Terraform detects the difference.

Without source_code_hash, Terraform would see the same filename and skip the update entirely.

  • Deploy the updated function by running:
terraform apply
  • Terraform will show you a plan and ask for confirmation. Type yes and press Enter.

You should see Terraform report that it changed one resource (the Lambda function).

Terraform says no changes?

This means Terraform did not detect a code change. Make sure you saved hello.js after adding the if block.

help me figure out why terraform apply says no changes after I edited hello.js

Test both endpoints
  • Confirm the original endpoint still works by running:
curl "$(terraform output -raw base_url)/hello"

You should see {"message":"Hello, World!"}. The default greeting is unchanged.

Now for the real test. Before you run the next command, what do you think will happen when you add ?Name=Terraform to the URL?

  • Test the personalized greeting by running:
curl "$(terraform output -raw base_url)/hello?Name=Terraform"

You should see {"message":"Hello, Terraform!"}. Your Lambda function is reading the query parameter and returning a personalized response.

Still seeing Hello, World! for both?

Lambda might still be running the old code. It can take a few seconds for the update to propagate. Wait 10 seconds and try the curl command again.

If it still returns the default greeting, double check that your if block in hello.js uses event.queryStringParameters['Name'] with a capital N. Query parameter names are case-sensitive.

my curl request to /hello?Name=Terraform still returns Hello World instead of Hello Terraform

Tear down all resources

Your API works with two behaviors from a single endpoint. The final step is proving that Terraform can clean up everything it created, leaving zero resources behind.

Before you run the next command, how many resources do you think Terraform will destroy?

  • Tear down all resources by running:
terraform destroy
  • Terraform will list every resource it plans to destroy and ask for confirmation. Type yes and press Enter.

You should see Terraform report that it destroyed all resources. The count matches every resource you defined in main.tf: the Lambda function, IAM role, policy attachment, CloudWatch log groups, API Gateway API, stage, integration, route, and the Lambda permission.

What about costs?

With all resources destroyed, there are zero ongoing charges. Lambda and API Gateway are serverless, so they only cost money when invoked. Now that they no longer exist, there is nothing left to bill.

Seeing an error during destroy?

If Terraform reports a dependency error, run terraform destroy again. Some resources take a moment to release their dependencies.

Make sure your AWS CLI credentials are still valid. If your session has expired, re-authenticate and try again.

help me fix an error when running terraform destroy on my Lambda and API Gateway resources

Secret mission

Add API Key Authentication

Your API is currently open to anyone with the URL. In this secret mission, you will switch from an HTTP API to a REST API, add API key authentication and a usage plan with rate limiting, and prove that unauthenticated requests are rejected.

Clean Up Your Resources

Clean Up Your Resources

You already ran terraform destroy at the end of this project, so all AWS resources have been removed. The only decision left is what to do with your local project files.

Resources you used:

  • AWS Lambda function (HelloWorld) with IAM role and policy attachment.
  • API Gateway with stage, integration, and routes.
  • CloudWatch log groups for Lambda and API Gateway.
  • Lambda permission for API Gateway invocation.
  • Local project files (main.tf, hello-world/hello.js, Terraform state files, and provider cache).

Keep everything running

No action needed. Choose this if you want to keep your project files on hand for reference or future redeployment.

  • All AWS resources were already removed when you ran terraform destroy. There are no ongoing charges.
  • Your local project files remain on your Desktop for reference.
  • To redeploy the infrastructure later, run terraform apply from your project folder.

Pause - I'll come back to this later

These serverless resources have no idle cost. They only incur charges when called.

  • Since you already ran terraform destroy, all AWS resources are removed. There is nothing to pause.
  • Your local project files remain on your Desktop for when you want to pick this back up.
  • When you are ready to redeploy, run terraform apply from your project folder.

Delete - I don't want to use this again

Remove all local project files to start fresh. All AWS resources were already removed by terraform destroy.

  • Delete your project folder from your Desktop (the folder containing main.tf and the hello-world directory) by running this command:

macOS

cd ~/Desktop
rm -rf [[PROJECT_DIR="your project folder name"]]

Windows

cd ~\Desktop
Remove-Item -Recurse -Force [[PROJECT_DIR="your project folder name"]]
  • Confirm the folder is gone by running ls (macOS) or dir (Windows). Your project folder should no longer appear in the output.

Nice Work!

Nice Work!

You just built a live, multi-endpoint REST API on AWS and managed every piece of it through Terraform. From writing your first provider block to tearing down the entire stack with a single command, you now know how to build and troubleshoot real cloud infrastructure as code.

You've learned how to:

  • Deploy a serverless REST API using Terraform, wiring together Lambda, API Gateway, and IAM resources from scratch.
  • Diagnose and fix a real 500 Internal Server Error caused by a missing Lambda invocation permission, tracing the root cause and applying the fix.
  • Manage the full infrastructure lifecycle with terraform plan, terraform apply, and terraform destroy to deploy, update, and tear down your entire API stack with a single command.
  • Secret Mission: Added API key authentication and rate limiting using API Gateway usage plans.

Ready to quiz yourself?