Build a Serverless URL Shortener

Deploy a URL shortener using Lambda, API Gateway, and DynamoDB.

Introduction

30 Second Summary

Every time you share a link, you probably prefer short, clean URLs over long messy ones. Services like bit.ly turn sprawling addresses into tiny codes that redirect in milliseconds.

In this project, you will build your own serverless URL shortener on AWS. You will create an API that accepts long URLs, generates 6-character short codes, and redirects visitors back to the original address.

What You'll Build

A fully functional serverless URL shortener powered by AWS Lambda, API Gateway, and DynamoDB that generates 6-character short codes and performs instant 302 redirects.

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

  • A DynamoDB table with a hash-key design optimized for single-digit-millisecond lookups at any scale.
  • Two Lambda functions with collision-safe short code generation and instant redirect logic, each locked down with a least-privilege IAM role.
  • A REST API with Lambda proxy integration and two resource paths, deployed and callable from your terminal.
  • Secret Mission: Add TTL-based link expiration so short URLs automatically self-destruct after 7 days.

Are there any prerequisites?

You need an AWS account that is free tier eligible. All work is done in the AWS Management Console and your local terminal.

Create the DynamoDB Table

Every URL shortener needs a database that can look up a short code and return the original URL in milliseconds.

DynamoDB is AWS's serverless key-value database, built for exactly this kind of single-key retrieval. In this step, you'll create the DynamoDB table that stores all your short code to URL mappings.

In this step, get ready to:
  • Create a DynamoDB table named url-shortener with on-demand capacity.
  • Verify the table status is Active and understand the key design.
  • Copy the table ARN for use in IAM policies.
Create the url-shortener table

You'll configure this table with a single partition key and on-demand capacity mode. This means DynamoDB handles all scaling automatically and you only pay for what you use.

  • Open the AWS Management Console at console.aws.amazon.com.
  • Click the search bar at the top of the page.
  • Type DynamoDB and select DynamoDB from the results.
  • In the left navigation pane, click Tables.
  • Click Create table.
  • In the Table name field, enter url-shortener.
  • In the Partition key field, enter short_code.
  • Make sure the partition key type dropdown is set to String.
  • Leave the Sort key field empty.
  • Under Table settings, select Customize settings.
  • Under Read/write capacity settings, select On-demand.
  • Leave all other settings as their defaults.
  • Click Create table.

Why on-demand capacity?

On-demand mode means DynamoDB automatically scales to handle whatever traffic you send it. You never need to predict how many reads or writes per second your table needs.

For a URL shortener with unpredictable traffic patterns, this is simpler than provisioned capacity and costs nothing within the free tier for low usage.

Don't see the Create table button?

Make sure you're in the Tables section in the left sidebar, not the DynamoDB dashboard. If you're on the dashboard, click Tables first.

help me find the Create table button in DynamoDB

Verify the table is active
  • Wait for the table status to show Active in the Tables list. This usually takes a few seconds.
  • Click on the table name url-shortener to open its details page.

You should see the table overview with Partition key showing short_code (S) and no sort key listed.

Why a partition key with no sort key?

Your URL shortener always looks up a single short code to find the matching URL. This is a direct key-value lookup. A partition key alone gives DynamoDB everything it needs to locate the item in constant time.

A sort key is useful when you need to query ranges of items under the same partition key (like finding all orders for a customer). That pattern doesn't apply here because every request targets exactly one short code.

Copy the table ARN

You need the table's ARN (Amazon Resource Name) to create IAM policies that grant your Lambda functions access to this specific table.

  • On the url-shortener table detail page, scroll down to the General information section.
  • Click Additional info to expand that section if it is not already visible.
  • Find the Amazon Resource Name (ARN) field.
  • Click the copy icon next to the ARN to copy it to your clipboard.
  • Paste it somewhere you can find it easily, like a text file or sticky note. You will use this ARN in the next step.

What is an ARN?

An ARN (Amazon Resource Name) is a unique identifier for any resource in AWS. It looks something like arn:aws:dynamodb:us-east-1:123456789012:table/url-shortener.

You'll use this exact ARN to tell IAM "only allow access to this specific table." This is how you enforce least-privilege permissions.

Can't find the ARN?

Make sure you clicked into the table itself (not just viewing the Tables list). The ARN is on the table detail page under General information > Additional info.

help me find my DynamoDB table ARN in the AWS Console

Your DynamoDB table is ready and waiting for data. Next up, you'll create the IAM roles that control exactly which Lambda functions can read from and write to this table.

Create IAM Roles with Least-Privilege Policies

Your DynamoDB table is ready to store short codes and their matching URLs. Now you need to give your Lambda functions permission to interact with it.

Every Lambda function needs an IAM role that defines exactly what it can do. In this step, you will create two separate roles. One grants only write access for creating short URLs. The other grants only read access for looking them up.

In this step, get ready to:
  • Create an IAM role for the create function with write-only DynamoDB access.
  • Create an IAM role for the redirect function with read-only DynamoDB access.
  • Verify both roles have the correct trust policy for Lambda.
