Provision a Backend with Terraform

Deploy a serverless AWS backend for a couples widget app using Terraform.

Introduction

30 Second Summary

Every time you click through the AWS console to create a database, a function, or an API, you are doing work that cannot be easily repeated, shared, or undone. One misclick and your infrastructure drifts from what you intended.

In this project, you will use Terraform to define a serverless API backend entirely in code and deploy it to AWS with a single command.

What You'll Build

A serverless couples widget app backend on AWS, provisioned entirely through Infrastructure as Code using Terraform's HCL syntax.

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

  • A Terraform project with provider configuration, a DynamoDB table, a Lambda function, an IAM role, and an API Gateway HTTP API.
  • A live API endpoint that accepts POST and GET requests for couples widget data including love notes and countdown timers.
  • Hands-on experience with the complete Terraform workflow of init, plan, apply, and destroy.
  • Secret Mission: Add Terraform variables for environment configuration and a new mood status API endpoint to prove how easy it is to extend infrastructure as code.

Are there any prerequisites?

You need an AWS account with admin access and basic familiarity with the command line. No prior Terraform or Infrastructure as Code experience is required.

Install Terraform and Configure AWS

Instead of clicking through the AWS console to create resources one by one, you will define your entire backend in code files.

Terraform reads these code files and creates the actual cloud resources for you. In this step, you will set up your project, tell Terraform to use AWS as its provider, and initialize the project so Terraform downloads everything it needs.

In this step, get ready to:
  • Confirm your Terraform and AWS CLI setup.
  • Create a Terraform project with AWS provider configuration.
  • Initialize Terraform and download the AWS provider plugin.
Confirm your Terraform and AWS CLI setup

Before creating any project files, confirm that Terraform and the AWS CLI are ready to go on your machine.

  • Open your terminal.
  • Confirm Terraform is installed by running:
terraform -version

You should see output starting with Terraform v1.15 (or higher).

  • Confirm your AWS credentials are configured by running:
aws configure list

You should see your access key, secret key, and region (us-east-1) listed. Terraform uses these same credentials to create AWS resources on your behalf.

Command not found or missing credentials?

If terraform -version is not recognized, install Terraform from the official HashiCorp install page at developer.hashicorp.com/terraform/install.

If aws configure list shows no credentials, run aws configure and enter your AWS Access Key ID, Secret Access Key, and set the region to us-east-1.

Help me troubleshoot my Terraform or AWS CLI setup

Create the project directory and provider configuration

Every Terraform project starts with a .tf file that tells Terraform which cloud provider to use and how to connect to it.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir couples-widget-terraform
cd couples-widget-terraform
  • Confirm the folder was created by running:
ls

The folder will be empty for now. That is expected.

  • In VS Code, open the couples-widget-terraform folder by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the couples-widget-terraform folder on your Desktop, then click Open.
  • Create a new file by clicking File then New File.
  • Name the file main.tf and press Enter.

This is where all your infrastructure definitions will live. The .tf extension tells Terraform to read this file.

  • In main.tf, paste the following provider configuration:
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }

  required_version = ">= 1.15.0"
}

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

What does this code do?

This file has two blocks that tell Terraform how to set up:

  • terraform { } declares which provider plugin to download (hashicorp/aws version ~> 6.0) and the minimum Terraform version required (>= 1.15.0).
  • provider "aws" { } configures the AWS provider to create resources in the us-east-1 region. Terraform reads your existing AWS CLI credentials automatically.

What is HCL?

HCL stands for HashiCorp Configuration Language. It is the language Terraform uses to define infrastructure. It reads like a configuration file with blocks, arguments, and values.

  • Save main.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Seeing red squiggles in VS Code?

VS Code might not recognize .tf files by default. This does not affect Terraform. You can optionally install the HashiCorp Terraform extension from the Extensions sidebar for syntax highlighting.

Help me fix syntax issues in my main.tf file

Initialize the Terraform project

Terraform needs to download the AWS provider plugin before it can create any resources. The terraform init command reads your configuration and fetches everything Terraform needs.

  • In VS Code, open the integrated terminal by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Initialize Terraform by running this command:
terraform init

