Shortly: AWS IaC with Terraform
Deploy a serverless URL shortener backend to AWS using Terraform.
Introduction
30 Second Summary
Imagine deploying your backend by clicking through dozens of console screens. Every time you need to recreate it, you repeat the same steps. One mistake and your environment drifts from what you intended.
In this project, you will write Terraform configuration files that deploy your entire Shortly URL shortener backend to AWS with a single command.
What You'll Build
You will run terraform apply in your terminal and watch Terraform provision a DynamoDB table, three Lambda functions, IAM roles, and an API Gateway. Then you will hit your live endpoint with curl and see a shortened URL come back.
By the end of this project, you'll have:
- A DynamoDB table created and visible in AWS without ever touching the console.
- Three Lambda functions deployed from your existing Python code, packaged automatically by Terraform.
- A live API Gateway HTTP API that routes requests to the correct Lambda and responds to real HTTP calls.
- A single terraform destroy command that cleanly removes everything when you are done.
- Secret Mission: Parameterize your configuration with Terraform variables and outputs for multi-environment reuse.
Are there any prerequisites?
This project assumes you already have an AWS account with the AWS CLI configured, and an existing Shortly project directory containing frontend/ and backend/ directories with Python Lambda functions from Phase 1.
You do not need any prior Terraform experience. This project teaches it from scratch.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Your backend/ directory has three Python Lambda functions ready to go. Right now there's no repeatable way to get them running in AWS without clicking through the console one resource at a time.
Install Terraform and Set Up the Project
Your Lambda functions are written and ready to go, but you have no way to deploy them repeatably. Right now, getting your backend live would mean clicking through the AWS console one resource at a time.
Terraform solves this by letting you describe your entire infrastructure in code. One command provisions everything your functions need. In this step, you will install Terraform and set up the configuration directory it works from.
In this step, get ready to:
- Install Terraform and verify it is available on your system PATH.
- Create a terraform/ directory with a provider configuration file.
- Run terraform init to download the AWS and archive providers.
Install Terraform
Terraform is a single binary you add to your system. Once it is on your PATH, you can run terraform from any directory.
- Check whether Terraform is already installed by running this command:
terraform version
✔️ I see version 1.15 or higher
You are good to go. Skip ahead to the next substep where you create the project directory.
ⓧ I see an older version
You need Terraform 1.15.0 or later for this project. Upgrade using the same method you originally installed with.
macOS
- Upgrade Terraform through Homebrew by running these commands:
brew tap hashicorp/tap
brew upgrade hashicorp/tap/terraform
- Confirm the upgrade by running:
terraform version
You should see Terraform v1.15.8 or later printed.
Windows
- Go to the Terraform install page at developer.hashicorp.com/terraform/install.
- Under Windows, download the AMD64 binary ZIP.
- Extract the new terraform.exe into the same folder your old binary lives in (e.g. C:\terraform), replacing the existing file.
- Open a new terminal and confirm the upgrade by running:
terraform version
You should see Terraform v1.15.8 or later printed.
ⓧ Command not found
Terraform is not installed yet. Follow the instructions for your operating system below.
macOS
- Install the HashiCorp Homebrew tap and then Terraform by running these commands:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
- Confirm the install by running:
terraform version
You should see Terraform v1.15.8 printed.
Windows
- Go to the Terraform install page at developer.hashicorp.com/terraform/install.
- Under Windows, download the AMD64 binary ZIP.
- Create a folder called C:\terraform and extract terraform.exe into it.
- Open Settings and search for Environment Variables.
- Click Edit the system environment variables, then click Environment Variables.
- Under System variables, select Path and click Edit.
- Click New and add C:\terraform.
- Click OK on all open dialogs to save.
- Open a new terminal (the old one will not see the PATH change) and confirm the install by running:
terraform version
You should see Terraform v1.15.8 printed.
What is Terraform?
Terraform is an Infrastructure as Code tool made by HashiCorp. You write .tf files describing the cloud resources you want, and Terraform creates, updates, or destroys them to match your code.
This means your infrastructure lives in version control alongside your application code. Anyone on your team can run the same files and get an identical environment.
Create the terraform/ directory and providers file
Every Terraform project starts with a provider configuration. Providers are plugins that teach Terraform how to talk to a specific platform (in your case, AWS).
- Navigate into your project directory and create the terraform/ folder by running these commands:
cd ~/Desktop/shortly
mkdir terraform
cd terraform
- Confirm the folder was created by running:
ls
You should see the terraform folder listed alongside your existing frontend/ and backend/ directories (if you ran ls from the project root), or an empty listing if you are already inside terraform/.
- Create a file called providers.tf inside terraform/ and paste the following code:
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.58"
}
archive = {
source = "hashicorp/archive"
version = "~> 2.8"
}
}
}
provider "aws" {
region = "us-east-1"
}
What does this code do?
- required_version = ">= 1.15.0" ensures anyone running this config has a new enough Terraform CLI.
- required_providers tells Terraform which plugins to download. The aws provider manages AWS resources. The archive provider packages your Lambda source code into ZIP files.
- ~> 6.58 is a pessimistic version constraint. It allows any 6.x version at or above 6.58 but not 7.0.
- provider "aws" configures the AWS plugin with your target region. Terraform picks up your existing AWS CLI credentials automatically.
- Save providers.tf.
✔️ Awesome, I've got everything!
Great. Make sure you have saved providers.tf inside the terraform/ directory before continuing.
ⓧ I'd like to double check the full code
Your terraform/providers.tf should look exactly like this:
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.58"
}
archive = {
source = "hashicorp/archive"
version = "~> 2.8"
}
}
}
provider "aws" {
region = "us-east-1"
}
File not saving to the right place?
Make sure you created providers.tf inside the terraform/ subdirectory, not in your project root. Run ls inside terraform/ to confirm providers.tf appears there.
help me find where my providers.tf was saved
Initialize Terraform
Running terraform init downloads the provider plugins you declared and sets up a .terraform/ directory where Terraform stores them locally.
- Make sure you are inside the terraform/ directory, then initialize the project by running:
terraform init
Before you look at the output, what do you expect to see? Terraform should find your two providers and download them.
You should see lines like Installing hashicorp/aws and Installing hashicorp/archive, followed by the message Terraform has been successfully initialized!.
Seeing a provider error?
If you see an error mentioning the provider version not being available, check that the version values in your providers.tf match the code above exactly. A typo like 6.85 instead of 6.58 would fail.
If you see Error: No configuration files, you are running the command from the wrong directory. Run cd into terraform/ first.
help me fix my terraform init error
- Verify the final state by running:
terraform version
You should see Terraform v1.15.8 printed, confirming your CLI is ready.
Your Terraform workspace is initialized and ready to create real AWS resources. Next up, you will write your first resource definition and watch Terraform create a DynamoDB table with a single command.
Deploy Your DynamoDB Table
Your Terraform project is initialized, but it does not create anything in AWS yet. Your Lambda functions need a DynamoDB table to store the mapping between short codes and original URLs.
Writing this table as code means you can recreate it identically in any AWS environment. This step is your first taste of terraform apply turning HCL into a real AWS resource.
In this step, get ready to:
- Write a DynamoDB table resource definition in HCL.
- Run terraform plan and terraform apply to create the table in AWS.
- Verify the table exists using the AWS CLI.
Write the DynamoDB table configuration
DynamoDB only requires you to define the attributes used as keys. Your table needs a single partition key called shortCode to look up URLs. All other attributes (like the original URL and click count) are schemaless and handled by your application code.
- Create a new file called dynamodb.tf in your terraform/ directory.
- Add the following resource definition:
resource "aws_dynamodb_table" "urls" {
name = "shortly-urls"
billing_mode = "PAY_PER_REQUEST"
hash_key = "shortCode"
attribute {
name = "shortCode"
type = "S"
}
tags = {
Project = "shortly"
}
}
What does this code do?
- name sets the table's name in AWS to shortly-urls.
- billing_mode = "PAY_PER_REQUEST" means you pay only when data is read or written. There is zero cost when the table sits idle.
- hash_key declares shortCode as the partition key. Every item in the table is looked up by this field.
- attribute defines the key's data type. "S" means String.
- Save dynamodb.tf.
- Confirm the file is in the right place by running:
ls
You should see both dynamodb.tf and providers.tf listed.
Don't see dynamodb.tf?
- Make sure you saved the file inside the terraform/ directory, not the project root.
- Confirm your terminal is in the terraform/ directory by running pwd (macOS/Linux) or cd (Windows).
Help me troubleshoot why my dynamodb.tf file is not showing up in my terraform directory.
✔️ Awesome, I've got everything!
Great. Make sure you have saved dynamodb.tf before moving on.
ⓧ I'd like to double check the full code
resource "aws_dynamodb_table" "urls" {
name = "shortly-urls"
billing_mode = "PAY_PER_REQUEST"
hash_key = "shortCode"
attribute {
name = "shortCode"
type = "S"
}
tags = {
Project = "shortly"
}
}
Run terraform plan and apply
Terraform uses a two-step workflow. First, terraform plan previews exactly what will be created. Then, terraform apply executes the change for real.
- Make sure your terminal is still in the terraform/ directory from the previous step.
- Preview what Terraform will create by running:
terraform plan
You should see Terraform describe the DynamoDB table it intends to create, ending with a summary line:
Plan: 1 to add, 0 to change, 0 to destroy.
What does this output mean?
Terraform compared your .tf files against its state file (which is currently empty). It found one new resource to create and nothing to change or destroy.
- Create the table for real by running:
terraform apply
Terraform asks for confirmation
Terraform displays the same plan and then prompts you to confirm. Type yes and press Enter to proceed. Only the exact word yes is accepted.
After a few seconds, you should see:
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Your DynamoDB table now exists in AWS. Terraform created it from your code in seconds.
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 that fails, your CLI credentials are not set up correctly.
- If you see a syntax error, double-check that your dynamodb.tf matches the code above exactly. Missing braces or typos in attribute names cause failures.
Help me fix the error I'm seeing when running terraform apply for my DynamoDB table.
Verify the table exists in AWS
Before you run the next command, what status do you expect the table to have?
- Confirm the table is live by running:
aws dynamodb describe-table --table-name shortly-urls
You should see a JSON response with "TableStatus": "ACTIVE" and a "BillingModeSummary" section showing PAY_PER_REQUEST.
Why verify outside of Terraform?
Terraform tracks state locally, but confirming with the AWS CLI proves the resource actually exists in your AWS account. This is the same check you would run in a CI/CD pipeline to validate a deployment.
Table not found?
- Confirm the table name matches exactly. Run aws dynamodb list-tables to see all tables in your account.
- Make sure your AWS CLI region matches the provider region in providers.tf (us-east-1). Run aws configure get region to check.
Help me figure out why my DynamoDB table is not showing up when I run describe-table.
Your first piece of infrastructure is live. Next up, you will deploy your Lambda function and watch Terraform package your Python code automatically.
Deploy Your First Lambda Function
Your DynamoDB table is live in AWS, created entirely from code. Now you need to get your Python function running up there too.
Terraform can package your source files into ZIP archives and upload them as Lambda functions automatically. In this step, you will deploy your first function and invoke it. But something will go wrong on purpose.
In this step, get ready to:
- Package your create_url.py into a ZIP archive using Terraform's archive provider.
- Deploy the function to AWS with an IAM execution role.
- Invoke the function and observe a permissions error.
Write the Lambda deployment configuration
Your create_url.py already exists in the backend/ directory. Terraform needs two things to deploy it: a way to package the file into a ZIP, and a Lambda resource definition that uploads that ZIP to AWS.
- Create a new file called lambda.tf inside your terraform/ directory.
- Add the following code to package and deploy your function:
data "archive_file" "create_url" {
type = "zip"
source_file = "${path.module}/../backend/create_url.py"
output_file_mode = "0666"
output_path = "${path.module}/packages/create_url.zip"
}
resource "aws_lambda_function" "create_url" {
function_name = "shortly-create-url"
role = aws_iam_role.lambda_role.arn
handler = "create_url.lambda_handler"
runtime = "python3.12"
filename = data.archive_file.create_url.output_path
source_code_hash = data.archive_file.create_url.output_base64sha256
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.urls.name
}
}
}
What does this code do?
This file handles two jobs:
- data "archive_file" takes your create_url.py source file and zips it into packages/create_url.zip. The output_file_mode = "0666" ensures consistent checksums across Windows and Linux.
- aws_lambda_function deploys that ZIP to AWS. It references the IAM role (which you will create next), sets the Python handler entry point, and passes the DynamoDB table name as an environment variable so your function knows where to store URLs.
- source_code_hash tells Terraform to redeploy the function whenever your Python source code changes.
- Save lambda.tf.
Write the IAM execution role
Every Lambda function needs an IAM role that defines what it is allowed to do. You will start with the bare minimum: permission to write logs. Nothing else.
- Create a new file called iam.tf inside your terraform/ directory.
- Add the following IAM role configuration:
resource "aws_iam_role" "lambda_role" {
name = "shortly-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"
}
What does this code do?
This creates an IAM role with two parts:
- The assume_role_policy is a trust policy. It tells AWS that the Lambda service is allowed to assume this role when running your function.
- AWSLambdaBasicExecutionRole is an AWS-managed policy that grants permission to write logs to CloudWatch. No DynamoDB access is included.
- Save iam.tf.
- Confirm both new files are syntactically valid by running this command from inside your terraform/ directory:
terraform validate
You should see Success! The configuration is valid. printed in your terminal.
Seeing a validation error?
- Check that aws_iam_role.lambda_role in iam.tf matches the reference in lambda.tf (aws_iam_role.lambda_role.arn). A typo in either file causes an undeclared resource error.
- Make sure you are running the command from the terraform/ directory, not the project root.
Help me fix my Terraform validation error.
✔️ Awesome, I've got everything!
Great. Double check you have saved both lambda.tf and iam.tf.
ⓧ I'd like to double check the full code
Here is the complete lambda.tf:
data "archive_file" "create_url" {
type = "zip"
source_file = "${path.module}/../backend/create_url.py"
output_file_mode = "0666"
output_path = "${path.module}/packages/create_url.zip"
}
resource "aws_lambda_function" "create_url" {
function_name = "shortly-create-url"
role = aws_iam_role.lambda_role.arn
handler = "create_url.lambda_handler"
runtime = "python3.12"
filename = data.archive_file.create_url.output_path
source_code_hash = data.archive_file.create_url.output_base64sha256
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.urls.name
}
}
}
And here is the complete iam.tf:
resource "aws_iam_role" "lambda_role" {
name = "shortly-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"
}
Deploy the function and invoke it
Your configuration is valid. Time to create the real resources in AWS.
- Deploy the IAM role and Lambda function by running:
terraform apply
- Terraform shows a plan with 3 resources to create (the IAM role, the policy attachment, and the Lambda function).
- Type yes when prompted and press Enter.
Why 3 resources and not 2?
The archive_file is a data source, not a resource. It runs locally during the plan phase to create the ZIP file. It does not count toward the resources Terraform creates in AWS.
The 3 AWS resources are: aws_iam_role, aws_iam_role_policy_attachment, and aws_lambda_function.
Once the apply completes, you will see Apply complete! Resources: 3 added in the output.
Apply failed with an error?
- If you see a message about not finding backend/create_url.py, make sure you are running terraform apply from the terraform/ directory (not the project root). The relative path ${path.module}/../backend/ goes up one level from terraform/ to find backend/.
- If you see an EntityAlreadyExists error, a previous manual setup may have created a role with the same name. Delete it in the IAM console and re-run terraform apply.
Help me fix my terraform apply error.
Now test the function. Create a test event that simulates an API request.
- Create a file called test-event.json inside your terraform/ directory with this content:
{
"body": "{\"url\": \"https://example.com\"}",
"requestContext": {
"http": {
"method": "POST",
"path": "/urls"
}
}
}
What is this test event?
This JSON mimics the structure API Gateway sends to your Lambda function. The body field contains the URL to shorten, and requestContext tells your function which HTTP method and path the request came from.
Before you invoke the function, think about what you expect to happen. The function will try to write a shortened URL to DynamoDB. Does the IAM role you just created give it permission to do that?
- Invoke the Lambda function by running:
aws lambda invoke --function-name shortly-create-url --cli-binary-format raw-in-base64-out --payload file://test-event.json response.json
- View the response by running:
type response.json
macOS or Linux?
If type response.json does not work on your system, use cat response.json instead. The type command is Windows-specific.
You will see an AccessDeniedException error in the response body. The function executed, but it could not write to DynamoDB because your IAM role only grants log-writing permissions.
Why did this fail?
This is least-privilege in action. You gave the Lambda role only AWSLambdaBasicExecutionRole, which allows CloudWatch log writes and nothing else. The function tried to call dynamodb:PutItem and AWS denied the request.
This is intentional. Starting with minimal permissions and adding only what is needed is a security best practice. You will fix this in the next step by adding exactly the DynamoDB actions your functions require.
Your function is deployed and running in AWS. The permissions error is exactly what you expected. In the next step, you will add the DynamoDB permissions and deploy all three functions.
Fix Permissions and Deploy All Functions
The AccessDeniedException you saw in the last step is proof that IAM is doing its job. Your Lambda function tried to write to DynamoDB but the role only permits basic CloudWatch logging.
Now you will add exactly the DynamoDB permissions your functions need. You will also deploy the remaining two Lambda functions so the full backend is live and working.
In this step, get ready to:
- Add a DynamoDB inline policy to the Lambda execution role.
- Deploy the redirect_url and get_stats Lambda functions.
- Re-invoke shortly-create-url and confirm it succeeds.
Add a DynamoDB inline policy to the IAM role
Your Lambda role currently only has the AWSLambdaBasicExecutionRole managed policy attached. That policy lets Lambda write logs to CloudWatch, but it grants zero access to DynamoDB.
- In iam.tf, find the closing brace of the aws_iam_role_policy_attachment block.
- Add the following inline policy resource below it:
resource "aws_iam_role_policy" "lambda_dynamodb" {
name = "shortly-lambda-dynamodb"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"dynamodb:PutItem",
"dynamodb:GetItem",
"dynamodb:UpdateItem",
"dynamodb:Query",
"dynamodb:Scan"
]
Resource = aws_dynamodb_table.urls.arn
}
]
})
}
What does this code do?
This inline policy grants the five specific DynamoDB actions your Lambda functions need. Nothing more, nothing less.
- Resource = aws_dynamodb_table.urls.arn scopes the policy to your shortly-urls table only. Your functions cannot touch any other DynamoDB table in your account.
- Using aws_iam_role.lambda_role.id links this policy to the same role all three functions share.
Why least-privilege instead of full DynamoDB access?
AWS offers a managed policy called AmazonDynamoDBFullAccess that grants access to every DynamoDB table and action. Using it would be faster to write but would violate the principle of least privilege.
By listing only the five actions your code actually calls and scoping them to a single table, you limit the blast radius if a function is ever compromised.
- Save iam.tf.
✔️ Awesome, I've got everything!
Great. Double check you have saved iam.tf with all three blocks: the role, the policy attachment, and the new inline policy.
ⓧ I'd like to double check the full code
Your iam.tf should now contain these three blocks in order:
- aws_iam_role "lambda_role" with the lambda.amazonaws.com assume role policy.
- aws_iam_role_policy_attachment "lambda_basic" referencing the AWSLambdaBasicExecutionRole ARN.
- aws_iam_role_policy "lambda_dynamodb" with the five DynamoDB actions scoped to aws_dynamodb_table.urls.arn.
Add packaging and deployment for the remaining two functions
You already have one Lambda function deployed. The other two functions follow the exact same pattern with different file names and handlers.
- In lambda.tf, find the existing data "archive_file" "create_url" block.
- Add these two archive data sources directly below it:
data "archive_file" "redirect_url" {
type = "zip"
source_file = "${path.module}/../backend/redirect_url.py"
output_file_mode = "0666"
output_path = "${path.module}/packages/redirect_url.zip"
}
data "archive_file" "get_stats" {
type = "zip"
source_file = "${path.module}/../backend/get_stats.py"
output_file_mode = "0666"
output_path = "${path.module}/packages/get_stats.zip"
}
What does this code do?
Each archive_file data source packages a single Python file into a ZIP archive that Lambda can consume. Terraform calculates a hash of each ZIP so it knows when your source code changes and needs re-uploading.
The output_file_mode = "0666" ensures consistent file permissions across Windows and macOS. Without it, the ZIP hash can differ between operating systems.
- Now scroll down past the existing aws_lambda_function "create_url" resource.
- Add these two Lambda function resources below it:
resource "aws_lambda_function" "redirect_url" {
function_name = "shortly-redirect-url"
role = aws_iam_role.lambda_role.arn
handler = "redirect_url.lambda_handler"
runtime = "python3.12"
filename = data.archive_file.redirect_url.output_path
source_code_hash = data.archive_file.redirect_url.output_base64sha256
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.urls.name
}
}
}
resource "aws_lambda_function" "get_stats" {
function_name = "shortly-get-stats"
role = aws_iam_role.lambda_role.arn
handler = "get_stats.lambda_handler"
runtime = "python3.12"
filename = data.archive_file.get_stats.output_path
source_code_hash = data.archive_file.get_stats.output_base64sha256
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.urls.name
}
}
}
What does this code do?
Each aws_lambda_function resource deploys one of your Python handlers to AWS. All three share the same IAM role, runtime, and TABLE_NAME environment variable.
- source_code_hash tells Terraform to redeploy the function whenever the ZIP contents change.
- The handler value follows the pattern filename.function_name so Lambda knows which Python function to invoke.
- Save lambda.tf.
✔️ Awesome, I've got everything!
Great. Confirm you have saved lambda.tf with all three archive data sources and all three Lambda function resources.
ⓧ I'd like to double check the full code
Your lambda.tf should now contain these six blocks in order:
- data "archive_file" "create_url" packaging backend/create_url.py.
- data "archive_file" "redirect_url" packaging backend/redirect_url.py.
- data "archive_file" "get_stats" packaging backend/get_stats.py.
- aws_lambda_function "create_url" with handler create_url.lambda_handler.
- aws_lambda_function "redirect_url" with handler redirect_url.lambda_handler.
- aws_lambda_function "get_stats" with handler get_stats.lambda_handler.
Apply the changes and verify the fix
Both files are saved. Time to let Terraform deploy the new policy and functions to AWS.
- Apply your updated configuration by running this command:
terraform apply
What will Terraform do here?
Terraform will show a plan adding the inline DynamoDB policy to your existing role, creating two new archive ZIP files, and deploying two new Lambda functions. It will prompt you to confirm.
When you see the confirmation prompt, type yes and press Enter.
- Wait for the apply to complete. You should see a message like Apply complete! Resources: 4 added, 0 changed, 0 destroyed..
Seeing an error about missing source files?
If Terraform reports it cannot find redirect_url.py or get_stats.py, confirm those files exist in your backend/ directory. The path in each archive_file block is relative to the terraform/ directory, using ../backend/ to go up one level.
help me fix a Terraform archive_file source path error
Now for the moment of truth. In the last step, invoking shortly-create-url returned an AccessDeniedException. Before you run the same test again, what do you think will happen this time?
- Invoke the function again by running this command:
aws lambda invoke --function-name shortly-create-url --cli-binary-format raw-in-base64-out --payload file://test-event.json response.json
- View the response by running:
type response.json
This time you should see a JSON response with a 200 status code and a shortCode in the body. The DynamoDB policy fixed the permission gap.
Still seeing AccessDeniedException?
IAM policy changes can take a few seconds to propagate. Wait 10-15 seconds and try the invoke command again.
If it still fails, open iam.tf and confirm the Resource line says aws_dynamodb_table.urls.arn (not the table name string). Then run terraform apply again.
help me debug why my Lambda still gets AccessDeniedException after adding a DynamoDB policy
Your full backend is deployed. All three Lambda functions are live with the correct permissions. Next up, you will wire them to public HTTP routes with API Gateway so your frontend can call them.
Wire Up API Gateway
Your three Lambda functions are deployed and can read from and write to DynamoDB. But right now, the only way to invoke them is through the AWS CLI's invoke command.
API Gateway gives your functions public HTTP endpoints that any client can call. Your React frontend, a browser, or curl can hit a URL and reach your Lambda function directly. In this step, you'll wire up an HTTP API that routes requests to the correct function.
In this step, get ready to:
- Create an API Gateway HTTP API with CORS, a deployment stage, and Lambda integrations.
- Grant API Gateway permission to invoke your Lambda functions.
- Test your live API endpoint with curl.
Create the API and connect your Lambda functions
An HTTP API in API Gateway needs several pieces: the API itself (with CORS settings so browsers can call it), a stage that auto-deploys changes, integrations that connect to each Lambda, and routes that map HTTP methods and paths to those integrations.
- Create a new file called api_gateway.tf inside your terraform/ directory.
- Add the API resource and stage by pasting this code:
resource "aws_apigatewayv2_api" "shortly" {
name = "shortly-api"
protocol_type = "HTTP"
cors_configuration {
allow_origins = ["*"]
allow_methods = ["GET", "POST", "OPTIONS"]
allow_headers = ["Content-Type"]
}
}
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.shortly.id
name = "$default"
auto_deploy = true
}
What does this code do?
The aws_apigatewayv2_api resource creates an HTTP API named shortly-api. The cors_configuration block allows any origin to call it with GET, POST, or OPTIONS methods.
The aws_apigatewayv2_stage creates a $default stage with auto_deploy = true. This means every change you apply is immediately live without a manual deployment step.
- Below the stage resource in the same file, add the three integration resources that connect API Gateway to your Lambda functions:
resource "aws_apigatewayv2_integration" "create_url" {
api_id = aws_apigatewayv2_api.shortly.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.create_url.invoke_arn
integration_method = "POST"
}
resource "aws_apigatewayv2_integration" "redirect_url" {
api_id = aws_apigatewayv2_api.shortly.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.redirect_url.invoke_arn
integration_method = "POST"
}
resource "aws_apigatewayv2_integration" "get_stats" {
api_id = aws_apigatewayv2_api.shortly.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.get_stats.invoke_arn
integration_method = "POST"
}
Why is integration_method always POST?
For AWS_PROXY integrations, the integration_method is always POST regardless of the client-facing HTTP method (GET, POST, etc.). This is because API Gateway invokes Lambda using a POST request internally. The route's route_key determines what the client sees.
- Save api_gateway.tf and preview what Terraform will create by running:
terraform plan
You should see Terraform planning to add 5 new resources (1 API, 1 stage, 3 integrations). This confirms your configuration is valid before you add the remaining pieces.
Seeing a syntax error?
- Make sure each resource block has matching opening and closing braces.
- Check that aws_lambda_function.create_url.invoke_arn references the exact resource names from your lambda.tf file.
- help me fix a Terraform syntax error in my api_gateway.tf file
- Now add the three route resources below the integrations in api_gateway.tf:
resource "aws_apigatewayv2_route" "create_url" {
api_id = aws_apigatewayv2_api.shortly.id
route_key = "POST /urls"
target = "integrations/${aws_apigatewayv2_integration.create_url.id}"
}
resource "aws_apigatewayv2_route" "redirect_url" {
api_id = aws_apigatewayv2_api.shortly.id
route_key = "GET /{shortCode}"
target = "integrations/${aws_apigatewayv2_integration.redirect_url.id}"
}
resource "aws_apigatewayv2_route" "get_stats" {
api_id = aws_apigatewayv2_api.shortly.id
route_key = "GET /urls/{shortCode}/stats"
target = "integrations/${aws_apigatewayv2_integration.get_stats.id}"
}
What do these routes do?
- POST /urls routes URL creation requests to shortly-create-url.
- GET /{shortCode} routes redirect lookups to shortly-redirect-url. The curly braces make shortCode a path parameter.
- GET /urls/{shortCode}/stats routes analytics requests to shortly-get-stats.
Grant invoke permissions
API Gateway needs explicit permission to invoke each Lambda function. Without these permission resources, the API would return a 500 error because AWS blocks unauthorized callers by default.
- Add the three aws_lambda_permission resources at the bottom of api_gateway.tf:
resource "aws_lambda_permission" "create_url" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.create_url.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.shortly.execution_arn}/*/*"
}
resource "aws_lambda_permission" "redirect_url" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.redirect_url.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.shortly.execution_arn}/*/*"
}
resource "aws_lambda_permission" "get_stats" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.get_stats.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.shortly.execution_arn}/*/*"
}
What does source_arn /*/* mean?
The execution_arn is the API's internal ARN prefix. The /*/* wildcard means "any HTTP method on any route." This allows API Gateway to invoke the function for all routes and methods attached to this API.
- Save api_gateway.tf.
✔️ Awesome, I've got everything!
Great. Double check you've saved api_gateway.tf with all four sections: the API + stage, three integrations, three routes, and three permissions.
ⓧ I'd like to double check the full code
Scroll through your api_gateway.tf and confirm these resource blocks appear in order:
- aws_apigatewayv2_api "shortly" with cors_configuration block.
- aws_apigatewayv2_stage "default" with auto_deploy = true.
- Three aws_apigatewayv2_integration resources (create_url, redirect_url, get_stats).
- Three aws_apigatewayv2_route resources with route keys POST /urls, GET /{shortCode}, and GET /urls/{shortCode}/stats.
- Three aws_lambda_permission resources with principal = "apigateway.amazonaws.com".
Deploy and test your live API
- Deploy everything by running:
terraform apply
- Terraform will show a plan to create 8 new resources (1 API, 1 stage, 3 integrations, 3 routes, 3 permissions minus the 5 you previewed earlier that haven't been applied yet equals 11 total new resources).
- Type yes when prompted and wait for the apply to complete.
Once the apply finishes, Terraform prints the IDs of all created resources. You need the API endpoint URL to test it.
- Grab the API endpoint by running:
terraform state show aws_apigatewayv2_api.shortly | findstr api_endpoint
What does this command do?
This queries the Terraform state for your API resource and filters for the api_endpoint attribute. The endpoint URL looks like https://{api-id}.execute-api.us-east-1.amazonaws.com where {api-id} is a random string AWS assigned.
Before you test, what do you think will happen when you POST a URL to this endpoint? Will you get back a shortCode like when you invoked the Lambda directly?
- Test the create URL endpoint by running this command, replacing the URL with your actual endpoint:
curl -X POST [[API_ENDPOINT="your API endpoint URL from the previous command"]]/urls -H "Content-Type: application/json" -d "{\"url\": \"https://example.com\"}"
You should see a JSON response containing a shortCode value. Your Lambda function just processed an HTTP request routed through API Gateway.
Getting an Internal Server Error?
- Confirm that all three aws_lambda_permission resources were applied successfully. Run terraform state list and look for them.
- Check that you pasted the full endpoint URL including https:// and the path /urls at the end.
- Make sure there is no trailing slash before /urls (the endpoint should end with .amazonaws.com/urls, not .amazonaws.com//urls).
- help me debug a 500 error from my API Gateway endpoint
Your entire serverless backend is now accessible over HTTP. A single terraform apply deployed a DynamoDB table, three Lambda functions, IAM roles, and a fully-routed API Gateway. Next up, you'll learn to tear it all down just as easily.
Secret mission
Parameterize with Variables and Outputs
Your Terraform config works, but every resource name is hardcoded. What if you need a staging environment alongside dev? In this mission, you refactor your configuration with Terraform variables and outputs so a single value change spins up a parallel set of resources.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running or delete them entirely. This project uses serverless AWS resources that cost $0 at zero traffic, so there is no financial pressure to tear down immediately.
Resources you used:
- 1 DynamoDB table (shortly-urls) with PAY_PER_REQUEST billing.
- 3 Lambda functions (shortly-create-url, shortly-redirect-url, shortly-get-stats).
- 1 API Gateway HTTP API (shortly-api).
- 1 IAM role and policies (shortly-lambda-role).
- 3 CloudWatch Log Groups (auto-created by Lambda, not managed by Terraform).
- Local terraform/ directory with all .tf files.
Keep everything running
No action needed. Choose this if you plan to keep iterating on your Shortly project or want to demo it later.
- All AWS resources remain deployed at $0 idle cost.
- Your terraform/ directory stays on disk, ready for future terraform apply runs.
- Terraform CLI remains installed on your system.
- CloudWatch Log Groups persist and store invocation logs (minimal storage cost at this scale).
Pause - I'll come back to this later
These serverless resources have no idle cost, so there is nothing to pause. Lambda, DynamoDB (PAY_PER_REQUEST), and API Gateway only charge when requests come in.
- Leave everything running. You will not be charged while the API receives no traffic.
- When you return, your Terraform state file is ready. Run terraform plan to confirm nothing has drifted.
Delete - I don't want to use this again
Remove all AWS resources and local project files. This is irreversible.
- Destroy all Terraform-managed AWS resources by running this command from your terraform/ directory:
cd terraform
terraform destroy
- Type yes when prompted. Terraform removes the DynamoDB table, all three Lambda functions, the IAM role, and the API Gateway in about 60 seconds.
- Delete the CloudWatch Log Groups that Lambda auto-created (Terraform does not manage these):
aws logs delete-log-group --log-group-name /aws/lambda/shortly-create-url
aws logs delete-log-group --log-group-name /aws/lambda/shortly-redirect-url
aws logs delete-log-group --log-group-name /aws/lambda/shortly-get-stats
- Delete the local terraform/ directory. On macOS or Linux:
rm -rf terraform/
- On Windows (PowerShell):
Remove-Item -Recurse -Force terraform\
- Optionally remove the Terraform binary. On Windows, delete terraform.exe from the folder you extracted it to (e.g., C:\terraform) and remove that folder from your system PATH.
That's a Wrap!
That's a Wrap!
Nice work! You just built a complete Infrastructure as Code pipeline that deploys your entire serverless backend with a single command.
You've learned how to:
- Write HCL to provision a DynamoDB table, three Lambda functions, IAM roles, and API Gateway routes from scratch.
- Experience IAM least-privilege enforcement firsthand when your Lambda failed without DynamoDB permissions, then fix it with a scoped inline policy.
- Deploy a complete serverless API accessible over HTTP with a single terraform apply, and tear it all down cleanly with terraform destroy.
- Secret Mission: Parameterized your config with Terraform variables for multi-environment reuse.
Ready to quiz yourself?