Create the create function's IAM role

The create function will write new items to DynamoDB. It needs permission for dynamodb:PutItem and nothing else.

  • In the AWS Console, type IAM into the search bar at the top and select IAM from the results.
  • In the left navigation pane, click Roles.
  • Click Create role.
  • For Trusted entity type, select AWS service.
  • In the Service or use case dropdown, select Lambda.
  • Click Next.

You are now on the Add permissions page. You need to attach the managed policy for CloudWatch Logs.

  • In the search box, type AWSLambdaBasicExecutionRole.
  • Check the box next to AWSLambdaBasicExecutionRole.
  • Click Next.

What is AWSLambdaBasicExecutionRole?

This is an AWS managed policy that grants your Lambda function permission to write logs to CloudWatch Logs. Without it, you would have no way to see what your function does when it runs.

You are now on the Name, review, and create page.

  • In the Role name field, enter url-shortener-create-role.
  • Scroll down and click Create role.

You should see a green success banner confirming the role was created. Now you need to add an inline policy that limits this role to only dynamodb:PutItem.

  • In the Roles list, click the role name url-shortener-create-role to open it.
  • Select the Permissions tab.
  • Click Add permissions, then select Create inline policy.
  • Click the JSON tab in the policy editor.
  • Delete the default content and paste the following policy:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "dynamodb:PutItem",
            "Resource": "[[TABLE_ARN=\"your url-shortener table ARN from the DynamoDB Overview tab\"]]"
        }
    ]
}

What does this policy do?

This policy grants exactly one permission: dynamodb:PutItem. It scopes that permission to your specific table ARN, so the function cannot write to any other DynamoDB table in your account.

The Resource field is the table ARN you noted in Step 1. It looks something like arn:aws:dynamodb:us-east-1:123456789012:table/url-shortener.

  • Click Next.
  • In the Policy name field, enter dynamodb-put-item-policy.
  • Click Create policy.

You should see the inline policy appear under the Permissions tab alongside AWSLambdaBasicExecutionRole.

Don't see two policies listed?

Make sure you clicked Create inline policy (not Attach policies). Inline policies are embedded directly on the role. If you accidentally attached a managed policy instead, remove it and try again.

Also confirm your JSON has no syntax errors. The console highlights errors in red if the JSON is malformed.

help me fix my IAM inline policy for the create role

Create the redirect function's IAM role

The redirect function only needs to read items from DynamoDB. You will repeat the same process with one key difference: the permission is dynamodb:GetItem instead of PutItem.

  • In the left navigation pane, click Roles.
  • Click Create role.
  • For Trusted entity type, select AWS service.
  • In the Service or use case dropdown, select Lambda.
  • Click Next.
  • Search for AWSLambdaBasicExecutionRole and check the box next to it.
  • Click Next.
  • In the Role name field, enter url-shortener-redirect-role.
  • Scroll down and click Create role.

Now add the inline policy for read-only DynamoDB access.

  • In the Roles list, click url-shortener-redirect-role to open it.
  • Select the Permissions tab.
  • Click Add permissions, then select Create inline policy.
  • Click the JSON tab in the policy editor.
  • Delete the default content and paste the following policy:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "dynamodb:GetItem",
            "Resource": "[[TABLE_ARN=\"your url-shortener table ARN from the DynamoDB Overview tab\"]]"
        }
    ]
}
  • Click Next.
  • In the Policy name field, enter dynamodb-get-item-policy.
  • Click Create policy.

You should see the inline policy listed alongside AWSLambdaBasicExecutionRole on the role's Permissions tab.

Why separate roles instead of one shared role?

A single role with both PutItem and GetItem would work functionally. But if an attacker compromised the redirect function, they could also write malicious data to your table.

Separate roles enforce the principle of least privilege. Each function can only perform the exact action it needs. This is a security best practice in production systems.

Verify the trust policies on both roles

Each role must trust the Lambda service so that Lambda can assume it at runtime.

  • In the Roles list, click url-shortener-create-role.
  • Select the Trust relationships tab.
  • Confirm the Trusted entities section shows lambda.amazonaws.com as the service principal.
  • Navigate back to the Roles list and click url-shortener-redirect-role.
  • Select the Trust relationships tab and confirm lambda.amazonaws.com appears as the trusted entity.

The trust policy JSON for both roles should look like this:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "lambda.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

What is a trust policy?

A trust policy controls who can assume the role. By specifying lambda.amazonaws.com as the principal, you allow the Lambda service to use this role when executing your functions. AWS automatically set this trust policy because you selected Lambda as the use case when creating the role.

Trust policy doesn't show lambda.amazonaws.com?

This means you may not have selected Lambda as the use case when creating the role. Click Edit trust policy on the Trust relationships tab and paste the trust policy JSON shown above, then click Update policy.

help me fix the trust policy on my IAM role

Both IAM roles are locked and loaded. Next up, you will write the Lambda function that generates short codes and stores them in DynamoDB.