You should see output ending with Terraform has been successfully initialized!

What did terraform init do?

Terraform downloaded the AWS provider plugin and stored it in a hidden .terraform folder inside your project. It also created a .terraform.lock.hcl file that locks the exact provider version for consistency.

You only need to run terraform init once per project (or again if you add new providers).

  • Confirm the provider was downloaded by running:
ls .terraform/providers/registry.terraform.io/hashicorp/aws/

You should see a version folder (like 6.53.0 or similar) confirming the AWS provider plugin is ready.

terraform init failed?

Make sure you are running the command from inside the couples-widget-terraform folder. Run pwd to check your current directory.

If you see a provider error, check that your main.tf matches the code above exactly. A missing brace or typo in hashicorp/aws will cause the download to fail.

Help me troubleshoot my terraform init error

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the complete main.tf file so far:

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

  required_version = ">= 1.15.0"
}

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

Your Terraform project is initialized and connected to AWS. Next up, you will define your first real cloud resource: a DynamoDB table to store your couples widget data.

Create the DynamoDB Table

Your Terraform environment is configured and initialized. Now it is time to define your first real AWS resource in code.

The couples widget app needs a database to store shared data like love notes and countdown timers. You will define a DynamoDB table directly in your main.tf file and deploy it using the Terraform workflow.

In this step, get ready to:
  • Define a DynamoDB table resource in your Terraform configuration.
  • Preview the infrastructure changes with terraform plan.
  • Deploy the table to AWS with terraform apply.
Define the DynamoDB table resource

DynamoDB is a serverless NoSQL database from AWS. It stores data as items in tables, and each item is identified by a key. Your widgets table uses two keys: a partition key to group widgets by couple, and a sort key to distinguish widget types.

  • In main.tf, add this resource block below the provider "aws" block:
# DynamoDB Table
resource "aws_dynamodb_table" "widgets" {
  name         = "couples-widgets"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "widgetId"
  range_key    = "widgetType"

  attribute {
    name = "widgetId"
    type = "S"
  }

  attribute {
    name = "widgetType"
    type = "S"
  }

  tags = {
    Project = "couples-widget-app"
  }
}

What does this code do?

  • resource "aws_dynamodb_table" "widgets" tells Terraform to create a DynamoDB table. The label widgets is how you reference this resource elsewhere in your Terraform code.
  • billing_mode = "PAY_PER_REQUEST" means you only pay for the reads and writes you actually use. No capacity planning needed, and it stays within the AWS Free Tier for this project.
  • hash_key (partition key) groups items. range_key (sort key) distinguishes items within the same group. Together they form a unique identifier for each widget.
  • type = "S" means the attribute stores String values.
  • Save main.tf.

Seeing red squiggles in your editor?

Make sure every opening brace { has a matching closing brace }. HCL uses braces for blocks, so a missing one causes syntax errors across the file.

Also check that string values are wrapped in double quotes and that each attribute is on its own line.

help me fix a syntax error in my Terraform DynamoDB resource

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf before moving on.

ⓧ I'd like to double check the full code

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

  required_version = ">= 1.15.0"
}

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

# DynamoDB Table
resource "aws_dynamodb_table" "widgets" {
  name         = "couples-widgets"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "widgetId"
  range_key    = "widgetType"

  attribute {
    name = "widgetId"
    type = "S"
  }

  attribute {
    name = "widgetType"
    type = "S"
  }

  tags = {
    Project = "couples-widget-app"
  }
}
Preview the infrastructure changes with terraform plan

Before Terraform creates anything in AWS, you can preview exactly what it will do. The terraform plan command compares your code to the current state and shows you what will be added, changed, or destroyed.

  • Preview the changes Terraform will make by running this command:
terraform plan

What should I see?

Terraform prints a summary at the bottom of the output in the format: Plan: 1 to add, 0 to change, 0 to destroy. This tells you Terraform will create one new resource (your DynamoDB table) and will not modify or delete anything.

Above the summary, you will see a detailed breakdown of the table resource prefixed with a + symbol. The + means "create." Each attribute (name, billing mode, keys) is listed so you can verify it matches your intent.

Plan showing errors instead of a preview?

