Deploy a Serverless API with Terraform

Provision a live AWS Lambda API from scratch using Terraform Infrastructure as Code.

Introduction

30 Second Summary

Imagine if every time you needed a new server or API, you could describe what you want in a text file and have it appear automatically. No clicking through dashboards, no forgetting steps, no mystery configurations that only one person on the team understands.

In this project, you will write Terraform code from scratch to deploy a serverless API on AWS. You will learn how Infrastructure as Code turns your cloud setup into a repeatable, version-controlled process.

What You'll Build

A serverless HTTP API powered by AWS Lambda and API Gateway, provisioned entirely through Terraform configuration you write line by line.

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

  • A complete Terraform configuration that provisions an S3 bucket, Lambda function, IAM role, and API Gateway.
  • A live public URL that anyone can hit to invoke your serverless function and receive a JSON response.
  • A full understanding of the Terraform workflow (init, plan, apply, destroy) and how each command manages your infrastructure.
  • Secret Mission: Add a second Lambda function with a different route and deploy both through the same Terraform configuration.

Are there any prerequisites?

You will need an AWS account with the AWS CLI configured, and Terraform installed on your machine. Step 1 walks you through verifying both tools are ready to go.

Set Up and Verify Your Tools

Before writing any Terraform code, you need to make sure your tools can talk to AWS correctly. Terraform manages your cloud resources, and the AWS CLI provides the credentials Terraform uses behind the scenes.

In this step, you will run quick checks to confirm everything is connected and set your AWS region for the project.

In this step, get ready to:
  • Confirm Terraform is at version 1.2.0 or higher.
  • Verify your AWS CLI credentials and set your region to us-east-1.
  • Open a terminal inside your project directory.
Confirm your Terraform version

Terraform needs to be at version 1.2.0 or higher for this project. The version constraint in your configuration will enforce this, so a quick check now saves you from confusing errors later.

  • Confirm your Terraform version by running this command in your terminal:
terraform -version

You should see output showing Terraform v1.2.0 or higher (the latest is v1.15.6).

Seeing a version below 1.2.0?

If your version is older than 1.2.0, update Terraform by running these two commands:

brew tap hashicorp/tap
brew install hashicorp/tap/terraform

After the install finishes, run terraform -version again to confirm the update.

help me update Terraform to the latest version

Verify your AWS credentials and set your region

Terraform uses the same credentials as the AWS CLI. You need to confirm those credentials work and that your default region is set to us-east-1, where you will deploy all resources.

  • Verify your AWS credentials are working by running this command:
aws sts get-caller-identity

You should see a JSON response containing your AWS account ID and user ARN.

What does this output mean?

The Account field is your 12-digit AWS account number. The Arn field shows the IAM user or role Terraform will use to create resources on your behalf.

  • Check your current default region by running:
aws configure get region
  • If the output does not show us-east-1, set it by running:
aws configure set region us-east-1

Why us-east-1?

This project uses us-east-1 because it is AWS's oldest and most feature-complete region. All services used in this project (Lambda, API Gateway, S3) are available there, and it matches the region configured in the Terraform provider block you will write next.

Getting a credentials error?

If aws sts get-caller-identity returns an error about expired or missing credentials, run aws configure and re-enter your Access Key ID, Secret Access Key, and set the default region to us-east-1.

help me fix my AWS CLI credentials

Open a terminal in your project directory