Build the Create Short URL Lambda Function

Your DynamoDB table is ready to store short codes, and your IAM roles are locked down with least-privilege permissions.

Now you need the actual compute logic that takes a long URL, generates a random 6-character code, and writes the mapping to DynamoDB. In this step, you will build that logic as an AWS Lambda function with collision-safe writes.

In this step, get ready to:
  • Create a Lambda function with the Python 3.13 runtime and your custom IAM role.
  • Write handler code that generates collision-safe short codes using conditional DynamoDB writes.
  • Test the function with a simulated API Gateway proxy request and verify the item in DynamoDB.
Create the Lambda function in the AWS Console

Lambda functions are units of compute that run only when triggered. You do not manage any servers. AWS handles provisioning, scaling, and teardown automatically.

  • In the AWS Console, search for Lambda in the top search bar and select the Lambda service.
  • Click Create function.
  • Select Author from scratch (this should already be selected by default).
  • Enter url-shortener-create in the Function name field.
  • For Runtime, select Python 3.13.
  • Expand the Change default execution role section under Permissions.
  • Select Use an existing role.
  • From the Existing role dropdown, choose url-shortener-create-role.
  • Click Create function.

You should see a success banner at the top of the page confirming your function was created.

Why use an existing role?

In the previous step, you created url-shortener-create-role with only dynamodb:PutItem permission. By attaching it here, this function can only write to DynamoDB. It cannot read, delete, or scan.

If you let Lambda create a default role instead, it would have broader permissions than this function actually needs.

The default memory setting of 128 MB is perfect for this function. No changes needed there.

Don't see your role in the dropdown?

Make sure you are in the same AWS region where you created the role. IAM roles are global, but the dropdown can sometimes take a moment to populate. Try refreshing the page.

Also confirm the role's trust policy includes lambda.amazonaws.com as the trusted service. Without that, Lambda will not list it.

Help me figure out why my IAM role isn't appearing in the Lambda console

Write the handler code

Your function was created with default boilerplate code. You will replace it with the URL shortening logic.

  • In your function's page, scroll down to the Code source section.
  • Double-click lambda_function.py in the file tree to open it in the editor.
  • Select all the existing code and delete it.
  • Paste the following imports and setup code:
import json
import string
import random
import time
import boto3
from botocore.exceptions import ClientError

# Connect to DynamoDB and reference the url-shortener table
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('url-shortener')

# Base62 character set for generating short codes (a-z, A-Z, 0-9)
CHARACTERS = string.ascii_letters + string.digits


def generate_short_code(length=6):
    # Pick 6 random characters from the Base62 set
    return ''.join(random.choices(CHARACTERS, k=length))

What does this code do?

  • boto3 is the AWS SDK for Python. It comes pre-installed in the Lambda Python runtime, so you do not need to install it yourself.
  • CHARACTERS combines all lowercase letters, uppercase letters, and digits to create a Base62 character set. With 6 characters, that gives you 62^6 = roughly 56.8 billion possible codes.
  • generate_short_code() picks 6 random characters from that set to produce a short code like aB3xYz.
  • Below the code you just pasted, add the main handler function:

def lambda_handler(event, context):
    # Parse the JSON body from the API Gateway request
    try:
        body = json.loads(event.get('body', '{}'))
    except (json.JSONDecodeError, TypeError):
        return {
            'statusCode': 400,
            'headers': {'Content-Type': 'application/json'},
            'body': json.dumps({'error': 'Invalid JSON in request body'})
        }

    # Extract the long URL from the request
    long_url = body.get('url')
    if not long_url:
        return {
            'statusCode': 400,
            'headers': {'Content-Type': 'application/json'},
            'body': json.dumps({'error': 'Missing required field: url'})
        }

    # Retry loop to handle unlikely short code collisions
    max_attempts = 5
    for attempt in range(max_attempts):
        short_code = generate_short_code()
        try:
            # Only write if this short_code doesn't already exist
            table.put_item(
                Item={
                    'short_code': short_code,
                    'long_url': long_url,
                    'created_at': int(time.time())
                },
                ConditionExpression='attribute_not_exists(short_code)'
            )
            # Success - return the new short code
            return {
                'statusCode': 200,
                'headers': {'Content-Type': 'application/json'},
                'body': json.dumps({
                    'short_code': short_code,
                    'long_url': long_url
                })
            }
        except ClientError as e:
            # If the code already exists, try again with a new one
            if e.response['Error']['Code'] == 'ConditionalCheckFailedException':
                continue
            raise

    # All attempts collided (astronomically unlikely)
    return {
        'statusCode': 500,
        'headers': {'Content-Type': 'application/json'},
        'body': json.dumps({'error': 'Failed to generate unique short code'})
    }