If you see a message about invalid HCL syntax, go back to main.tf and check for missing quotes, mismatched braces, or typos in attribute names like billing_mode.

If you see a credentials error, confirm your AWS CLI is still configured by running aws sts get-caller-identity.

help me fix my terraform plan error

Deploy the table with terraform apply

The plan looks good. Now tell Terraform to go ahead and create the table for real.

  • Deploy your DynamoDB table by running this command:
terraform apply

What is the confirmation prompt?

Terraform shows you the same plan output, then asks: Do you want to perform these actions? Type yes and press Enter to proceed. Only the exact word yes is accepted.

  • Type yes when prompted and press Enter.
  • Verify the table exists in AWS by running this command:
aws dynamodb describe-table --table-name couples-widgets

You should see a JSON response containing your table details, including TableStatus set to ACTIVE.

Why is this a big deal?

You just created a real AWS resource without ever opening the AWS console. The table definition lives in your main.tf file, which means you can version-control it, share it with teammates, and reproduce it in any AWS account.

This is the core promise of Infrastructure as Code: your infrastructure is reviewable, repeatable, and reversible.

Apply failed or table not showing?

If terraform apply failed with a permissions error, your AWS credentials may not have DynamoDB access. Check that your IAM user has the AdministratorAccess policy attached.

If the describe-table command returns an error about the table not existing, confirm your AWS CLI region matches us-east-1 by running aws configure get region.

help me troubleshoot my terraform apply failure

Your first Terraform-managed resource is live. Next up, you will write the Lambda function that reads and writes widget data to this table.

Write the Lambda Function and IAM Role

Your DynamoDB table is live and waiting for data. Now you need the brains of the operation: a Lambda function that knows how to read and write widget data.

In this step, you will write the Node.js handler that processes API requests, then define the IAM role and permissions that let Lambda interact with DynamoDB. Every permission is declared in code, so nothing is left to guesswork.

In this step, get ready to:
  • Write the Lambda handler that processes POST and GET requests for widget data.
  • Define the IAM role and policies that grant Lambda the minimum permissions it needs.
  • Define the Lambda function resource in Terraform and preview the deployment plan.
Create the Lambda function code

The Lambda function is where your API logic lives. It receives an event from API Gateway, checks whether the request is a POST or GET, and talks to DynamoDB accordingly.

  • Create a lambda folder inside your couples-widget-terraform project directory by running this command:
mkdir lambda
  • Create the handler file inside that folder by running:
touch lambda/index.mjs
  • Open lambda/index.mjs in VS Code and paste the following code:
import { DynamoDBClient, PutItemCommand, QueryCommand } from "@aws-sdk/client-dynamodb";

const client = new DynamoDBClient({});
const TABLE_NAME = process.env.TABLE_NAME;

export const handler = async (event) => {
  const method = event.requestContext.http.method;
  const path = event.rawPath;

  try {
    if (method === "POST" && path === "/widgets") {
      const body = JSON.parse(event.body);
      const { widgetId, widgetType, content } = body;

      await client.send(new PutItemCommand({
        TableName: TABLE_NAME,
        Item: {
          widgetId: { S: widgetId },
          widgetType: { S: widgetType },
          content: { S: content },
          createdAt: { S: new Date().toISOString() }
        }
      }));

      return {
        statusCode: 201,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message: "Widget created", widgetId, widgetType })
      };
    }

    if (method === "GET" && path.startsWith("/widgets/")) {
      const widgetId = event.pathParameters.widgetId;

      const result = await client.send(new QueryCommand({
        TableName: TABLE_NAME,
        KeyConditionExpression: "widgetId = :id",
        ExpressionAttributeValues: {
          ":id": { S: widgetId }
        }
      }));

      const items = result.Items.map(item => ({
        widgetId: item.widgetId.S,
        widgetType: item.widgetType.S,
        content: item.content.S,
        createdAt: item.createdAt.S
      }));

      return {
        statusCode: 200,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ widgets: items })
      };
    }

    return {
      statusCode: 404,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: "Route not found" })
    };
  } catch (error) {
    console.error("Error:", error);
    return {
      statusCode: 500,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: "Internal server error" })
    };
  }
};

