Phishing Site Tracker API with Terraform
Build a serverless CRUD API on AWS to track phishing sites using Terraform.
Introduction
30 Second Summary
Phishing attacks are one of the most common cyber threats today. Security teams need quick, reliable ways to track and classify suspicious URLs as they get reported.
In this project, you will build a serverless CRUD API that tracks phishing sites by classification status. The entire infrastructure is provisioned through Terraform, meaning you can create and destroy your whole backend with a single command.
What You'll Build
A REST API backed by AWS Lambda, DynamoDB, and API Gateway HTTP API, fully defined in Terraform configuration files.
By the end of this project, you'll have:
- A Terraform configuration that provisions your entire serverless stack on AWS.
- A Python Lambda function that handles create, read, update, and delete operations for phishing site records.
- A live API endpoint you can test with curl to track URLs as malicious, safe, or pending.
- Secret Mission: Add a Global Secondary Index to filter phishing sites by classification status.
Do I need AWS and Terraform already set up?
Yes. This project assumes you have an AWS account with the CLI configured and Terraform installed. If you haven't set these up yet, complete those steps before starting.
Set Up Your Terraform Project
Every Terraform project starts with a directory and a provider configuration. The provider tells Terraform which cloud platform to manage and which plugin to download.
In this step, you'll scaffold your project folder, configure the AWS provider, and run terraform init so Terraform is ready to provision resources.
In this step, get ready to:
- Create the phishing-tracker project directory with a lambda/ subfolder.
- Configure the hashicorp/aws provider in main.tf.
- Run terraform init to download the provider plugin.
Create the project directory
Before writing any configuration, you need a folder structure that Terraform can work with. You'll create the project directory, a subfolder for your Lambda code, and empty placeholder files.
- Open a terminal in Cursor by pressing Ctrl+`.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir phishing-tracker
cd phishing-tracker
- Create the lambda/ subdirectory by running:
mkdir lambda
- Create the empty project files by running:
macOS
touch main.tf outputs.tf lambda/handler.py
Windows
New-Item main.tf
New-Item outputs.tf
New-Item lambda\handler.py
- Confirm the files exist by running:
ls
You should see main.tf and outputs.tf listed, along with the lambda folder.
Don't see the files?
Make sure you ran cd phishing-tracker before creating the files. If ls shows nothing, you're likely in the wrong directory.
help me find my project files
- Now open this folder in Cursor so you can edit the files. In Cursor, click File in the top menu bar.
- Click Open Folder.
- Navigate to Desktop > phishing-tracker and click Open.
You should see main.tf, outputs.tf, and the lambda folder in Cursor's Explorer sidebar on the left.
Configure the AWS provider
The terraform block tells Terraform which provider plugin to download. The provider block configures that plugin with your preferred AWS region.
- In Cursor's Explorer sidebar, click main.tf to open it.
- Paste the following code into main.tf:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
What does this code do?
- required_providers tells Terraform to download the hashicorp/aws plugin from the Terraform Registry.
- version = "~> 6.0" allows any 6.x release but prevents major version jumps that could introduce breaking changes.
- provider "aws" configures the plugin to deploy resources in the us-east-1 region.
- Save main.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Seeing red squiggly lines in Cursor?
Cursor may show syntax warnings before you initialize Terraform. These will disappear after you run terraform init in the next substep.
help me with syntax errors in my main.tf
Initialize Terraform
Running terraform init downloads the AWS provider plugin and sets up the working directory. This creates a hidden .terraform directory and a lock file that pins the exact provider version.
- Download the AWS provider by running this command in your terminal:
terraform init
You should see Terraform has been successfully initialized! at the end of the output.
What did terraform init create?
- .terraform/ is a hidden directory where Terraform stores the downloaded provider plugin.
- .terraform.lock.hcl is a lock file that records the exact provider version installed, ensuring consistent builds.
Seeing a provider error?
If you see an error about failing to query available provider packages, check that your internet connection is active. Terraform needs network access to download the provider from the registry.
If you see a credentials error, make sure your AWS CLI is configured correctly by running aws sts get-caller-identity in your terminal.
help me fix my terraform init error
✔️ Awesome, I've got everything!
Great. Double check you've saved main.tf and that your terminal shows the initialization success message.
ⓧ I'd like to double check the full code
Here is the complete main.tf for this step:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
Your outputs.tf and lambda/handler.py should still be empty files at this point.
Your project scaffold is ready and Terraform is connected to the AWS provider. Next up, you'll define the DynamoDB table that stores your phishing site records.
Define the DynamoDB Table
Your Terraform project is initialized and ready to provision resources. Now it's time to define the first piece of infrastructure: the database that stores phishing site records.
Every phishing site record needs a home. DynamoDB is a fully managed NoSQL database on AWS that requires zero server maintenance and scales automatically. It's a natural fit for a serverless API.
In this step, get ready to:
- Define a DynamoDB table resource in Terraform.
- Configure the table's primary key and billing mode.
- Validate your Terraform configuration.
Add the DynamoDB table resource
Your API needs a table to store phishing site records. Each record will be uniquely identified by a site_id field, which serves as the table's primary key (also called a hash key in DynamoDB).
- In Cursor, open main.tf from the file explorer sidebar.
- Add the following resource block below the provider "aws" block:
# DynamoDB Table
resource "aws_dynamodb_table" "phishing_sites" {
name = "phishing-sites"
billing_mode = "PAY_PER_REQUEST"
hash_key = "site_id"
attribute {
name = "site_id"
type = "S"
}
tags = {
Project = "phishing-tracker"
}
}
What does this code do?
- resource "aws_dynamodb_table" "phishing_sites" declares a DynamoDB table resource. The first string is the resource type, and the second (phishing_sites) is the name Terraform uses to reference it internally.
- billing_mode = "PAY_PER_REQUEST" sets the table to on-demand mode. You only pay for the read and write requests you actually make, with no upfront capacity planning needed.
- hash_key = "site_id" sets the primary key for the table. Every item you store must have a unique site_id value.
- The attribute block defines the key's data type. "S" means string.
- The tags block adds metadata so you can identify this table in the AWS console later.
What is a hash key?
A hash key is DynamoDB's version of a primary key. It uniquely identifies each item in the table. When you query for a specific phishing site record, DynamoDB uses the hash key to locate it instantly.
- Save main.tf.
- Verify your configuration is valid by running this command in your terminal:
terraform validate
You should see the message: Success! The configuration is valid.
Seeing a validation error?
- Check that every opening brace { has a matching closing brace }. The resource block, attribute block, and tags block each need their own pair.
- Make sure main.tf is saved. Terraform validates what's on disk, not what's unsaved in your editor.
- Confirm you're running the command from inside the phishing-tracker directory where .terraform lives.
Help me debug a terraform validate error for my DynamoDB table resource.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.tf and move on to the next step.
ⓧ I'd like to double check the full code
Here's the complete main.tf file as it should look at the end of this step:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
# DynamoDB Table
resource "aws_dynamodb_table" "phishing_sites" {
name = "phishing-sites"
billing_mode = "PAY_PER_REQUEST"
hash_key = "site_id"
attribute {
name = "site_id"
type = "S"
}
tags = {
Project = "phishing-tracker"
}
}
Your database is defined and your configuration is valid. Next up, you'll create the IAM role that gives your Lambda function permission to read and write to this table.
Create the Lambda IAM Role and Policy
Your DynamoDB table is ready to store phishing site records. Now your Lambda function needs permission to actually read and write to that table.
AWS uses IAM (Identity and Access Management) to control what each service can do. In this step, you'll create an IAM role that grants your Lambda function exactly the permissions it needs.
In this step, get ready to:
- Define an IAM role that Lambda can assume.
- Attach an inline policy granting DynamoDB access.
- Attach a managed policy for CloudWatch Logs.
Define an IAM role with an assume role policy
An IAM role is like a badge that your Lambda function wears. The "assume role policy" specifies which AWS service is allowed to take on this role.
- In main.tf, add the following code below the DynamoDB table resource:
# IAM Role for Lambda
resource "aws_iam_role" "lambda_role" {
name = "phishing-tracker-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 phishing-tracker-lambda-role. The assume_role_policy tells AWS that the Lambda service (lambda.amazonaws.com) is the only service allowed to assume this role.
The jsonencode() function converts the Terraform map into the JSON format that AWS requires for IAM policies.
- Save main.tf.
Why does the role have no permissions yet?
The assume role policy only controls WHO can wear the badge. It does not grant any actual permissions to do things. You'll attach permissions in the next two chunks.
Attach an inline policy granting DynamoDB access
Your Lambda function needs to perform five DynamoDB operations: get, put, update, delete, and scan. You'll grant these exact permissions using an inline policy attached to the role.
- In main.tf, add this code below the IAM role you just created:
# IAM Policy for DynamoDB access
resource "aws_iam_role_policy" "lambda_dynamodb_policy" {
name = "phishing-tracker-dynamodb-policy"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Scan"
]
Resource = aws_dynamodb_table.phishing_sites.arn
}
]
})
}
What does this code do?
- role = aws_iam_role.lambda_role.id attaches this policy to the role you defined above.
- The Action list grants exactly five DynamoDB operations: read, write, update, delete, and scan.
- Resource = aws_dynamodb_table.phishing_sites.arn scopes the permissions to your specific table only. This follows the principle of least privilege.
- Save main.tf.
Seeing a reference error?
If Terraform complains about aws_iam_role.lambda_role or aws_dynamodb_table.phishing_sites not being found, make sure both resources are defined above this policy in your main.tf file.
Help me fix a Terraform reference error in my IAM policy
Attach the managed policy for CloudWatch Logs
Lambda functions write logs to CloudWatch so you can debug issues. AWS provides a pre-built managed policy for this instead of requiring you to write one from scratch.
- In main.tf, add this code below the DynamoDB policy:
# Attach basic Lambda execution role for CloudWatch Logs
resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
role = aws_iam_role.lambda_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
What does this code do?
This attaches the AWS-managed AWSLambdaBasicExecutionRole policy to your role. It grants permission to create log groups and write log events to CloudWatch.
Notice this uses aws_iam_role_policy_attachment (not aws_iam_role_policy). Attachments reference existing AWS-managed policies by ARN. Inline policies define custom permissions from scratch.
- Save main.tf.
- Validate your configuration by running this command in the terminal:
terraform validate
- You should see Success! The configuration is valid. in the output.
Validation failing?
- Check that every opening brace { has a matching closing brace }. Missing braces are the most common Terraform syntax error.
- Verify the policy_arn string is wrapped in double quotes and matches exactly: arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole.
- Make sure the DynamoDB policy uses aws_iam_role.lambda_role.id and the policy attachment uses aws_iam_role.lambda_role.name. These are different attributes.
Help me fix my Terraform validation error
✔️ Awesome, I've got everything!
Great. Double check you've saved main.tf before moving on.
ⓧ I'd like to double check the full code
Here is the full main.tf file up to this point:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
# DynamoDB Table
resource "aws_dynamodb_table" "phishing_sites" {
name = "phishing-sites"
billing_mode = "PAY_PER_REQUEST"
hash_key = "site_id"
attribute {
name = "site_id"
type = "S"
}
tags = {
Project = "phishing-tracker"
}
}
# IAM Role for Lambda
resource "aws_iam_role" "lambda_role" {
name = "phishing-tracker-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}
]
})
}
# IAM Policy for DynamoDB access
resource "aws_iam_role_policy" "lambda_dynamodb_policy" {
name = "phishing-tracker-dynamodb-policy"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Scan"
]
Resource = aws_dynamodb_table.phishing_sites.arn
}
]
})
}
# Attach basic Lambda execution role for CloudWatch Logs
resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
role = aws_iam_role.lambda_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
Your IAM permissions are locked in. Next, you'll write the Python Lambda function that uses these permissions to handle all five CRUD operations.
Write the Lambda Function Code
Your infrastructure permissions are locked down. The IAM role grants your Lambda function exactly the DynamoDB actions it needs and nothing more.
Now you need the actual application logic. Your Lambda function will receive HTTP requests from API Gateway and route them to the correct CRUD operation. Each operation interacts with the DynamoDB table you defined in Step 2.
In this step, get ready to:
- Set up the Lambda handler with imports and DynamoDB initialization.
- Implement route matching based on API Gateway's event format.
- Write the five CRUD operation functions.
Set up the handler with imports and initialization
A single Lambda function will handle all five API routes. Instead of deploying five separate functions, you route requests inside one handler based on the HTTP method and path.
- In Cursor, open the lambda/handler.py file.
- Add the following imports and DynamoDB initialization at the top of the file:
import json
import boto3
import os
import uuid
from datetime import datetime
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ["TABLE_NAME"])
What does this code do?
The imports pull in everything the handler needs. boto3 is the AWS SDK for Python, included in every Lambda Python runtime by default.
The two lines after the imports connect to DynamoDB using the TABLE_NAME environment variable. Terraform will inject this value when it deploys the function in the next step.
You won't see a change yet. The next chunk adds the router that connects incoming requests to the functions you'll write.
Implement route matching with the Lambda handler
API Gateway HTTP API sends a payload format version 2.0 event to your Lambda. This event includes a routeKey field with the HTTP method and path combined (e.g., GET /sites/{id}).
- Below the initialization code, add the lambda_handler function:
def lambda_handler(event, context):
route_key = event.get("routeKey", "")
path_parameters = event.get("pathParameters") or {}
if route_key == "POST /sites":
return create_site(event)
elif route_key == "GET /sites":
return list_sites(event)
elif route_key == "GET /sites/{id}":
return get_site(path_parameters.get("id"))
elif route_key == "PUT /sites/{id}":
return update_site(path_parameters.get("id"), event)
elif route_key == "DELETE /sites/{id}":
return delete_site(path_parameters.get("id"))
else:
return response(404, {"error": "Route not found"})
What does this code do?
This is the single entry point for all API requests. routeKey tells the handler which HTTP method and path was called, so it can dispatch to the correct function.
pathParameters holds URL path variables like {id}. The or {} fallback prevents errors when no path parameters exist (like on GET /sites).
You won't see a change yet. The next chunk implements the functions this router calls.
Implement the CRUD operation functions
Each CRUD operation is its own function. They validate input, interact with DynamoDB, and return a consistent JSON response.
- Below the lambda_handler function, add the create, list, and get functions:
def create_site(event):
body = json.loads(event.get("body", "{}"))
url = body.get("url")
status = body.get("status", "pending")
notes = body.get("notes", "")
if not url:
return response(400, {"error": "url is required"})
if status not in ["malicious", "safe", "pending"]:
return response(400, {"error": "status must be malicious, safe, or pending"})
site_id = str(uuid.uuid4())
item = {
"site_id": site_id,
"url": url,
"status": status,
"notes": notes,
"reported_at": datetime.utcnow().isoformat(),
}
table.put_item(Item=item)
return response(201, item)
def list_sites(event):
result = table.scan()
return response(200, result.get("Items", []))
def get_site(site_id):
if not site_id:
return response(400, {"error": "site_id is required"})
result = table.get_item(Key={"site_id": site_id})
item = result.get("Item")
if not item:
return response(404, {"error": "Site not found"})
return response(200, item)
What does this code do?
- create_site validates that a URL was provided and that the status is one of malicious, safe, or pending. It generates a unique ID and timestamps the record.
- list_sites runs a full table scan to return all phishing site records.
- get_site fetches a single record by its site_id and returns a 404 if it doesn't exist.
- Below the get_site function, add the update, delete, and response helper functions:
def update_site(site_id, event):
if not site_id:
return response(400, {"error": "site_id is required"})
body = json.loads(event.get("body", "{}"))
status = body.get("status")
notes = body.get("notes")
if status and status not in ["malicious", "safe", "pending"]:
return response(400, {"error": "status must be malicious, safe, or pending"})
update_expr_parts = []
expr_values = {}
if status:
update_expr_parts.append("#s = :s")
expr_values[":s"] = status
if notes is not None:
update_expr_parts.append("notes = :n")
expr_values[":n"] = notes
if not update_expr_parts:
return response(400, {"error": "No fields to update"})
update_expr = "SET " + ", ".join(update_expr_parts)
expr_names = {}
if status:
expr_names["#s"] = "status"
kwargs = {
"Key": {"site_id": site_id},
"UpdateExpression": update_expr,
"ExpressionAttributeValues": expr_values,
"ReturnValues": "ALL_NEW",
}
if expr_names:
kwargs["ExpressionAttributeNames"] = expr_names
result = table.update_item(**kwargs)
return response(200, result.get("Attributes", {}))
def delete_site(site_id):
if not site_id:
return response(400, {"error": "site_id is required"})
table.delete_item(Key={"site_id": site_id})
return response(200, {"message": f"Site {site_id} deleted"})
def response(status_code, body):
return {
"statusCode": status_code,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(body),
}
What does this code do?
- update_site builds a dynamic update expression so you can change the status, notes, or both. It uses ExpressionAttributeNames to alias #s to status because "status" is a reserved word in DynamoDB.
- delete_site removes a record by its primary key and returns a confirmation message.
- response is a helper that formats every return value into the structure API Gateway expects: a status code, content-type header, and JSON body.
Why use ExpressionAttributeNames?
DynamoDB reserves certain words (like "status", "name", "data") that cannot appear directly in update expressions. The workaround is to use a placeholder like #s in the expression and map it to the real attribute name in ExpressionAttributeNames.
- Save lambda/handler.py.
- Confirm the file is saved by running this command in your terminal:
ls lambda/handler.py
- You should see lambda/handler.py printed in the output.
Not seeing the file?
- Make sure your terminal is in the project root directory (the folder containing main.tf). If not, navigate there first.
- Check that the lambda/ subdirectory exists. If it's missing, create it with mkdir lambda and save the file again.
Help me find my handler.py file
✔️ 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 is the complete lambda/handler.py file. Compare it to yours and fix any differences.
import json
import boto3
import os
import uuid
from datetime import datetime
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ["TABLE_NAME"])
def lambda_handler(event, context):
route_key = event.get("routeKey", "")
path_parameters = event.get("pathParameters") or {}
if route_key == "POST /sites":
return create_site(event)
elif route_key == "GET /sites":
return list_sites(event)
elif route_key == "GET /sites/{id}":
return get_site(path_parameters.get("id"))
elif route_key == "PUT /sites/{id}":
return update_site(path_parameters.get("id"), event)
elif route_key == "DELETE /sites/{id}":
return delete_site(path_parameters.get("id"))
else:
return response(404, {"error": "Route not found"})
def create_site(event):
body = json.loads(event.get("body", "{}"))
url = body.get("url")
status = body.get("status", "pending")
notes = body.get("notes", "")
if not url:
return response(400, {"error": "url is required"})
if status not in ["malicious", "safe", "pending"]:
return response(400, {"error": "status must be malicious, safe, or pending"})
site_id = str(uuid.uuid4())
item = {
"site_id": site_id,
"url": url,
"status": status,
"notes": notes,
"reported_at": datetime.utcnow().isoformat(),
}
table.put_item(Item=item)
return response(201, item)
def list_sites(event):
result = table.scan()
return response(200, result.get("Items", []))
def get_site(site_id):
if not site_id:
return response(400, {"error": "site_id is required"})
result = table.get_item(Key={"site_id": site_id})
item = result.get("Item")
if not item:
return response(404, {"error": "Site not found"})
return response(200, item)
def update_site(site_id, event):
if not site_id:
return response(400, {"error": "site_id is required"})
body = json.loads(event.get("body", "{}"))
status = body.get("status")
notes = body.get("notes")
if status and status not in ["malicious", "safe", "pending"]:
return response(400, {"error": "status must be malicious, safe, or pending"})
update_expr_parts = []
expr_values = {}
if status:
update_expr_parts.append("#s = :s")
expr_values[":s"] = status
if notes is not None:
update_expr_parts.append("notes = :n")
expr_values[":n"] = notes
if not update_expr_parts:
return response(400, {"error": "No fields to update"})
update_expr = "SET " + ", ".join(update_expr_parts)
expr_names = {}
if status:
expr_names["#s"] = "status"
kwargs = {
"Key": {"site_id": site_id},
"UpdateExpression": update_expr,
"ExpressionAttributeValues": expr_values,
"ReturnValues": "ALL_NEW",
}
if expr_names:
kwargs["ExpressionAttributeNames"] = expr_names
result = table.update_item(**kwargs)
return response(200, result.get("Attributes", {}))
def delete_site(site_id):
if not site_id:
return response(400, {"error": "site_id is required"})
table.delete_item(Key={"site_id": site_id})
return response(200, {"message": f"Site {site_id} deleted"})
def response(status_code, body):
return {
"statusCode": status_code,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(body),
}
Your Lambda code is ready. Next up, you'll wire this function into Terraform so it gets packaged and deployed to AWS alongside the rest of your infrastructure.
Deploy the Lambda Function with Terraform
You now have a complete Lambda handler that routes requests and performs CRUD operations on DynamoDB. The next challenge is getting that code packaged and deployed to AWS.
AWS Lambda expects your code as a zip archive. Instead of zipping it manually every time you make a change, Terraform can handle the packaging automatically. In this step, you will add Terraform resources that zip your handler and deploy it as a Lambda function.
In this step, get ready to:
- Zip the Lambda code using Terraform's archive_file data source.
- Define the Lambda function resource with the correct runtime and IAM role.
- Pass the DynamoDB table name to the function as an environment variable.
Zip the Lambda code with archive_file
Lambda requires your Python code to be uploaded as a zip file. Terraform provides a built-in archive_file data source that creates this zip for you every time you run terraform apply.
- In main.tf, scroll to the bottom of the file (after the aws_iam_role_policy_attachment resource).
- Add the archive_file data source by pasting the following code:
# Archive the Lambda function code
data "archive_file" "lambda_zip" {
type = "zip"
source_file = "${path.module}/lambda/handler.py"
output_path = "${path.module}/lambda/handler.zip"
}
What does this code do?
The archive_file data source is a Terraform built-in that creates a zip file without needing an external tool.
- source_file points to your Python handler at lambda/handler.py.
- output_path tells Terraform where to write the resulting handler.zip file.
- Terraform also generates an output_base64sha256 hash from this archive. You will use that hash next so Lambda knows when your code has changed.
- Save main.tf.
You won't see a change yet. The next chunk uses this archive to create the actual Lambda function.
Define the Lambda function resource
The aws_lambda_function resource tells AWS everything it needs to run your code: the zip file location, which runtime to use, the entry point function, and the IAM role that grants permissions.
- In main.tf, add the Lambda function resource directly below the archive_file block you just added:
# Lambda Function
resource "aws_lambda_function" "phishing_tracker" {
filename = data.archive_file.lambda_zip.output_path
function_name = "phishing-tracker"
role = aws_iam_role.lambda_role.arn
handler = "handler.lambda_handler"
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
runtime = "python3.13"
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.phishing_sites.name
}
}
}
What does this code do?
- filename points to the zip archive Terraform just created.
- handler is set to handler.lambda_handler. This tells Lambda to look in the file named handler.py and call the function named lambda_handler.
- source_code_hash is the SHA-256 hash of your zip. Terraform uses this to detect when your code changes so it re-deploys the function.
- runtime is set to python3.13. This is the latest Python runtime available on Lambda.
- The environment block passes TABLE_NAME to the function. Your Python code reads this with os.environ["TABLE_NAME"] to connect to the correct DynamoDB table.
Why pass the table name as an environment variable?
Hardcoding "phishing-sites" directly in your Python code would work, but it creates a hidden dependency between your Terraform config and your application code. By passing the table name through an environment variable, Terraform becomes the single source of truth for all resource names.
- Save main.tf.
- Verify Terraform can read your updated configuration by running:
terraform validate
You should see a message confirming the configuration is valid.
Seeing a validation error?
- Check that the archive_file block comes before the aws_lambda_function block. Terraform resolves references in order.
- Confirm your lambda/handler.py file exists. The source_file path must point to a real file.
- Make sure all curly braces are balanced. A missing } in the environment block is a common mistake.
Help me debug my Terraform validation error for the Lambda function resource.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.tf.
ⓧ I'd like to double check the full code
Here is the complete main.tf file as it should look after this step:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
# DynamoDB Table
resource "aws_dynamodb_table" "phishing_sites" {
name = "phishing-sites"
billing_mode = "PAY_PER_REQUEST"
hash_key = "site_id"
attribute {
name = "site_id"
type = "S"
}
tags = {
Project = "phishing-tracker"
}
}
# IAM Role for Lambda
resource "aws_iam_role" "lambda_role" {
name = "phishing-tracker-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}
]
})
}
# IAM Policy for DynamoDB access
resource "aws_iam_role_policy" "lambda_dynamodb_policy" {
name = "phishing-tracker-dynamodb-policy"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Scan"
]
Resource = aws_dynamodb_table.phishing_sites.arn
}
]
})
}
# Attach basic Lambda execution role for CloudWatch Logs
resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
role = aws_iam_role.lambda_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# Archive the Lambda function code
data "archive_file" "lambda_zip" {
type = "zip"
source_file = "${path.module}/lambda/handler.py"
output_path = "${path.module}/lambda/handler.zip"
}
# Lambda Function
resource "aws_lambda_function" "phishing_tracker" {
filename = data.archive_file.lambda_zip.output_path
function_name = "phishing-tracker"
role = aws_iam_role.lambda_role.arn
handler = "handler.lambda_handler"
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
runtime = "python3.13"
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.phishing_sites.name
}
}
}
Your Lambda function is defined and ready to deploy. Next up, you will create the API Gateway HTTP API that routes incoming web requests to this function.
Create the API Gateway HTTP API
Your Lambda function is deployed, but it has no way for the outside world to reach it. Right now it sits in AWS waiting for invocations that will never come.
In this step, you will create an API Gateway HTTP API that exposes your Lambda function as a public REST endpoint. You will define routes for each CRUD operation so that HTTP requests like GET /sites and POST /sites get forwarded to your Lambda handler.
In this step, get ready to:
- Define the API Gateway HTTP API, stage, and Lambda integration.
- Create routes for all five CRUD operations.
- Grant API Gateway permission to invoke your Lambda function and add Terraform outputs.
Define the API, stage, and integration
An API Gateway HTTP API needs three pieces to function: the API itself, a stage that deploys it, and an integration that tells it where to send requests.
- In main.tf, add the following code below your aws_lambda_function resource:
# API Gateway HTTP API
resource "aws_apigatewayv2_api" "phishing_api" {
name = "phishing-tracker-api"
protocol_type = "HTTP"
}
# API Gateway Stage
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.phishing_api.id
name = "$default"
auto_deploy = true
}
# API Gateway Integration
resource "aws_apigatewayv2_integration" "lambda_integration" {
api_id = aws_apigatewayv2_api.phishing_api.id
integration_uri = aws_lambda_function.phishing_tracker.invoke_arn
integration_type = "AWS_PROXY"
integration_method = "POST"
payload_format_version = "2.0"
}
What does this code do?
These three resources work together to expose your Lambda function over HTTP.
- aws_apigatewayv2_api creates the HTTP API itself. The protocol_type = "HTTP" makes this an HTTP API (cheaper and simpler than a REST API).
- aws_apigatewayv2_stage creates a deployment stage. Setting auto_deploy = true means any route changes are deployed automatically without a manual step.
- aws_apigatewayv2_integration connects the API to your Lambda function. The AWS_PROXY type passes the full request event to Lambda and returns the Lambda response directly to the caller.
Why HTTP API instead of REST API?
AWS offers two types of API Gateway: REST APIs and HTTP APIs. HTTP APIs are the recommended choice for simple CRUD backends because they cost less, have lower latency, and support automatic deployments out of the box.
- Save main.tf.
Define routes for each CRUD operation
Routes tell API Gateway which HTTP method and path combinations to accept. Each route maps to the Lambda integration you just created.
- In main.tf, add the following code below the integration resource:
# API Gateway Routes
resource "aws_apigatewayv2_route" "post_sites" {
api_id = aws_apigatewayv2_api.phishing_api.id
route_key = "POST /sites"
target = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}"
}
resource "aws_apigatewayv2_route" "get_sites" {
api_id = aws_apigatewayv2_api.phishing_api.id
route_key = "GET /sites"
target = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}"
}
resource "aws_apigatewayv2_route" "get_site" {
api_id = aws_apigatewayv2_api.phishing_api.id
route_key = "GET /sites/{id}"
target = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}"
}
resource "aws_apigatewayv2_route" "put_site" {
api_id = aws_apigatewayv2_api.phishing_api.id
route_key = "PUT /sites/{id}"
target = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}"
}
resource "aws_apigatewayv2_route" "delete_site" {
api_id = aws_apigatewayv2_api.phishing_api.id
route_key = "DELETE /sites/{id}"
target = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}"
}
What does this code do?
Each route_key is a combination of an HTTP method and a path. These match exactly what your Lambda handler checks in its routeKey logic.
- POST /sites creates a new phishing site record.
- GET /sites lists all records.
- GET /sites/{id} fetches a single record by ID.
- PUT /sites/{id} updates a record's status or notes.
- DELETE /sites/{id} removes a record.
- Save main.tf.
Grant API Gateway permission and add outputs
Even though the routes point to your Lambda integration, AWS requires an explicit permission grant before API Gateway can actually invoke the function. You will also create an outputs file so Terraform prints your API URL after deployment.
- In main.tf, add the following code below your route resources:
# Lambda permission for API Gateway
resource "aws_lambda_permission" "api_gateway" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.phishing_tracker.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.phishing_api.execution_arn}/*/*"
}
What does this code do?
This resource grants the apigateway.amazonaws.com service permission to call your Lambda function. The /*/* wildcard at the end of the source_arn allows any route and any HTTP method on this API to trigger the function.
- Save main.tf.
Now you will create a separate outputs file so Terraform prints your live API URL after each deployment.
- Open outputs.tf in your phishing-tracker folder and add the following code:
output "api_endpoint" {
description = "The URL of the phishing tracker API"
value = aws_apigatewayv2_api.phishing_api.api_endpoint
}
output "dynamodb_table_name" {
description = "The name of the DynamoDB table"
value = aws_dynamodb_table.phishing_sites.name
}
What does this code do?
Terraform outputs print useful values to your terminal after terraform apply completes. The api_endpoint gives you the base URL to call your API. The dynamodb_table_name confirms which table your stack created.
- Save outputs.tf.
- Confirm your project now contains three files by running:
ls
- You should see main.tf, outputs.tf, and the lambda/ folder in the output.
Don't see outputs.tf?
- Make sure you created outputs.tf in the same directory as main.tf. Terraform only reads .tf files in the current working directory.
- Check the file extension is .tf and not .tf.txt or similar.
Help me figure out why my outputs.tf file isn't showing up.
✔️ Awesome, I've got everything!
Great. Double check you have saved both main.tf and outputs.tf.
ⓧ 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"
}
}
}
provider "aws" {
region = "us-east-1"
}
# DynamoDB Table
resource "aws_dynamodb_table" "phishing_sites" {
name = "phishing-sites"
billing_mode = "PAY_PER_REQUEST"
hash_key = "site_id"
attribute {
name = "site_id"
type = "S"
}
tags = {
Project = "phishing-tracker"
}
}
# IAM Role for Lambda
resource "aws_iam_role" "lambda_role" {
name = "phishing-tracker-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}
]
})
}
# IAM Policy for DynamoDB access
resource "aws_iam_role_policy" "lambda_dynamodb_policy" {
name = "phishing-tracker-dynamodb-policy"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Scan"
]
Resource = aws_dynamodb_table.phishing_sites.arn
}
]
})
}
# Attach basic Lambda execution role for CloudWatch Logs
resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
role = aws_iam_role.lambda_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# Archive the Lambda function code
data "archive_file" "lambda_zip" {
type = "zip"
source_file = "${path.module}/lambda/handler.py"
output_path = "${path.module}/lambda/handler.zip"
}
# Lambda Function
resource "aws_lambda_function" "phishing_tracker" {
filename = data.archive_file.lambda_zip.output_path
function_name = "phishing-tracker"
role = aws_iam_role.lambda_role.arn
handler = "handler.lambda_handler"
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
runtime = "python3.13"
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.phishing_sites.name
}
}
}
# API Gateway HTTP API
resource "aws_apigatewayv2_api" "phishing_api" {
name = "phishing-tracker-api"
protocol_type = "HTTP"
}
# API Gateway Stage
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.phishing_api.id
name = "$default"
auto_deploy = true
}
# API Gateway Integration
resource "aws_apigatewayv2_integration" "lambda_integration" {
api_id = aws_apigatewayv2_api.phishing_api.id
integration_uri = aws_lambda_function.phishing_tracker.invoke_arn
integration_type = "AWS_PROXY"
integration_method = "POST"
payload_format_version = "2.0"
}
# API Gateway Routes
resource "aws_apigatewayv2_route" "post_sites" {
api_id = aws_apigatewayv2_api.phishing_api.id
route_key = "POST /sites"
target = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}"
}
resource "aws_apigatewayv2_route" "get_sites" {
api_id = aws_apigatewayv2_api.phishing_api.id
route_key = "GET /sites"
target = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}"
}
resource "aws_apigatewayv2_route" "get_site" {
api_id = aws_apigatewayv2_api.phishing_api.id
route_key = "GET /sites/{id}"
target = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}"
}
resource "aws_apigatewayv2_route" "put_site" {
api_id = aws_apigatewayv2_api.phishing_api.id
route_key = "PUT /sites/{id}"
target = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}"
}
resource "aws_apigatewayv2_route" "delete_site" {
api_id = aws_apigatewayv2_api.phishing_api.id
route_key = "DELETE /sites/{id}"
target = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}"
}
# Lambda permission for API Gateway
resource "aws_lambda_permission" "api_gateway" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.phishing_tracker.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.phishing_api.execution_arn}/*/*"
}
And here is the full outputs.tf:
output "api_endpoint" {
description = "The URL of the phishing tracker API"
value = aws_apigatewayv2_api.phishing_api.api_endpoint
}
output "dynamodb_table_name" {
description = "The name of the DynamoDB table"
value = aws_dynamodb_table.phishing_sites.name
}
Your infrastructure is fully defined. Every resource your API needs is now captured in Terraform configuration. Next up, you will deploy the entire stack and test your live API with real HTTP requests.
Deploy and Test Your API
Your API Gateway routes are wired up and every resource is defined in Terraform code. Now it's time to bring everything to life on AWS with a single command.
In this step, you'll run terraform apply to provision your entire stack, then fire real HTTP requests at your live API to confirm every CRUD operation works end to end.
In this step, get ready to:
- Deploy all resources with terraform apply.
- Test each CRUD endpoint with curl.
- Verify data persists in DynamoDB.
Deploy your infrastructure
Every resource you defined across main.tf and outputs.tf is still just a plan on paper. Running terraform apply compares your configuration to what currently exists in AWS and creates everything that's missing.
- Deploy your entire stack by running this command:
terraform apply
What happens when you run this?
Terraform first generates a plan showing every resource it will create. It then asks you to confirm by displaying:
Do you want to perform these actions?
Only yes (typed in full) will be accepted. Any other input cancels the operation safely.
- Type yes and press Enter to confirm.
Terraform creates each resource and prints progress lines. After a minute or so, you should see a message like:
Apply complete! Resources: 9 added, 0 changed, 0 destroyed.
Outputs:
api_endpoint = "https://<id>.execute-api.us-east-1.amazonaws.com"
dynamodb_table_name = "phishing-sites"
- Copy the api_endpoint URL from the output. You'll use it in every curl command that follows.
Seeing an error during apply?
- If you see a credentials error, confirm your AWS CLI is configured by running aws sts get-caller-identity.
- If a resource already exists (name conflict), either delete the existing resource in the AWS console or change the name in main.tf.
- If the Lambda zip fails, confirm lambda/handler.py exists relative to your main.tf file.
Why is terraform apply failing for me?
Test each CRUD endpoint with curl
Your API is live. Time to send real HTTP requests and confirm every route works.
Windows users
In Windows PowerShell, curl is an alias for a different command. Use curl.exe instead of curl in every command below.
- Create a new phishing site record by running this command:
curl -X POST [[API_URL="your api_endpoint URL"]]/sites \
-H "Content-Type: application/json" \
-d '{"url": "http://evil-phishing.com", "status": "malicious", "notes": "Reported by user"}'
You should see a JSON response with a 201 status containing the new record, including a generated site_id.
What does the response look like?
The response includes site_id (a UUID), url, status, notes, and reported_at. Copy the site_id value. You'll need it for the next commands.
- List all phishing site records by running:
curl [[API_URL="your api_endpoint URL"]]/sites
You should see a JSON array containing the site you just created.
- Get a single site by its ID by running:
curl [[API_URL="your api_endpoint URL"]]/sites/[[SITE_ID="paste your site_id from the POST response"]]
You should see the full record for that specific site.
- Update the site's classification status by running:
curl -X PUT [[API_URL="your api_endpoint URL"]]/sites/[[SITE_ID="paste your site_id from the POST response"]] \
-H "Content-Type: application/json" \
-d '{"status": "safe", "notes": "Verified as legitimate"}'
You should see the updated record with status changed to safe and new notes.
- Delete the site record by running:
curl -X DELETE [[API_URL="your api_endpoint URL"]]/sites/[[SITE_ID="paste your site_id from the POST response"]]
You should see a confirmation message indicating the site was deleted.
Getting an Internal Server Error?
- Make sure your api_endpoint URL does not have a trailing slash before /sites.
- Check that you replaced the site_id placeholder with the actual UUID from your POST response.
- If you see a timeout, wait 10-15 seconds and retry. Lambda cold starts can cause the first request to take longer.
Why am I getting an Internal Server Error from my API?
Verify data persists in DynamoDB
Each curl call you ran hit the live Lambda function, which read from and wrote to DynamoDB. The fact that your GET returned data created by POST already proves persistence.
- Create one more record to confirm the table is still working:
curl -X POST [[API_URL="your api_endpoint URL"]]/sites \
-H "Content-Type: application/json" \
-d '{"url": "http://suspicious-login.net", "status": "pending", "notes": "Needs review"}'
- Confirm it appears in the full list by running:
curl [[API_URL="your api_endpoint URL"]]/sites
You should see the new record in the response array. The previously deleted site should not appear.
What does this prove?
Your Lambda function successfully writes to DynamoDB and reads back persisted data across separate HTTP requests. The data survives between calls because DynamoDB stores it durably on disk, not in memory.
Your entire serverless stack (API Gateway, Lambda, DynamoDB, IAM) is working together as defined in your Terraform configuration.
Secret mission
Add Status Filtering with a GSI
Your API can list all phishing sites, but what if you only want to see the malicious ones? Right now you would have to scan the entire table and filter client-side. Add a Global Secondary Index and a query parameter so you can efficiently filter sites by classification status.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. All resources in this project are serverless, so there are no idle charges while they sit unused.
Cost warning
The API Gateway HTTP API free tier (1M requests/month) lasts 12 months from your AWS account creation date. After that, any requests to the endpoint will incur charges.
DynamoDB on-demand and Lambda have effectively zero cost at learning-project traffic levels. If you do not plan to use this API again, run terraform destroy to remove everything.
Resources you used:
- 1 DynamoDB table (phishing-sites).
- 1 Lambda function (phishing-tracker).
- 1 API Gateway HTTP API (phishing-tracker-api).
- 1 IAM role (phishing-tracker-lambda-role) with attached policies.
- 1 CloudWatch Log Group (auto-created by Lambda, not managed by Terraform).
- Local project files (main.tf, outputs.tf, lambda/handler.py).
Keep everything running
No action needed. Choose this if you plan to keep experimenting with the API or want to build on top of it.
- All resources are serverless with on-demand billing. At learning-project traffic levels, you will not incur charges.
- The CloudWatch Log Group will remain in your account but incurs no cost unless you store significant log data.
- Your local Terraform files stay on your Desktop for future use.
Pause - I'll come back to this later
Serverless resources have no idle charges, so there is nothing to pause. Your Lambda function, API Gateway, and DynamoDB table only cost money when they receive requests.
- Leave everything as-is. When you return, your API endpoint will still work immediately.
- If you want to prevent any accidental usage, you can delete the API Gateway stage from the AWS console. This disables the public endpoint without destroying your infrastructure.
Delete - I don't want to use this again
Remove all project resources and start fresh. Terraform makes this a single command.
- In your terminal, make sure you are inside your project folder (the one containing main.tf).
- Destroy all Terraform-managed resources by running:
terraform destroy
- When prompted, type yes and press Enter to confirm.
- Wait for Terraform to finish removing all resources. You should see a message confirming all resources have been destroyed.
The CloudWatch Log Group is auto-created by AWS and is not tracked in your Terraform state. Delete it separately by running:
aws logs delete-log-group --log-group-name /aws/lambda/phishing-tracker
- Delete your local project files by running:
cd ~/Desktop
rm -rf phishing-tracker
What if my folder has a different name?
Replace phishing-tracker in the command above with whatever you named your project folder in Step 1.
Nice Work!
Nice Work!
Nice work! You just built a full serverless CRUD API entirely provisioned through Terraform.
You've learned how to:
- Wrote a Python Lambda function handling 5 CRUD routes backed by DynamoDB.
- Deployed an API Gateway HTTP API with route-level integrations to a live, publicly accessible endpoint.
- Provisioned and destroyed the entire infrastructure with a single Terraform command.
- Added a Global Secondary Index for efficient status-based querying of phishing sites.
Ready to quiz yourself?