What does the handler do?

  • lambda_handler is the entry point that Lambda calls when the function is triggered. The event parameter contains the incoming API Gateway request.
  • It parses the JSON body looking for a url field. If it is missing or the body is invalid JSON, the function returns a 400 error.
  • ConditionExpression='attribute_not_exists(short_code)' tells DynamoDB to only write if no item with that key exists yet. If a collision occurs, the function generates a new code and retries up to 5 times.
  • The response format with statusCode, headers, and body is required by Lambda proxy integration. API Gateway passes this directly back to the caller.
  • Click Deploy (above the code editor) to save and deploy your function.

You should see a banner saying "Successfully updated the function url-shortener-create."

Seeing a deployment error?

Check for indentation issues. Python is whitespace-sensitive, so every level of indentation must use consistent spaces (not a mix of tabs and spaces).

Make sure the function name at the top of the handler is exactly lambda_handler. Lambda looks for this name by default.

Help me fix a deployment error in my Lambda function

✔️ Awesome, I've got everything!

Great. Make sure you clicked Deploy and saw the success banner before moving on.

ⓧ I'd like to double check the full code

Here is the complete lambda_function.py file. Your code editor should match this exactly.

import json
import string
import random
import time
import boto3
from botocore.exceptions import ClientError

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('url-shortener')

CHARACTERS = string.ascii_letters + string.digits


def generate_short_code(length=6):
    return ''.join(random.choices(CHARACTERS, k=length))


def lambda_handler(event, context):
    try:
        body = json.loads(event.get('body', '{}'))
    except (json.JSONDecodeError, TypeError):
        return {
            'statusCode': 400,
            'headers': {'Content-Type': 'application/json'},
            'body': json.dumps({'error': 'Invalid JSON in request body'})
        }

    long_url = body.get('url')
    if not long_url:
        return {
            'statusCode': 400,
            'headers': {'Content-Type': 'application/json'},
            'body': json.dumps({'error': 'Missing required field: url'})
        }

    max_attempts = 5
    for attempt in range(max_attempts):
        short_code = generate_short_code()
        try:
            table.put_item(
                Item={
                    'short_code': short_code,
                    'long_url': long_url,
                    'created_at': int(time.time())
                },
                ConditionExpression='attribute_not_exists(short_code)'
            )
            return {
                'statusCode': 200,
                'headers': {'Content-Type': 'application/json'},
                'body': json.dumps({
                    'short_code': short_code,
                    'long_url': long_url
                })
            }
        except ClientError as e:
            if e.response['Error']['Code'] == 'ConditionalCheckFailedException':
                continue
            raise

    return {
        'statusCode': 500,
        'headers': {'Content-Type': 'application/json'},
        'body': json.dumps({'error': 'Failed to generate unique short code'})
    }
Test the function with a simulated API Gateway request

Before wiring this function to a real API, you can test it directly in the Lambda console using a test event. The test event mimics the JSON structure that API Gateway sends to Lambda.

  • Click the Test tab next to Code (near the top of the function page).
  • For Event name, enter test-create.
  • Replace the default JSON with the following test event:
{
  "body": "{\"url\": \"https://www.example.com/very/long/path/to/a/resource\"}",
  "httpMethod": "POST",
  "path": "/shorten"
}

Why is the body a string inside JSON?

When API Gateway uses Lambda proxy integration, it sends the request body as a JSON-encoded string inside the body field. That is why you see escaped quotes. Your handler calls json.loads(event.get('body')) to decode this back into a Python dictionary.

  • Click Test.

You should see a green "Execution result: succeeded" banner. The response body contains a short_code (a random 6-character string) and the long_url you submitted.

  • Now verify the item was saved. Open the DynamoDB console in a new tab.
  • Click Tables in the left sidebar, then click url-shortener.
  • Click Explore table items.

You should see one item with three attributes: short_code, long_url, and created_at (a Unix timestamp).

Test failed or no item in DynamoDB?

If you see an "AccessDeniedException" error, your IAM role may be missing the dynamodb:PutItem permission, or the table ARN in the policy does not match your actual table. Go back to IAM and double-check the inline policy on url-shortener-create-role.

If you see "Invalid JSON in request body", make sure the body field in your test event is a string (with escaped quotes), not a nested JSON object.

Help me debug my Lambda function test failure

Your create function is working. It generates a short code, safely writes it to DynamoDB, and handles edge cases like collisions and bad input. Next up, you will build the redirect function that looks up a short code and sends the user to the original URL.

Build the Redirect Lambda Function

Your create function can shorten URLs, but a URL shortener is useless if people can't actually visit those short links. You need a second function that looks up a short code and sends the user to the original URL.

In this step, you'll build the redirect function. It reads the short code from the URL path, fetches the matching record from DynamoDB, and returns an HTTP 302 redirect that sends the browser to the original long URL.

In this step, get ready to:
  • Create the redirect Lambda function with the correct IAM role.
  • Write handler code that performs a DynamoDB lookup and returns a 302 redirect.
  • Test the function with a known short code and verify the redirect response.
Create the url-shortener-redirect function