What does this code do?

This handler exports a single async function that API Gateway will invoke for every request.

  • POST /widgets: parses the request body, extracts widgetId, widgetType, and content, then writes the item to DynamoDB with a timestamp.
  • GET /widgets/{widgetId}: queries DynamoDB for all widgets matching the given ID and returns them as JSON.
  • Any other route returns a 404. Any unhandled error returns a 500.

The AWS SDK v3 is bundled in the nodejs22.x Lambda runtime, so you do not need to install any packages separately.

  • Save lambda/index.mjs.
  • Confirm the file exists by running:
ls lambda/

You should see index.mjs listed in the output.

Don't see index.mjs?

  • Make sure you created the lambda folder first. The file must be at lambda/index.mjs, not in the project root.
  • Check the file extension is .mjs (ES module format), not .js.

Help me figure out why my lambda/index.mjs file isn't showing up.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

import { DynamoDBClient, PutItemCommand, QueryCommand } from "@aws-sdk/client-dynamodb";

const client = new DynamoDBClient({});
const TABLE_NAME = process.env.TABLE_NAME;

export const handler = async (event) => {
  const method = event.requestContext.http.method;
  const path = event.rawPath;

  try {
    if (method === "POST" && path === "/widgets") {
      const body = JSON.parse(event.body);
      const { widgetId, widgetType, content } = body;

      await client.send(new PutItemCommand({
        TableName: TABLE_NAME,
        Item: {
          widgetId: { S: widgetId },
          widgetType: { S: widgetType },
          content: { S: content },
          createdAt: { S: new Date().toISOString() }
        }
      }));

      return {
        statusCode: 201,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message: "Widget created", widgetId, widgetType })
      };
    }

    if (method === "GET" && path.startsWith("/widgets/")) {
      const widgetId = event.pathParameters.widgetId;

      const result = await client.send(new QueryCommand({
        TableName: TABLE_NAME,
        KeyConditionExpression: "widgetId = :id",
        ExpressionAttributeValues: {
          ":id": { S: widgetId }
        }
      }));

      const items = result.Items.map(item => ({
        widgetId: item.widgetId.S,
        widgetType: item.widgetType.S,
        content: item.content.S,
        createdAt: item.createdAt.S
      }));

      return {
        statusCode: 200,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ widgets: items })
      };
    }

    return {
      statusCode: 404,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: "Route not found" })
    };
  } catch (error) {
    console.error("Error:", error);
    return {
      statusCode: 500,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: "Internal server error" })
    };
  }
};
Define the IAM role for Lambda

Before Lambda can run, it needs an IAM role that grants it permission to execute and access your DynamoDB table. You will define the role, attach a managed policy for CloudWatch logging, and create an inline policy scoped to your widgets table.

  • Return to main.tf in VS Code.
  • Find the closing brace of the aws_dynamodb_table resource (after the tags block).
  • Add the IAM role resource below it by pasting the following code:
# IAM Role for Lambda
resource "aws_iam_role" "lambda_role" {
  name = "couples-widget-lambda-role"

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

What does this code do?

This creates an IAM role named couples-widget-lambda-role. The assume_role_policy tells AWS that the Lambda service is allowed to assume this role.

Without this trust relationship, Lambda could not use the role at all. It is the first building block of the permissions chain.

  • Directly below the IAM role, add the managed policy attachment and the inline DynamoDB policy:
resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "couples-widget-dynamodb-access"
  role = aws_iam_role.lambda_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "dynamodb:PutItem",
          "dynamodb:GetItem",
          "dynamodb:Query"
        ]
        Resource = aws_dynamodb_table.widgets.arn
      }
    ]
  })
}

What do these policies do?

  • AWSLambdaBasicExecutionRole is an AWS-managed policy that lets Lambda write logs to CloudWatch. Every Lambda function needs this.
  • The inline lambda_dynamodb policy grants only PutItem, GetItem, and Query on your specific widgets table. This follows the principle of least privilege.
  • The Resource line references aws_dynamodb_table.widgets.arn so Terraform automatically wires the correct table ARN.
  • Save main.tf.

You will not see a visible change yet. The next chunk defines the Lambda function that uses this role.