Your terraform-lambda-api folder is already open in VS Code. Now you need a terminal positioned inside that folder so every command you run targets the right place.

  • In VS Code, open the integrated terminal by pressing Ctrl+` (macOS and Windows).
  • Alternatively, click Terminal in the top menu bar, then click New Terminal.

The terminal opens at the root of your workspace, which is the terraform-lambda-api folder.

  • Confirm you are in the correct directory by running:
pwd

You should see a path ending in terraform-lambda-api.

Terminal opened in the wrong folder?

If pwd shows a different path, navigate to the correct folder by running:

cd ~/Desktop/terraform-lambda-api

help me navigate to my terraform-lambda-api folder

Your tools are verified and your terminal is ready. Next up, you will write your first Terraform configuration and watch it create real infrastructure on AWS.

Write the Terraform Provider and S3 Bucket Configuration

Your tools are verified and your project directory is ready. Now it's time to write your first Terraform configuration from scratch.

Every Terraform project starts with a configuration file that declares which providers it needs. Providers are plugins that let Terraform talk to cloud platforms like AWS. You'll set up the AWS provider, then define an S3 bucket that will store your Lambda function code in later steps.

In this step, get ready to:
  • Configure the Terraform providers block in main.tf.
  • Run the Terraform workflow (init, plan, apply) for the first time.
  • Verify the S3 bucket exists in your AWS account.
Create the main.tf configuration file

Terraform reads .tf files in your working directory to determine what infrastructure to manage. You'll create a single file called main.tf that holds all your resource definitions for now.

  • In VS Code's Explorer sidebar, hover over the terraform-lambda-api folder name and click the New File icon.
  • Name the file main.tf and press Enter.
  • Add the following Terraform block to declare your required providers:
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
    archive = {
      source  = "hashicorp/archive"
      version = "~> 2.4"
    }
  }

  required_version = ">= 1.2.0"
}

What does this code do?

The terraform block is your project's dependency manifest. It tells Terraform which provider plugins to download before it can manage any resources.

  • hashicorp/aws lets Terraform create and manage AWS resources (S3 buckets, Lambda functions, IAM roles).
  • hashicorp/random generates random values for unique naming (you'll use it for the bucket name).
  • hashicorp/archive creates zip files from source code (needed later for packaging your Lambda function).
  • required_version = ">= 1.2.0" ensures anyone running this config has a compatible Terraform version.
  • Below the closing } of the terraform block, add the AWS provider configuration and your first two resources:
provider "aws" {
  region = "us-east-1"
}

resource "random_pet" "lambda_bucket_name" {
  prefix = "terraform-lambda"
  length = 4
}

resource "aws_s3_bucket" "lambda_bucket" {
  bucket = random_pet.lambda_bucket_name.id
}

What does this code do?

The provider "aws" block configures the AWS plugin to deploy resources in the us-east-1 region. Every AWS resource you define will be created there.

  • random_pet.lambda_bucket_name generates a random name like terraform-lambda-quietly-purely-sadly-keen-cub. S3 bucket names must be globally unique, so a random name avoids collisions with other AWS users.
  • aws_s3_bucket.lambda_bucket creates the actual S3 bucket using that random name. This bucket will store your Lambda function's zipped code.
  • Save main.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).

You won't see a change yet. The next substep runs terraform init to download the providers and bring this configuration to life.

Seeing red squiggles in VS Code?

VS Code may underline HCL syntax because it doesn't recognize .tf files by default. This doesn't affect Terraform. You can optionally install the HashiCorp Terraform extension from the Extensions sidebar for syntax highlighting.

Why is my main.tf showing errors in VS Code?

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here's the complete main.tf at this point:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
    archive = {
      source  = "hashicorp/archive"
      version = "~> 2.4"
    }
  }

  required_version = ">= 1.2.0"
}

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

resource "random_pet" "lambda_bucket_name" {
  prefix = "terraform-lambda"
  length = 4
}

resource "aws_s3_bucket" "lambda_bucket" {
  bucket = random_pet.lambda_bucket_name.id
}
Initialize and apply the Terraform configuration

Terraform uses a three-command workflow: init downloads providers, plan previews changes, and apply creates real resources. You'll run all three now.

  • Download the provider plugins by running this command in your terminal:
terraform init
  • You should see output ending with Terraform has been successfully initialized! along with messages showing each provider being installed.

What did terraform init do?

Terraform read your main.tf file, found the three required providers, and downloaded their plugin binaries into a hidden .terraform directory. It also created a .terraform.lock.hcl file that locks the exact provider versions for reproducible builds.

Seeing an error during init?

If you see a network error, check your internet connection. Terraform needs to reach the Terraform Registry to download providers.

If you see a version constraint error, make sure you copied the required_providers block exactly as shown above.

Help me debug my terraform init error

  • Preview what Terraform will create by running:
terraform plan

What does terraform plan show?

The plan output shows a + symbol next to each resource Terraform will create. You should see two resources marked for creation: the random_pet name generator and the aws_s3_bucket. No real infrastructure changes happen yet.

  • Create the resources for real by running:
terraform apply

Terraform will ask for confirmation

After showing the plan, Terraform displays a confirmation prompt. Type yes (the full word, not just y) and press Enter to proceed.

  • Type yes and press Enter to confirm.
  • You should see output ending with Apply complete! Resources: 2 added, 0 changed, 0 destroyed. confirming both resources were created.

Apply failed with an AWS error?

If you see an authentication error, your AWS CLI credentials may have expired. Run aws sts get-caller-identity to check. If it fails, re-run aws configure to refresh your credentials.

If you see a region error, make sure your provider "aws" block says region = "us-east-1" exactly.

Help me fix my terraform apply error

Verify the bucket exists in AWS
  • Confirm the S3 bucket was created by running:
aws s3 ls | grep lambda
  • You should see a line containing your bucket name, something like terraform-lambda-quietly-purely-sadly-keen-cub (your random words will be different).

Terraform also created a terraform.tfstate file in your project directory. This is how Terraform remembers what it has created.

What is terraform.tfstate?

The state file is Terraform's memory. It maps each resource block in your code to a real-world object in AWS (like a specific S3 bucket ARN). Without it, Terraform wouldn't know which resources it owns and which ones already existed.

Every time you run terraform apply or terraform destroy, Terraform reads this file to figure out what needs to change. You'll see it update as you add more resources in later steps.

Don't see your bucket in the output?

Make sure you typed yes (not just y) when Terraform asked for confirmation. Only the full word yes is accepted.

If terraform apply succeeded but aws s3 ls shows nothing, check that your AWS CLI region matches us-east-1. S3 is global, but the CLI may filter by region in some configurations.

Help me find my S3 bucket

Your S3 bucket is live and Terraform is tracking it. Next up, you'll create a Lambda function, package its code into that bucket, and wire up the IAM permissions it needs to run.

Create and Deploy the Lambda Function

Your S3 bucket is live on AWS, created entirely through Terraform code. Now it's time to write the actual function that will run in the cloud.

In this step, you'll create a Node.js Lambda handler, then configure Terraform to package it into a zip file, upload it to your S3 bucket, and deploy it as a Lambda function. You'll also set up the IAM permissions that Lambda needs to execute.

In this step, get ready to:
  • Write a Node.js Lambda handler function.
  • Configure Terraform to package and deploy the function to AWS.
  • Create IAM permissions and CloudWatch logging for the function.
Write the Lambda handler and add deployment resources

Every Lambda function needs a handler. This is the entry point that AWS calls when the function is invoked.

  • Create the hello-world directory and hello.js file by running these commands:
mkdir hello-world
touch hello-world/hello.js
  • Confirm the files exist by running:
ls hello-world

You should see hello.js in the output.

Don't see the file?

Make sure you ran mkdir hello-world before touch hello-world/hello.js. The directory must exist before you can create a file inside it.

Help me create the hello-world directory and file

  • Open hello-world/hello.js in VS Code and paste the following code:
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,
    }),
  }
}

What does this code do?

This is your Lambda handler function. When AWS invokes it, the function receives an event object containing request data.

It logs the event for debugging in CloudWatch, then returns an HTTP-style response with a 200 status code and a JSON body containing the greeting message.

  • Save hello-world/hello.js.

Now you need to tell Terraform how to package this code, upload it to S3, and create the Lambda function from it.

  • In main.tf, add the following code below the aws_s3_bucket resource block:
data "archive_file" "lambda_hello_world" {
  type = "zip"

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

resource "aws_s3_object" "lambda_hello_world" {
  bucket = aws_s3_bucket.lambda_bucket.id

  key    = "hello-world.zip"
  source = data.archive_file.lambda_hello_world.output_path

  etag = filemd5(data.archive_file.lambda_hello_world.output_path)
}

resource "aws_lambda_function" "hello_world" {
  function_name = "HelloWorld"

  s3_bucket = aws_s3_bucket.lambda_bucket.id
  s3_key    = aws_s3_object.lambda_hello_world.key

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

  source_code_hash = data.archive_file.lambda_hello_world.output_base64sha256

  role = aws_iam_role.lambda_exec.arn
}

What does this code do?

  • archive_file zips the hello-world/ directory into hello-world.zip. This is how Terraform packages your code for deployment.
  • aws_s3_object uploads the zip file to your S3 bucket. The etag uses an MD5 hash so Terraform detects when the file changes.
  • aws_lambda_function creates the actual Lambda function. It points to the zip in S3, specifies the nodejs22.x runtime, and uses hello.handler to tell Lambda which file and exported function to call.
  • source_code_hash triggers a redeployment whenever your code changes. Terraform compares this hash on each run.
  • Save main.tf.

You won't see a change yet. The Lambda function references an IAM role that doesn't exist yet.

Add the IAM role and CloudWatch log group

Lambda functions need an IAM role that grants them permission to execute and write logs. Without this role, AWS refuses to run your function.

  • In main.tf, add the following code below the aws_lambda_function resource block:
resource "aws_cloudwatch_log_group" "hello_world" {
  name = "/aws/lambda/${aws_lambda_function.hello_world.function_name}"

  retention_in_days = 30
}

resource "aws_iam_role" "lambda_exec" {
  name = "serverless_lambda"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Sid    = ""
      Principal = {
        Service = "lambda.amazonaws.com"
      }
    }]
  })
}

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

What does this code do?

  • aws_cloudwatch_log_group creates a dedicated log group for the function with a 30-day retention. Every console.log in your handler shows up here.
  • aws_iam_role creates a role named serverless_lambda. The assume role policy tells AWS that the Lambda service (lambda.amazonaws.com) is allowed to assume this role.
  • aws_iam_role_policy_attachment attaches the AWS-managed AWSLambdaBasicExecutionRole policy. This grants the minimum permissions Lambda needs: writing logs to CloudWatch.
  • Save main.tf.

Your configuration is complete. Time to deploy everything.

Deploy and test the Lambda function
  • Deploy all the new resources by running:
terraform apply
  • Terraform shows a plan with new resources to create. Type yes and press Enter to confirm.

What should I see?

Terraform lists each new resource marked with a + symbol. After you confirm, it creates the resources and finishes with a message showing resources added, 0 changed, 0 destroyed.

Seeing an error about the IAM role?

If you see an error referencing aws_iam_role.lambda_exec, make sure the IAM role resource block is in main.tf and that the resource name matches exactly. Terraform resolves references by name, so any typo breaks the connection.

Help me troubleshoot my terraform apply error

Now that your function is deployed, test it directly from the command line.

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

What does this command do?

  • aws lambda invoke calls the Lambda function synchronously and writes the function's response to response.json.
  • cat response.json prints the file contents so you can see what the function returned.

You should see the CLI print a status showing StatusCode: 200, followed by cat displaying the function's JSON response containing your greeting message.

Seeing a permission or not-found error?

  • If you see a ResourceNotFoundException, the function might not have finished deploying. Wait 10 seconds and try again.
  • If you see an AccessDeniedException, your AWS CLI credentials may not have permission to invoke Lambda functions. Check that the IAM user you configured with aws configure has the lambda:InvokeFunction permission.

Help me debug my Lambda invoke error

✔️ Awesome, I've got everything!

Great. Double check you've saved both files.

ⓧ I'd like to double check the full code

Here is the full hello-world/hello.js:

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,
    }),
  }
}

Here is the full main.tf:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
    archive = {
      source  = "hashicorp/archive"
      version = "~> 2.4"
    }
  }

  required_version = ">= 1.2.0"
}

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

resource "random_pet" "lambda_bucket_name" {
  prefix = "terraform-lambda"
  length = 4
}

resource "aws_s3_bucket" "lambda_bucket" {
  bucket = random_pet.lambda_bucket_name.id
}

data "archive_file" "lambda_hello_world" {
  type = "zip"

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

resource "aws_s3_object" "lambda_hello_world" {
  bucket = aws_s3_bucket.lambda_bucket.id

  key    = "hello-world.zip"
  source = data.archive_file.lambda_hello_world.output_path

  etag = filemd5(data.archive_file.lambda_hello_world.output_path)
}

resource "aws_lambda_function" "hello_world" {
  function_name = "HelloWorld"

  s3_bucket = aws_s3_bucket.lambda_bucket.id
  s3_key    = aws_s3_object.lambda_hello_world.key

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

  source_code_hash = data.archive_file.lambda_hello_world.output_base64sha256

  role = aws_iam_role.lambda_exec.arn
}

resource "aws_cloudwatch_log_group" "hello_world" {
  name = "/aws/lambda/${aws_lambda_function.hello_world.function_name}"

  retention_in_days = 30
}

resource "aws_iam_role" "lambda_exec" {
  name = "serverless_lambda"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Sid    = ""
      Principal = {
        Service = "lambda.amazonaws.com"
      }
    }]
  })
}

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

Your Lambda function is deployed and responding. Next up, you'll expose it to the internet through API Gateway so anyone can call it with a URL.

Expose the Lambda via API Gateway

Your Lambda function works, but right now the only way to call it is through the AWS CLI. That is fine for testing, but not for building a real API that anyone can reach.

In this step, you will create an API Gateway HTTP API that gives your Lambda function a public URL. When someone visits that URL, API Gateway routes the request to your function and returns the response.

In this step, get ready to:
  • Add API Gateway resources to your Terraform configuration.
  • Grant API Gateway permission to invoke your Lambda function.
  • Deploy and test a live public URL that returns your greeting.
Add the API Gateway HTTP API and stage

An HTTP API needs two core pieces: the API itself (the entry point) and a stage (a deployment environment that serves traffic). You will also configure access logging so you can troubleshoot requests later.

  • In main.tf, scroll to the bottom of the file (after the aws_iam_role_policy_attachment resource) and add the following API Gateway and stage configuration:
resource "aws_apigatewayv2_api" "lambda" {
  name          = "serverless_lambda_gw"
  protocol_type = "HTTP"
}

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 this code do?

The aws_apigatewayv2_api resource creates an HTTP API named serverless_lambda_gw. This is the front door that accepts incoming HTTP requests.

The aws_apigatewayv2_stage resource creates a deployment stage. Setting auto_deploy = true means any changes to the API are automatically published without needing a separate deployment step.

The access_log_settings block sends structured request logs to CloudWatch. You will create the log group it references in a later substep.

  • You won't see a change yet. The stage references a CloudWatch log group you have not created yet, so keep going to add the remaining resources before applying.
Add the integration and route

The API needs to know which Lambda to call and which URL path triggers the call. An integration connects the API to a backend service, and a route maps a specific HTTP method and path to that integration.

  • Still in main.tf, add the following integration and route resources below the stage you just added:
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"
}

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

What does this code do?

The integration uses AWS_PROXY type, which means API Gateway passes the entire HTTP request to Lambda and returns Lambda's response directly to the caller. The integration_method is always POST for Lambda proxy integrations regardless of the client-facing HTTP method.

The route maps GET /hello to the integration. When someone sends a GET request to /hello, API Gateway forwards it to your HelloWorld Lambda function.

  • You won't see a change yet. The next substep adds the remaining resources, then you will apply everything together.
Add the log group, permission, and outputs

Two more resources complete the API Gateway setup. A CloudWatch log group stores the access logs, and a Lambda permission resource grants API Gateway the right to invoke your function. Without the permission, API Gateway would receive an access denied error when calling Lambda.

  • Still in main.tf, add the CloudWatch log group and Lambda permission below the route:
resource "aws_cloudwatch_log_group" "api_gw" {
  name = "/aws/api_gw/${aws_apigatewayv2_api.lambda.name}"

  retention_in_days = 30
}

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 does this code do?

The aws_cloudwatch_log_group creates a log group that the stage's access_log_settings block references. Logs are retained for 30 days.

The aws_lambda_permission resource adds a resource-based policy to your Lambda function allowing apigateway.amazonaws.com to invoke it. The source_arn pattern /*/* allows any stage and any method on this API.

  • Save main.tf.

Now create a separate outputs file so Terraform prints your API's public URL after each apply.

  • Create a new file called outputs.tf in your terraform-lambda-api folder and add the following:
output "lambda_bucket_name" {
  description = "Name of the S3 bucket used to store function code."

  value = aws_s3_bucket.lambda_bucket.id
}

output "function_name" {
  description = "Name of the Lambda function."

  value = aws_lambda_function.hello_world.function_name
}

output "base_url" {
  description = "Base URL for API Gateway stage."

  value = aws_apigatewayv2_stage.lambda.invoke_url
}

What does this code do?

Terraform outputs let you extract values from your infrastructure after it is created. The base_url output gives you the invoke URL for your API Gateway stage. You will use this to curl your live API.

  • Save outputs.tf.

✔️ Awesome, I've got everything!

Great. Make sure both main.tf and outputs.tf are saved.

ⓧ I'd like to double check the full code

Here is the full main.tf at this point:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
    archive = {
      source  = "hashicorp/archive"
      version = "~> 2.4"
    }
  }

  required_version = ">= 1.2.0"
}

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

resource "random_pet" "lambda_bucket_name" {
  prefix = "terraform-lambda"
  length = 4
}

resource "aws_s3_bucket" "lambda_bucket" {
  bucket = random_pet.lambda_bucket_name.id
}

data "archive_file" "lambda_hello_world" {
  type = "zip"

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

resource "aws_s3_object" "lambda_hello_world" {
  bucket = aws_s3_bucket.lambda_bucket.id

  key    = "hello-world.zip"
  source = data.archive_file.lambda_hello_world.output_path

  etag = filemd5(data.archive_file.lambda_hello_world.output_path)
}

resource "aws_lambda_function" "hello_world" {
  function_name = "HelloWorld"

  s3_bucket = aws_s3_bucket.lambda_bucket.id
  s3_key    = aws_s3_object.lambda_hello_world.key

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

  source_code_hash = data.archive_file.lambda_hello_world.output_base64sha256

  role = aws_iam_role.lambda_exec.arn
}

resource "aws_cloudwatch_log_group" "hello_world" {
  name = "/aws/lambda/${aws_lambda_function.hello_world.function_name}"

  retention_in_days = 30
}

resource "aws_iam_role" "lambda_exec" {
  name = "serverless_lambda"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Sid    = ""
      Principal = {
        Service = "lambda.amazonaws.com"
      }
    }]
  })
}

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

resource "aws_apigatewayv2_api" "lambda" {
  name          = "serverless_lambda_gw"
  protocol_type = "HTTP"
}

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"
    })
  }
}

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

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

resource "aws_cloudwatch_log_group" "api_gw" {
  name = "/aws/api_gw/${aws_apigatewayv2_api.lambda.name}"

  retention_in_days = 30
}

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

And here is the full outputs.tf:

output "lambda_bucket_name" {
  description = "Name of the S3 bucket used to store function code."

  value = aws_s3_bucket.lambda_bucket.id
}

output "function_name" {
  description = "Name of the Lambda function."

  value = aws_lambda_function.hello_world.function_name
}

output "base_url" {
  description = "Base URL for API Gateway stage."

  value = aws_apigatewayv2_stage.lambda.invoke_url
}
Deploy and test your live API
  • Apply the new resources by running:
terraform apply
  • Terraform will show you a plan with several new resources to create. Type yes and press Enter to confirm.

After the apply completes, Terraform prints your outputs including the base_url. This is your live public URL.

  • Test your live API by running:
curl "$(terraform output -raw base_url)/hello"

You should see a JSON response containing your greeting message:

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

What is happening here?

The terraform output -raw base_url command extracts the API Gateway invoke URL from your Terraform state. Appending /hello matches the route key you defined, so API Gateway forwards the request to your HelloWorld Lambda function.

Not seeing the JSON response?

Make sure you typed yes during terraform apply and it completed without errors. Check the terminal output for any red error messages.

If you see Internal Server Error, double check that aws_lambda_permission.api_gw is present in your main.tf. Without it, API Gateway cannot invoke your function.

If curl returns nothing, verify the URL is correct by running terraform output base_url and confirming it starts with https://.

help me troubleshoot my API Gateway curl response

Your Lambda function now has a live public URL that anyone on the internet can hit. Next up, you will update the function to accept a query parameter and see how Terraform handles code changes in place.

Update the Function and Destroy Infrastructure

Your serverless API is live and responding to HTTP requests. Now comes the real power of Infrastructure as Code: updating deployed infrastructure by changing your code, not by clicking through a console.

In this step, you will modify the Lambda function to accept a query parameter, then watch Terraform detect and apply only the changed resources. Finally, you will tear down every resource with a single command.

In this step, get ready to:
  • Update your Lambda function to return personalized greetings.
  • Observe Terraform's change detection and in-place update workflow.
  • Destroy all AWS resources with a single command.
Add a query string parameter to the Lambda function

Right now, your function always returns "Hello, World!" regardless of who calls it. You will add logic to read a Name query string parameter from the incoming request event and personalize the greeting.

  • In VS Code, open hello-world/hello.js.
  • Find this line:
  let responseMessage = 'Hello, World!';
  • Add the following code directly below that line:
  if (event.queryStringParameters && event.queryStringParameters['Name']) {
    responseMessage = 'Hello, ' + event.queryStringParameters['Name'] + '!';
  }

What does this code do?

API Gateway passes query string parameters to your Lambda function inside the event object.

  • event.queryStringParameters && event.queryStringParameters['Name'] safely checks whether the caller included a Name parameter in the URL.
  • If the parameter exists, the greeting becomes personalized (e.g., "Hello, Terraform!").
  • If no parameter is passed, the original "Hello, World!" response still works.
  • Save hello-world/hello.js.

Not sure where to add the code?

The if block goes between the let responseMessage line and the return statement. Make sure there is a blank line separating the if block from the return below it.

Help me place the query string parameter code in hello.js

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before continuing.

ⓧ I'd like to double check the full code

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,
    }),
  }
}
Apply the update and observe change detection

Terraform tracks your Lambda's code using the source_code_hash attribute in main.tf. When you run terraform apply, it compares the current zip's hash against the stored state and updates only what changed.

  • Deploy the updated function by running:
terraform apply
  • Terraform will show a plan with resources marked ~ update in-place. Type yes and press Enter to confirm.

Why didn't Terraform recreate everything?

Terraform compares your desired state (the code you wrote) against the actual state (what exists in AWS). It only modifies resources whose attributes changed.

In this case, the S3 object's etag changed (new zip contents) and the Lambda function's source_code_hash changed. Everything else (IAM role, API Gateway, log groups) stays untouched.

  • You should see output ending with Apply complete! and a count showing resources changed (not added or destroyed).
  • Test the personalized greeting by running:
curl "$(terraform output -raw base_url)/hello?Name=Terraform"
  • You should see a JSON response containing "Hello, Terraform!" in the message field.

Still seeing "Hello, World!" instead?

API Gateway caches responses briefly. Wait 10-15 seconds and try the curl command again.

If it persists, confirm your terraform apply completed successfully and check that hello.js was saved before applying.

Help me debug why my Lambda isn't returning the personalized greeting

Destroy all infrastructure with a single command

The final piece of the Terraform workflow is teardown. Instead of hunting through the AWS Console to delete each resource one by one, you will remove everything in one command.

  • Destroy all the resources Terraform created by running:
terraform destroy
  • Terraform will list every resource it plans to destroy and ask Do you really want to destroy all resources? Type yes and press Enter.

What does terraform destroy do?

terraform destroy reads your state file to find every resource Terraform manages, then deletes them in the correct dependency order.

It removes the Lambda function, IAM role, S3 bucket, API Gateway, CloudWatch log groups, and all other resources. Your Terraform code files remain on disk so you can recreate everything with terraform apply at any time.

  • Wait for the output to show Destroy complete! with a count of resources destroyed.
  • Verify the Lambda function no longer exists by running:
aws lambda list-functions --region us-east-1
  • The output should show an empty Functions array. Your HelloWorld function is gone.

What happened to terraform.tfstate?

Open terraform.tfstate in VS Code. The file still exists, but its resources array is now empty. Terraform has nothing left to manage.

Destroy failed or timed out?

If a resource fails to delete (e.g., the S3 bucket is non-empty), Terraform will report the error and leave remaining resources intact. Run terraform destroy again. Terraform retries only the resources that failed.

Help me troubleshoot a failed terraform destroy

Secret mission

Add a Second Route and Lambda Function

Your API has one route. Real APIs have many. Can you duplicate and modify the Lambda + API Gateway pattern to serve a second endpoint, proving you can extend Infrastructure as Code configurations independently?

Clean Up Your Resources

Clean Up Your Resources

You already ran terraform destroy during the project, so all AWS resources (Lambda functions, API Gateway, S3 bucket, CloudWatch log groups, and IAM role) are already removed. The only remaining item is your local project directory.

Decide whether to keep your local files for future reference or delete them entirely.

Resources you used:

  • Local project directory terraform-lambda-api on your Desktop (still exists).
  • AWS Lambda functions (HelloWorld, GoodbyeWorld) already destroyed.
  • API Gateway HTTP API (serverless_lambda_gw) already destroyed.
  • S3 bucket, CloudWatch log groups, and IAM role already destroyed.

Keep everything running

No action needed. Choose this if you want to keep the Terraform code as a reference or portfolio piece.

  • Your AWS resources are already destroyed, so there are zero ongoing costs.
  • Your local terraform-lambda-api folder contains a complete, working Terraform project you can redeploy anytime with terraform apply.
  • Terraform itself remains installed on your machine via Homebrew.

Pause - I'll come back to this later

Pausing does not apply to this project. Serverless resources only cost money when invoked, and yours are already destroyed.

  • Your local files remain on disk and you can redeploy anytime by running terraform apply from inside the terraform-lambda-api directory.
  • No running processes or background services to stop.

Delete - I don't want to use this again

Remove all local project files and optionally uninstall Terraform. Your AWS resources are already gone.

  • Delete the local project directory by running this command:
rm -rf ~/Desktop/terraform-lambda-api
  • If you are on Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\terraform-lambda-api
  • Optionally uninstall Terraform if you no longer need it:
brew uninstall hashicorp/tap/terraform
  • Confirm the directory is gone by running ls ~/Desktop and verifying terraform-lambda-api no longer appears.

That's a Wrap!

That's a Wrap!

Nice work! You just built and deployed a serverless API on AWS, provisioned entirely through Terraform code you wrote from scratch.

You've learned how to:

  • Written Terraform configuration from scratch that provisions a complete serverless API including an S3 bucket, Lambda function, IAM role, and API Gateway.
  • Mastered the Terraform workflow (init, plan, apply, destroy) to manage cloud infrastructure as code.
  • Deployed a live Lambda function with API Gateway and a public URL that anyone can hit to invoke your function.
  • Secret Mission: Extended the infrastructure with a second function and route, proving you can scale IaC patterns independently.

Ready to quiz yourself?