You'll follow the same process you used for the create function, but this time you'll assign the url-shortener-redirect-role which only has permission to read from DynamoDB.

  • In the AWS Console, navigate to the Lambda service using the search bar at the top.
  • Click Create function.
  • Select Author from scratch.
  • Enter url-shortener-redirect in the Function name field.
  • Select Python 3.13 from the Runtime dropdown.
  • Expand Change default execution role.
  • Select Use an existing role.
  • Choose url-shortener-redirect-role from the Existing role dropdown.
  • Click Create function.

You should see a success banner confirming the function was created.

  • Scroll down to the General configuration section and click Edit.
  • Set Memory to 128 MB.
  • Click Save.

Why a separate role for this function?

The redirect function only needs to read from DynamoDB, so its role only grants dynamodb:GetItem. If this function were ever compromised, an attacker could not write or delete data.

This is least-privilege in action. Each function gets only the permissions it needs to do its job.

Write the redirect handler code

When API Gateway sends a request to this function, it includes a pathParameters field containing the short code from the URL. Your handler extracts that code, looks it up in DynamoDB, and returns an HTTP 302 redirect.

  • In the Code source editor, select all the existing code in lambda_function.py and delete it.
  • Replace it with the following code:
import json
import boto3

# Connect to the DynamoDB url-shortener table
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('url-shortener')


def lambda_handler(event, context):
    # Extract path parameters from the API Gateway event
    path_params = event.get('pathParameters')
    if not path_params or 'code' not in path_params:
        return {
            'statusCode': 400,
            'headers': {'Content-Type': 'application/json'},
            'body': json.dumps({'error': 'Missing short code in path'})
        }

    short_code = path_params['code']

    # Look up the short code in DynamoDB
    response = table.get_item(Key={'short_code': short_code})
    item = response.get('Item')

    # Return 404 if the short code doesn't exist
    if not item:
        return {
            'statusCode': 404,
            'headers': {'Content-Type': 'application/json'},
            'body': json.dumps({'error': 'Short URL not found'})
        }

    # Return a 302 redirect to the original long URL
    return {
        'statusCode': 302,
        'headers': {
            'Location': item['long_url']
        },
        'body': ''
    }

What does this code do?

  • event.get('pathParameters') pulls the short code from the URL path. When someone visits /abc123, API Gateway maps that to {'code': 'abc123'}.
  • table.get_item(Key={'short_code': short_code}) performs an O(1) lookup in DynamoDB using the partition key. This is why the table design uses short_code as the hash key.
  • The 302 status code tells the browser to navigate to the URL in the Location header. The browser handles the redirect automatically.
  • Click Deploy to save and deploy your code.

You should see a banner confirming the function was updated successfully.

Don't see the Deploy button?

Make sure you're in the Code source section, not the Configuration tab. Scroll down if needed. The Deploy button sits above the code editor.

help me find the Deploy button for my Lambda function

Test the redirect function with a known short code

You need a short code that actually exists in your DynamoDB table. You created one when you tested the create function in the previous step.

  • Open the DynamoDB console in a new tab.
  • Click Tables in the left sidebar, then click url-shortener.
  • Click Explore table items.
  • Copy the short_code value from one of the items in your table. You'll use this in your test event.
  • Switch back to the url-shortener-redirect Lambda function tab.
  • Click the Test tab.
  • Enter redirectTest in the Event name field.
  • Replace the default test event JSON with the following, substituting in the short code you just copied:
{
  "pathParameters": {
    "code": "[[SHORT_CODE=\"your short code from DynamoDB\"]]"
  }
}
  • Click Test.

You should see a successful execution result with a response that looks like this:

{
  "statusCode": 302,
  "headers": {
    "Location": "https://www.example.com/some-long-url"
  },
  "body": ""
}

What should I see?

The statusCode should be 302 and the Location header should contain the original long URL you shortened in the previous step. This 302 status code is what tells a browser to redirect.

Seeing a 404 response instead?

A 404 means the short code you entered in the test event doesn't match any item in your DynamoDB table. Double check that you copied the exact short_code value from the table, including uppercase and lowercase letters.

Also confirm you placed it inside the "code" field of pathParameters in your test JSON, not inside a body field.

help me debug my 404 response from the redirect Lambda function

✔️ Awesome, I've got everything!

Great. Double check you've deployed the function and that your test returned a 302 with the correct Location header.

ⓧ I'd like to double check the full code

Here's the complete code for lambda_function.py in your url-shortener-redirect function:

import json
import boto3

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('url-shortener')


def lambda_handler(event, context):
    path_params = event.get('pathParameters')
    if not path_params or 'code' not in path_params:
        return {
            'statusCode': 400,
            'headers': {'Content-Type': 'application/json'},
            'body': json.dumps({'error': 'Missing short code in path'})
        }

    short_code = path_params['code']

    response = table.get_item(Key={'short_code': short_code})
    item = response.get('Item')

    if not item:
        return {
            'statusCode': 404,
            'headers': {'Content-Type': 'application/json'},
            'body': json.dumps({'error': 'Short URL not found'})
        }

    return {
        'statusCode': 302,
        'headers': {
            'Location': item['long_url']
        },
        'body': ''
    }