Seeing a syntax error in main.tf?

  • Check that you pasted the new code after the closing brace of the DynamoDB resource, not inside it.
  • Make sure every opening brace { has a matching closing brace }. The jsonencode blocks are easy to misclose.

Help me fix a syntax error in my main.tf IAM role section.

Define the Lambda function resource

Terraform needs to package your Lambda code into a zip file before it can upload it to AWS. A data block handles the zipping, and the aws_lambda_function resource handles the deployment.

  • Add the following code below the aws_iam_role_policy resource in main.tf:
# Lambda Function
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/lambda"
  output_path = "${path.module}/lambda.zip"
}

resource "aws_lambda_function" "widget_api" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "couples-widget-api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "index.handler"
  runtime          = "nodejs22.x"
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256

  environment {
    variables = {
      TABLE_NAME = aws_dynamodb_table.widgets.name
    }
  }
}

What does this code do?

  • data "archive_file" zips your lambda/ directory into lambda.zip. Terraform handles this automatically during plan and apply.
  • source_code_hash tells Terraform to redeploy the function whenever your code changes. Without it, Terraform would not detect code updates.
  • The environment block passes the DynamoDB table name to your Lambda handler via the TABLE_NAME environment variable.
  • runtime is set to nodejs22.x, which bundles the AWS SDK v3. No external package installs needed.
  • Save main.tf.
  • Preview what Terraform will create by running:
terraform plan

You should see a plan summary showing resources to add. Look for lines starting with + next to resources like aws_iam_role.lambda_role, aws_iam_role_policy.lambda_dynamodb, and aws_lambda_function.widget_api.

What does terraform plan show?

The plan output lists every resource Terraform intends to create, update, or destroy. Resources marked with + are new additions.

At the bottom, you will see a summary line like "Plan: 5 to add, 0 to change, 0 to destroy." This confirms Terraform knows about your new IAM role, policy attachment, inline policy, archive file, and Lambda function.

Seeing errors in terraform plan?

  • If you see a provider error, run terraform init again. The archive provider must be downloaded for the data "archive_file" block to work.
  • If you see a reference error mentioning aws_dynamodb_table.widgets, make sure the DynamoDB resource block is still intact at the top of your file.
  • If lambda/index.mjs is not found, confirm the lambda folder exists in the same directory as main.tf.

Help me fix my terraform plan errors for the Lambda and IAM resources.

✔️ Awesome, I've got everything!

Great. Make sure main.tf is saved before moving on.

ⓧ I'd like to double check the full code

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

  required_version = ">= 1.15.0"
}

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

# DynamoDB Table
resource "aws_dynamodb_table" "widgets" {
  name         = "couples-widgets"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "widgetId"
  range_key    = "widgetType"

  attribute {
    name = "widgetId"
    type = "S"
  }

  attribute {
    name = "widgetType"
    type = "S"
  }

  tags = {
    Project = "couples-widget-app"
  }
}

# IAM Role for Lambda
resource "aws_iam_role" "lambda_role" {
  name = "couples-widget-lambda-role"

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

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "couples-widget-dynamodb-access"
  role = aws_iam_role.lambda_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "dynamodb:PutItem",
          "dynamodb:GetItem",
          "dynamodb:Query"
        ]
        Resource = aws_dynamodb_table.widgets.arn
      }
    ]
  })
}

# Lambda Function
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/lambda"
  output_path = "${path.module}/lambda.zip"
}

resource "aws_lambda_function" "widget_api" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "couples-widget-api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "index.handler"
  runtime          = "nodejs22.x"
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256

  environment {
    variables = {
      TABLE_NAME = aws_dynamodb_table.widgets.name
    }
  }
}

Your Lambda function code is written, and Terraform now knows exactly how to package, deploy, and permission it. Next up, you will wire everything together with API Gateway so the outside world can reach your Lambda function over HTTP.

Create the API Gateway and Deploy

Your Lambda function and IAM role are defined in code, but there is no way for the outside world to reach them yet. API Gateway is the front door that receives HTTP requests and routes them to your Lambda function.

In this step you will define an HTTP API in Terraform, wire it to Lambda, deploy the entire stack with a single command, and prove it works by sending real requests.

In this step, get ready to:
  • Define the API Gateway HTTP API, integration, routes, stage, and Lambda permission in main.tf.
  • Add an output block and run terraform apply to deploy the full stack.
  • Test the live API with curl to confirm data round-trips through the serverless backend.
Define the API Gateway and Lambda integration

An HTTP API needs several connected pieces: the API itself, an integration that tells it where to forward requests, routes that match specific URL patterns, a stage that auto-deploys changes, and a permission that allows API Gateway to invoke Lambda.

  • In main.tf, add the following code below your aws_lambda_function resource block:
# API Gateway HTTP API
resource "aws_apigatewayv2_api" "widget_api" {
  name          = "couples-widget-api"
  protocol_type = "HTTP"
}

resource "aws_apigatewayv2_integration" "lambda_integration" {
  api_id             = aws_apigatewayv2_api.widget_api.id
  integration_type   = "AWS_PROXY"
  integration_uri    = aws_lambda_function.widget_api.invoke_arn
  integration_method = "POST"
}

resource "aws_apigatewayv2_route" "post_widget" {
  api_id    = aws_apigatewayv2_api.widget_api.id
  route_key = "POST /widgets"
  target    = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}"
}

resource "aws_apigatewayv2_route" "get_widget" {
  api_id    = aws_apigatewayv2_api.widget_api.id
  route_key = "GET /widgets/{widgetId}"
  target    = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}"
}

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

resource "aws_lambda_permission" "api_gateway" {
  statement_id  = "AllowAPIGatewayInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.widget_api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.widget_api.execution_arn}/*/*"
}

What does this code do?

Each resource block builds one piece of the API Gateway puzzle:

  • aws_apigatewayv2_api creates the HTTP API itself with the name couples-widget-api.
  • aws_apigatewayv2_integration connects the API to your Lambda function using an AWS_PROXY integration. This means API Gateway passes the full request to Lambda and returns Lambda's response directly.
  • The two aws_apigatewayv2_route blocks map POST /widgets and GET /widgets/{widgetId} to the Lambda integration.
  • aws_apigatewayv2_stage with auto_deploy = true means any route or integration change deploys automatically.
  • aws_lambda_permission grants API Gateway the right to invoke your Lambda function. Without this, requests would be blocked.
  • Save main.tf.

You won't see a visible change yet. The next substep adds an output block and deploys everything.

Seeing red squiggles in your editor?

Make sure the new code sits outside (below) the closing } of the aws_lambda_function block. Each resource block must be at the top level of the file, not nested inside another block.

help me fix a syntax error in my Terraform main.tf file

Add the output and deploy the full stack

Terraform outputs let you extract values from your deployed infrastructure. You will add an output that prints the API's public URL after deployment.

  • Add this output block at the bottom of main.tf, below the aws_lambda_permission resource:
# Outputs
output "api_endpoint" {
  description = "The URL of the couples widget API"
  value       = aws_apigatewayv2_stage.default.invoke_url
}

What does this output do?

After terraform apply finishes, Terraform prints any defined outputs to your terminal. The invoke_url attribute gives you the base URL of your HTTP API. You will use this URL to send requests with curl.

  • Save main.tf.
  • Deploy the entire stack by running this command in your terminal:
terraform apply

What happens during terraform apply?

Terraform shows you a plan of everything it will create, update, or destroy. It then asks you to confirm by typing yes. Only after you confirm does Terraform make changes to your AWS account.

This run will create the IAM role, Lambda function, API Gateway resources, and the Lambda permission. The DynamoDB table already exists from Step 2, so Terraform will not recreate it.

  • When prompted, type yes and press Enter to confirm.

Terraform provisions each resource in the correct dependency order. After it finishes, you should see a line that says Apply complete! followed by the api_endpoint output URL.

Apply failed with an error?

If you see an error about invalid references, double-check that the resource names in your code match exactly. For example, aws_apigatewayv2_api.widget_api.id depends on the resource being named widget_api in the resource line.

If you see a permissions or credentials error, confirm your AWS CLI is still configured by running aws sts get-caller-identity.