Both Lambda functions are built and tested. Next up, you'll create the REST API that connects them to the outside world so anyone can shorten and redirect URLs over HTTP.

Create the REST API and Wire Lambda Integration

You have two Lambda functions that can create short URLs and redirect visitors. Right now, though, there is no way for the outside world to reach them.

In this step, you will build a REST API using API Gateway that exposes two endpoints. A POST to /shorten will trigger your create function, and a GET to /{code} will trigger your redirect function.

In this step, get ready to:
  • Create a REST API with Regional endpoint type.
  • Wire two resources with Lambda proxy integration.
  • Deploy the API and test both endpoints with curl.
Create a new REST API in API Gateway

API Gateway acts as the front door for your Lambda functions. It receives HTTP requests from the internet and routes them to the correct function.

  • Open the AWS Management Console and search for API Gateway in the top search bar.
  • Click on **API Gateway** to open the service.
  • Click **Create API**.
  • Find the **REST API** card (not the one labelled "private") and click **Build**.
  • For **API name**, enter url-shortener-api.
  • Keep **API endpoint type** set to **Regional**.
  • For **IP address type**, select **IPv4**.
  • Click **Create API**.

What is a Regional endpoint?

A Regional endpoint deploys your API in a single AWS region. This is the simplest setup and keeps latency low for testing from your own machine.

For production traffic spanning multiple continents, you could add a CloudFront distribution later. For this project, Regional is the right choice.

You should now see the **Resources** page for your new API with a single root resource (/).

Create the /shorten resource with a POST method

Your create function needs a dedicated path. You will create a /shorten resource and attach a POST method that forwards requests to your url-shortener-create Lambda function using proxy integration.

  • In the **Resources** pane, select the root resource /.
  • Click **Create resource**.
  • For **Resource name**, enter shorten.
  • Leave **Proxy resource** turned off.
  • Leave **CORS** turned off.
  • Click **Create resource**.

You should see /shorten appear below the root resource in the tree.

  • Select the /shorten resource you just created.
  • Click **Create method**.
  • For **Method type**, select **POST**.
  • For **Integration type**, select **Lambda function**.
  • Turn on **Lambda proxy integration**.
  • For **Lambda function**, select your region, then enter url-shortener-create and select it from the dropdown.
  • Click **Create method**.
  • A dialog titled **Add Permission to Lambda Function** appears. Click **OK** to grant API Gateway permission to invoke your function.

What is Lambda proxy integration?

With proxy integration, API Gateway passes the entire HTTP request (headers, body, path parameters) straight through to your Lambda function as the event object. Your function then returns a response object with statusCode, headers, and body.

This is the simplest integration pattern because you do not need to configure any request or response mapping templates. The Lambda function handles everything.

Create the /{code} resource with a GET method

Your redirect function needs to receive the short code from the URL path. You will create a path parameter resource {code} and attach a GET method pointing to url-shortener-redirect.

  • In the **Resources** pane, select the root resource / again.
  • Click **Create resource**.
  • For **Resource name**, enter {code}.
  • The **Resource path** field should show /{code}. This curly brace syntax tells API Gateway that this segment is a path parameter.
  • Leave **Proxy resource** and **CORS** turned off.
  • Click **Create resource**.

You should see /{code} appear alongside /shorten under the root.

  • Select the /{code} resource.
  • Click **Create method**.
  • For **Method type**, select **GET**.
  • For **Integration type**, select **Lambda function**.
  • Turn on **Lambda proxy integration**.
  • For **Lambda function**, enter url-shortener-redirect and select it from the dropdown.
  • Click **Create method**.
  • When the **Add Permission to Lambda Function** dialog appears, click **OK**.

How does the path parameter reach Lambda?

When a request hits /prod/abc123, API Gateway extracts abc123 and places it in event['pathParameters']['code'] inside your Lambda function. This is exactly what your redirect handler reads to look up the original URL.

Deploy the API and test with curl

Your API exists in API Gateway but is not yet accessible from the internet. You need to deploy it to a stage to make it live.

  • In the **Resources** pane, click **Deploy API**.
  • For **Stage**, select **New stage**.
  • For **Stage name**, enter prod.
  • Click **Deploy**.

API Gateway takes you to the **Stages** page. Under **Stage details**, you will see your invoke URL.

  • Click the copy icon next to the **Invoke URL** to copy it. It looks like https://<api-id>.execute-api.<region>.amazonaws.com/prod.

What is a stage?

A stage is a named snapshot of your deployed API. Common stage names include dev, staging, and prod. Each stage gets its own invoke URL, so you can test changes without affecting production traffic.

Now open your terminal and test the POST endpoint to create a short URL.

  • Test the create endpoint by running this command:
curl -X POST [[INVOKE_URL="your invoke URL"]]/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://www.example.com/very/long/path"}'