help me debug a terraform apply failure for my couples widget project

✔️ Awesome, I've got everything!

Great. Make sure you have copied the api_endpoint URL from the terminal output. You will need it for the next substep.

ⓧ I'd like to double check the full code

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

  required_version = ">= 1.15.0"
}

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

# DynamoDB Table
resource "aws_dynamodb_table" "widgets" {
  name         = "couples-widgets"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "widgetId"
  range_key    = "widgetType"

  attribute {
    name = "widgetId"
    type = "S"
  }

  attribute {
    name = "widgetType"
    type = "S"
  }

  tags = {
    Project = "couples-widget-app"
  }
}

# IAM Role for Lambda
resource "aws_iam_role" "lambda_role" {
  name = "couples-widget-lambda-role"

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

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "couples-widget-dynamodb-access"
  role = aws_iam_role.lambda_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "dynamodb:PutItem",
          "dynamodb:GetItem",
          "dynamodb:Query"
        ]
        Resource = aws_dynamodb_table.widgets.arn
      }
    ]
  })
}

# Lambda Function
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/lambda"
  output_path = "${path.module}/lambda.zip"
}

resource "aws_lambda_function" "widget_api" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "couples-widget-api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "index.handler"
  runtime          = "nodejs22.x"
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256

  environment {
    variables = {
      TABLE_NAME = aws_dynamodb_table.widgets.name
    }
  }
}

# API Gateway HTTP API
resource "aws_apigatewayv2_api" "widget_api" {
  name          = "couples-widget-api"
  protocol_type = "HTTP"
}

resource "aws_apigatewayv2_integration" "lambda_integration" {
  api_id             = aws_apigatewayv2_api.widget_api.id
  integration_type   = "AWS_PROXY"
  integration_uri    = aws_lambda_function.widget_api.invoke_arn
  integration_method = "POST"
}

resource "aws_apigatewayv2_route" "post_widget" {
  api_id    = aws_apigatewayv2_api.widget_api.id
  route_key = "POST /widgets"
  target    = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}"
}

resource "aws_apigatewayv2_route" "get_widget" {
  api_id    = aws_apigatewayv2_api.widget_api.id
  route_key = "GET /widgets/{widgetId}"
  target    = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}"
}

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

resource "aws_lambda_permission" "api_gateway" {
  statement_id  = "AllowAPIGatewayInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.widget_api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.widget_api.execution_arn}/*/*"
}

# Outputs
output "api_endpoint" {
  description = "The URL of the couples widget API"
  value       = aws_apigatewayv2_stage.default.invoke_url
}
Test the live API with curl

The best way to confirm your serverless stack works end-to-end is to send real HTTP requests. You will POST a love note widget, then GET it back to prove data flows through API Gateway, Lambda, and DynamoDB.

  • Send a POST request to create a widget by running this command (replace the URL with your own api_endpoint output):

macOS

curl -X POST [[API_ENDPOINT="your api_endpoint URL from terraform output"]]/widgets \
  -H "Content-Type: application/json" \
  -d '{"widgetId":"couple1","widgetType":"note","content":"I love you!"}'

Windows

curl.exe -X POST [[API_ENDPOINT="your api_endpoint URL from terraform output"]]/widgets `
  -H "Content-Type: application/json" `
  -d '{"widgetId":"couple1","widgetType":"note","content":"I love you!"}'

You should see a JSON response with status code 201 and a message confirming the widget was created:

{"message":"Widget created","widgetId":"couple1","widgetType":"note"}
  • Retrieve the widget you just created by running this command:

macOS

curl [[API_ENDPOINT="your api_endpoint URL from terraform output"]]/widgets/couple1

Windows

curl.exe [[API_ENDPOINT="your api_endpoint URL from terraform output"]]/widgets/couple1

You should see a JSON response containing the love note you just posted:

{"widgets":[{"widgetId":"couple1","widgetType":"note","content":"I love you!","createdAt":"2026-07-05T..."}]}

What just happened?

Your request traveled through three AWS services defined entirely in Terraform code. API Gateway received the HTTP request, forwarded it to Lambda via the proxy integration, and Lambda read from or wrote to DynamoDB. The data round-tripped successfully.

Getting a 500 Internal Server Error?

This usually means your Lambda function threw an error. Check CloudWatch Logs for the couples-widget-api function in the AWS console to see the full error message.

Common causes: the TABLE_NAME environment variable is missing (check the environment block in aws_lambda_function), or the IAM policy does not grant the required DynamoDB actions.

help me debug a 500 error from my couples widget API

Getting a 403 Forbidden?

This means API Gateway cannot invoke your Lambda function. Confirm that the aws_lambda_permission resource exists in your main.tf and that you ran terraform apply after adding it.

help me fix a 403 Forbidden error from API Gateway

Your entire serverless backend is live and working. Every resource was defined in code and deployed with a single terraform apply command. Next up, you will take this further in the Secret Mission by adding variables and a new mood endpoint.

Secret mission

Add Variables and a Mood Endpoint

Your infrastructure works, but it's rigid. Every resource name is hardcoded, so you can't easily spin up a staging copy or rename anything without editing every line. In this mission, you'll introduce Terraform variables and add a brand new API endpoint to prove how painless it is to extend infrastructure as code.

Clean Up Your Resources

Clean Up Your Resources

All the AWS resources in this project are covered by the AWS Free Tier. You will not be charged unless you exceed the free tier limits (which is unlikely from this project alone).

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • 1 DynamoDB table (couples-widget-dev-widgets).
  • 1 Lambda function (couples-widget-dev-api).
  • 1 API Gateway HTTP API (couples-widget-dev-api).
  • 1 IAM role with attached policies (couples-widget-dev-lambda-role).
  • Local project files (main.tf, variables.tf, lambda/index.mjs).

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the project further.

  • All resources remain within the AWS Free Tier. DynamoDB uses PAY_PER_REQUEST billing, Lambda charges per invocation, and API Gateway is free for 1 million calls per month for 12 months.
  • Your Terraform state file tracks everything you deployed. You can pick up exactly where you left off by returning to your project folder and running terraform plan.
  • Terraform CLI and AWS CLI remain installed on your machine.

Pause - I'll come back to this later

Since all resources are serverless and on-demand, there is nothing to "pause" in the traditional sense. They only incur usage when you send requests.

  • Your resources will sit idle at no cost within the free tier.
  • If you want to clean up test data, open the DynamoDB console, select the couples-widget-dev-widgets table, and delete any items you created during testing.
  • When you are ready to resume, return to your project folder and run terraform plan to check your infrastructure state.

Delete - I don't want to use this again

Remove all AWS resources with a single command, then delete your local project files. This is one of the key benefits of Infrastructure as Code.

  • In your terminal, navigate to your project folder (the one containing main.tf) and run:
terraform destroy

What does this command do?

Terraform reads your state file and generates a plan to delete every resource it manages. It shows you exactly what it will destroy and asks for confirmation before proceeding.

Type yes when prompted to confirm the destruction. Terraform will then remove the DynamoDB table, Lambda function, API Gateway, IAM role, and all associated policies from your AWS account.

  • Verify the resources are gone by checking the AWS console, or by running:
terraform show

You should see an empty state with no resources listed.

  • Delete your local project folder from the Desktop. On macOS or Linux, run:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your-project-folder-name"]]
  • On Windows, run:
cd ~\Desktop
Remove-Item -Recurse -Force [[PROJECT_FOLDER="your-project-folder-name"]]

This removes your Terraform files, Lambda code, state file, and the generated lambda.zip archive.

That's a Wrap!

That's a Wrap!

Nice work! You just built and deployed a complete serverless API backend on AWS using nothing but Terraform code.

You've learned how to:

  • Define AWS infrastructure as code using HCL and deploy it with the Terraform plan/apply workflow.
  • Wire together a serverless stack of DynamoDB, Lambda, and API Gateway with proper IAM permissions, all version-controlled in a single configuration file.
  • Test a live HTTP API that stores and retrieves couples widget data through a full request cycle from API Gateway to DynamoDB and back.
  • Secret Mission: Used Terraform variables for environment configuration and added a mood status endpoint, proving how easy it is to extend infrastructure as code.

Ready to quiz yourself?