You should see a JSON response containing a short_code and the original long_url.

  • Copy the short_code value from the response.
  • Test the redirect endpoint by running this command (replace the short code with your actual value):
curl -v [[INVOKE_URL="your invoke URL"]]/[[SHORT_CODE="your short code from the previous response"]]

Look for < HTTP/2 302 in the verbose output. You should also see a location: https://www.example.com/very/long/path header confirming the redirect.

Follow the redirect automatically

Add the -L flag to your curl command to follow redirects. This will fetch the final destination page instead of stopping at the 302 response.

Getting a 500 Internal Server Error?

Make sure you selected the correct Lambda function for each method. The POST on /shorten should point to url-shortener-create and the GET on /{code} should point to url-shortener-redirect.

Also confirm that you turned on **Lambda proxy integration** for both methods. Without it, API Gateway will not pass the request in the format your Lambda functions expect.

If you clicked away the **Add Permission to Lambda Function** dialog without clicking OK, your API cannot invoke the function. Go back to the method's integration settings, re-enter the function name, and click the checkmark to trigger the permission dialog again.

Help me troubleshoot my API Gateway 500 error with Lambda proxy integration.

Your URL shortener is live. Anyone with the invoke URL can create short links and get redirected to the original destination. Next up, you will stress-test the system with concurrent requests to prove it handles real-world load.

Test Under Concurrent Load

Your URL shortener works for single requests. But real-world APIs handle many users at the same time.

In this step, you will fire 20 simultaneous requests at your API to prove that the ConditionExpression collision handling actually works under pressure. You will also verify that Lambda scales to handle concurrent invocations without errors.

In this step, get ready to:
  • Send 20 parallel POST requests and confirm all short codes are unique.
  • Send parallel GET requests and verify 302 redirects to the correct URLs.
  • Check CloudWatch Logs to confirm zero errors under concurrent execution.
Send 20 parallel POST requests

A bash for loop with & sends each curl request to the background, so all 20 fire almost simultaneously. The wait command pauses until every background job completes.

  • Open your macOS Terminal.
  • Run 20 parallel POST requests by pasting this command:
for i in {1..20}; do
  curl -s -X POST [[API_URL="your invoke URL e.g. https://abc123.execute-api.us-east-1.amazonaws.com/prod"]]/shorten \
    -H "Content-Type: application/json" \
    -d "{\"url\": \"https://example.com/page$i\"}" &
done
wait

What does this command do?

The loop iterates from 1 to 20. Each iteration sends a POST request with a unique URL (https://example.com/page1 through https://example.com/page20).

The & at the end of each curl sends it to the background. This means all 20 requests launch nearly at once, simulating real concurrent traffic hitting your Lambda functions.

After a few seconds, you should see 20 JSON responses printed to your terminal. Each one contains a unique short_code and the corresponding long_url.

  • Now verify all 20 items are in DynamoDB. In the AWS Console, navigate to the DynamoDB service.
  • Click Tables in the left sidebar, then click url-shortener.
  • Click Explore table items.
  • Click Run to scan the table. You should see at least 20 items (plus any test items from earlier steps). Every short_code value should be unique.

Why are all the codes unique?

Your Lambda function uses ConditionExpression='attribute_not_exists(short_code)' on every write. If two Lambda instances generate the same code at the same instant, only one write succeeds. The other gets a ConditionalCheckFailedException and retries with a new code.

With 62^6 (roughly 56.8 billion) possible codes, collisions are astronomically unlikely for small datasets. But the retry loop means your system handles them gracefully even at scale.

Seeing errors in the output?

If you see Internal server error responses, your Lambda function may be hitting a cold start timeout. Wait 30 seconds and run the loop again. Lambda scales up automatically after the first batch of invocations.

If you see Endpoint request timed out, your API Gateway integration timeout (29 seconds default) may need more time for cold starts. Run the batch again. The second batch will be faster because Lambda containers stay warm.

help me debug errors in my concurrent POST requests to the URL shortener API

Send parallel GET requests to verify redirects

Now you will confirm the redirect function works under load too. Pick 5 short codes from the output you just received and fire parallel GET requests for each one.

  • Copy 5 short codes from the JSON output in your terminal. You will substitute these into the command below.
  • Run parallel GET requests for your 5 short codes by pasting this command (replacing each code placeholder with a real short code from your output):
for code in [[CODE1="your-first-short-code"]] [[CODE2="your-second-short-code"]] [[CODE3="your-third-short-code"]] [[CODE4="your-fourth-short-code"]] [[CODE5="your-fifth-short-code"]]; do
  curl -s -o /dev/null -w "Code: $code -> HTTP %{http_code} Location: %{redirect_url}\n" \
    [[API_URL="your invoke URL e.g. https://abc123.execute-api.us-east-1.amazonaws.com/prod"]]/$code &
done
wait

What does this command do?

The -o /dev/null suppresses the response body. The -w flag prints a formatted output showing the HTTP status code and the redirect URL from the Location header.

Each line of output should show HTTP 302 followed by the original URL you submitted (e.g. https://example.com/page3).

You should see 5 lines of output. Each line shows HTTP 302 and the correct original URL that matches the short code you used.

Seeing HTTP 404 for a code?

Double-check you copied the short code exactly from the POST output. Short codes are case-sensitive. A single wrong character returns a 404.

help me debug why my GET request returns 404 instead of 302

Check CloudWatch Logs for zero errors

The final check confirms your functions ran cleanly. CloudWatch Logs captures every Lambda invocation, including any unhandled exceptions.

  • In the AWS Console, search for CloudWatch in the top search bar and open the service.
  • Click Log groups in the left sidebar under Logs.
  • Click the log group named /aws/lambda/url-shortener-create.
  • Click the most recent log stream (the one at the top of the list).
  • Scan the log events for any lines containing ERROR or Traceback. You should see none.
  • Go back to Log groups and click /aws/lambda/url-shortener-redirect.
  • Click the most recent log stream.
  • Scan for ERROR or Traceback lines. Again, you should see none.

If both log groups show clean invocations with no errors, your URL shortener is confirmed working under concurrent load.

What should I see in the logs?

Each invocation produces a START, END, and REPORT line. The REPORT line shows duration, billed duration, and memory used. You may see multiple log streams because Lambda spun up separate containers to handle the concurrent requests.

If you have many log streams, check the 2-3 most recent ones. As long as none contain ERROR lines, your functions are healthy.

Seeing errors in CloudWatch?

If you see AccessDeniedException errors, your IAM role is missing the required DynamoDB permission. Go back to the IAM Console and verify the inline policy on the correct role grants dynamodb:PutItem (for the create role) or dynamodb:GetItem (for the redirect role).

If you see timeout errors, the default 3-second Lambda timeout may be too short for cold starts. In the Lambda function configuration, increase the timeout to 10 seconds.

help me fix errors showing in my Lambda CloudWatch Logs

Secret mission

Add TTL-Based Link Expiration

Your URL shortener works great, but shortened links live forever. In production systems, stale links pile up and waste storage. In this secret mission, you will add automatic link expiration so short URLs self-destruct after 7 days using DynamoDB TTL.

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.

This project uses serverless resources that only incur cost when invoked. All usage from this project falls well within the AWS Free Tier.

Cost warning

The API Gateway REST API free tier (1 million calls/month) expires after 12 months. After that, any calls to your API will be billed at $3.50 per million requests.

DynamoDB storage and Lambda invocations are Always Free tier and will not incur cost. If your AWS account is older than 12 months, consider deleting the API Gateway API to avoid unexpected charges.

Resources you used:

  • DynamoDB table: url-shortener.
  • Lambda function: url-shortener-create.
  • Lambda function: url-shortener-redirect.
  • REST API: url-shortener-api (API Gateway).
  • IAM role: url-shortener-create-role.
  • IAM role: url-shortener-redirect-role.
  • CloudWatch Log groups for both Lambda functions.

Keep everything running

No action needed. Choose this if you want to keep experimenting with your URL shortener. Serverless resources only cost money when invoked, and your usage is well within free tier limits.

  • Your API endpoint remains live and callable.
  • DynamoDB stores your short codes at no cost (25 GB free forever).
  • Lambda functions sit idle with zero cost until invoked.

Pause - I'll come back to this later

Serverless resources do not run continuously, so there is nothing to "pause" in the traditional sense. However, you can remove the API Gateway deployment to prevent external access while keeping all code and data intact.

  • Open the API Gateway console and select url-shortener-api.
  • Click Stages in the left sidebar.
  • Select the prod stage, then click Delete Stage.
  • This removes the public endpoint. Your Lambda functions and DynamoDB data remain untouched.
  • To resume later, redeploy the API to a new prod stage.

Delete - I don't want to use this again

Remove all project resources completely. Delete them in this order to avoid dependency issues.

  • Open the API Gateway console, select url-shortener-api, click Actions, then Delete API.
  • Open the Lambda console, select url-shortener-create, click Actions, then Delete function.
  • Repeat for url-shortener-redirect.
  • Open the DynamoDB console, select the url-shortener table, click Delete, and confirm.
  • Open the IAM console, go to Roles, search for url-shortener, and delete both url-shortener-create-role and url-shortener-redirect-role.
  • Open the CloudWatch console, go to Log groups, and delete the log groups for both Lambda functions.

Nice Work!

Nice Work!

You just built a production-ready serverless URL shortener from scratch on AWS.

What you learned:

  • Designed a DynamoDB table with a hash-key schema optimized for O(1) lookups, and wrote two Lambda functions in Python with collision-safe conditional writes and proper error handling.
  • Wired a REST API in API Gateway with Lambda proxy integration across two resource paths, then verified correctness under concurrent load with parallel requests.
  • Enforced least-privilege IAM with separate roles scoped to only PutItem and GetItem for each function.
  • Added TTL-based automatic link expiration with graceful 410 Gone responses for expired short URLs.

Ready to quiz yourself?