AWS for Backend Engineers
March 31, 2026|12 min read
Lesson 15 / 15

15. Real Project — Build a Serverless URL Shortener

TL;DR

Build a production-ready URL shortener using Lambda, API Gateway HTTP API, and DynamoDB single-table design. Add Cognito for auth, CloudWatch for monitoring, and GitHub Actions for CI/CD. Total cost for 100K requests/month: under $1. This is your capstone project — everything from the course comes together here.

Time to build something real. This lesson puts together everything you have learned in this course — Lambda, API Gateway, DynamoDB, IAM, monitoring, and CI/CD — into a single working project.

We are building a URL shortener API. It is simple enough to build in one lesson but complex enough to demonstrate production patterns. By the end, you will have a deployed, monitored, and CI/CD-enabled serverless API.

Serverless URL shortener architecture on AWS

Project Overview

The URL shortener supports these operations:

  • POST /urls — Create a short URL (authenticated)
  • GET /{shortCode} — Redirect to the original URL (public)
  • GET /urls/{shortCode}/stats — Get click statistics (authenticated)

We will use:

  • AWS SAM for infrastructure as code and local testing
  • Lambda (Node.js 20) for compute
  • API Gateway HTTP API for routing
  • DynamoDB for storage (single-table design)
  • Cognito for authentication
  • CloudWatch for monitoring and alarms
  • GitHub Actions for CI/CD

Step 1: Initialize the SAM Project

sam init --runtime nodejs20.x --name url-shortener --app-template hello-world
cd url-shortener

Replace the generated files with our project structure:

url-shortener/
├── template.yaml
├── src/
│   ├── create-url/
│   │   └── index.mjs
│   ├── redirect/
│   │   └── index.mjs
│   ├── get-stats/
│   │   └── index.mjs
│   └── shared/
│       ├── dynamo.mjs
│       └── response.mjs
├── tests/
│   └── create-url.test.mjs
└── .github/
    └── workflows/
        └── deploy.yml

Step 2: SAM Template — template.yaml

This is the core of your infrastructure. Every AWS resource is defined here:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Serverless URL Shortener API

Globals:
  Function:
    Timeout: 10
    Runtime: nodejs20.x
    MemorySize: 256
    Environment:
      Variables:
        TABLE_NAME: !Ref UrlTable
        REGION: !Ref AWS::Region
    Tracing: Active
    Architectures:
      - arm64

Parameters:
  Stage:
    Type: String
    Default: prod
    AllowedValues: [dev, staging, prod]

Resources:
  # --- API Gateway ---
  HttpApi:
    Type: AWS::Serverless::HttpApi
    Properties:
      StageName: !Ref Stage
      CorsConfiguration:
        AllowOrigins:
          - "https://myapp.com"
        AllowMethods:
          - GET
          - POST
        AllowHeaders:
          - Authorization
          - Content-Type
      Auth:
        DefaultAuthorizer: CognitoAuthorizer
        Authorizers:
          CognitoAuthorizer:
            AuthorizationScopes:
              - email
            IdentitySource: $request.header.Authorization
            JwtConfiguration:
              issuer: !Sub "https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPool}"
              audience:
                - !Ref UserPoolClient
      ThrottlingBurstLimit: 100
      ThrottlingRateLimit: 50

  # --- Lambda Functions ---
  CreateUrlFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/create-url/
      Handler: index.handler
      Description: Create a new short URL
      Events:
        CreateUrl:
          Type: HttpApi
          Properties:
            ApiId: !Ref HttpApi
            Path: /urls
            Method: POST
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref UrlTable

  RedirectFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/redirect/
      Handler: index.handler
      Description: Redirect short URL to original
      Events:
        Redirect:
          Type: HttpApi
          Properties:
            ApiId: !Ref HttpApi
            Path: /{shortCode}
            Method: GET
            Auth:
              Authorizer: NONE
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref UrlTable

  GetStatsFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/get-stats/
      Handler: index.handler
      Description: Get URL click statistics
      Events:
        GetStats:
          Type: HttpApi
          Properties:
            ApiId: !Ref HttpApi
            Path: /urls/{shortCode}/stats
            Method: GET
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref UrlTable

  # --- DynamoDB Table ---
  UrlTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "url-shortener-${Stage}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: PK
          AttributeType: S
        - AttributeName: SK
          AttributeType: S
        - AttributeName: GSI1PK
          AttributeType: S
        - AttributeName: GSI1SK
          AttributeType: S
      KeySchema:
        - AttributeName: PK
          KeyType: HASH
        - AttributeName: SK
          KeyType: RANGE
      GlobalSecondaryIndexes:
        - IndexName: GSI1
          KeySchema:
            - AttributeName: GSI1PK
              KeyType: HASH
            - AttributeName: GSI1SK
              KeyType: RANGE
          Projection:
            ProjectionType: ALL
      TimeToLiveSpecification:
        AttributeName: ttl
        Enabled: true
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true

  # --- Cognito ---
  UserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: !Sub "url-shortener-${Stage}"
      AutoVerifiedAttributes:
        - email
      UsernameAttributes:
        - email
      Policies:
        PasswordPolicy:
          MinimumLength: 8
          RequireLowercase: true
          RequireNumbers: true
          RequireSymbols: false
          RequireUppercase: true

  UserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      ClientName: url-shortener-client
      UserPoolId: !Ref UserPool
      GenerateSecret: false
      ExplicitAuthFlows:
        - ALLOW_USER_SRP_AUTH
        - ALLOW_REFRESH_TOKEN_AUTH
      AccessTokenValidity: 1
      IdTokenValidity: 1
      RefreshTokenValidity: 30
      TokenValidityUnits:
        AccessToken: hours
        IdToken: hours
        RefreshToken: days

  # --- CloudWatch Alarms ---
  CreateUrlErrorAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: !Sub "url-shortener-${Stage}-create-errors"
      AlarmDescription: "Alarm when CreateUrl function errors exceed threshold"
      Namespace: AWS/Lambda
      MetricName: Errors
      Dimensions:
        - Name: FunctionName
          Value: !Ref CreateUrlFunction
      Statistic: Sum
      Period: 300
      EvaluationPeriods: 2
      Threshold: 5
      ComparisonOperator: GreaterThanThreshold
      TreatMissingData: notBreaching

  RedirectLatencyAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: !Sub "url-shortener-${Stage}-redirect-latency"
      AlarmDescription: "Alarm when redirect p99 latency exceeds 500ms"
      Namespace: AWS/Lambda
      MetricName: Duration
      Dimensions:
        - Name: FunctionName
          Value: !Ref RedirectFunction
      ExtendedStatistic: p99
      Period: 300
      EvaluationPeriods: 3
      Threshold: 500
      ComparisonOperator: GreaterThanThreshold
      TreatMissingData: notBreaching

  # --- CloudWatch Dashboard ---
  Dashboard:
    Type: AWS::CloudWatch::Dashboard
    Properties:
      DashboardName: !Sub "url-shortener-${Stage}"
      DashboardBody: !Sub |
        {
          "widgets": [
            {
              "type": "metric",
              "properties": {
                "title": "API Requests",
                "metrics": [
                  ["AWS/ApiGateway", "Count", "ApiId", "${HttpApi}", {"stat": "Sum"}]
                ],
                "period": 300
              }
            },
            {
              "type": "metric",
              "properties": {
                "title": "Lambda Errors",
                "metrics": [
                  ["AWS/Lambda", "Errors", "FunctionName", "${CreateUrlFunction}", {"stat": "Sum"}],
                  ["AWS/Lambda", "Errors", "FunctionName", "${RedirectFunction}", {"stat": "Sum"}],
                  ["AWS/Lambda", "Errors", "FunctionName", "${GetStatsFunction}", {"stat": "Sum"}]
                ],
                "period": 300
              }
            },
            {
              "type": "metric",
              "properties": {
                "title": "Redirect Latency (p50, p99)",
                "metrics": [
                  ["AWS/Lambda", "Duration", "FunctionName", "${RedirectFunction}", {"stat": "p50"}],
                  ["AWS/Lambda", "Duration", "FunctionName", "${RedirectFunction}", {"stat": "p99"}]
                ],
                "period": 300
              }
            },
            {
              "type": "metric",
              "properties": {
                "title": "DynamoDB Consumed Capacity",
                "metrics": [
                  ["AWS/DynamoDB", "ConsumedReadCapacityUnits", "TableName", "${UrlTable}", {"stat": "Sum"}],
                  ["AWS/DynamoDB", "ConsumedWriteCapacityUnits", "TableName", "${UrlTable}", {"stat": "Sum"}]
                ],
                "period": 300
              }
            }
          ]
        }

Outputs:
  ApiUrl:
    Description: API Gateway URL
    Value: !Sub "https://${HttpApi}.execute-api.${AWS::Region}.amazonaws.com/${Stage}"
  UserPoolId:
    Value: !Ref UserPool
  UserPoolClientId:
    Value: !Ref UserPoolClient

Step 3: DynamoDB Single-Table Design

We use one table for all entities. Here is the access pattern design:

Entity PK SK GSI1PK GSI1SK Attributes
URL URL#<shortCode> META USER#<userId> URL#<createdAt> originalUrl, shortCode, userId, createdAt, clickCount, ttl
Click URL#<shortCode> CLICK#<timestamp> ip, userAgent, referer, country, timestamp

This design gives us:

  • Get URL by short code: Query PK = URL#abc123, SK = META
  • List URLs by user: Query GSI1 where GSI1PK = USER#userId
  • Get clicks for a URL: Query PK = URL#abc123, SK begins_with CLICK#
  • Time-range click queries: Query PK = URL#abc123, SK between CLICK#2026-03-01 and CLICK#2026-03-31

Step 4: Shared Utility Modules

src/shared/dynamo.mjs

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
  DynamoDBDocumentClient,
  PutCommand,
  GetCommand,
  QueryCommand,
  UpdateCommand,
} from '@aws-sdk/lib-dynamodb';

const client = new DynamoDBClient({ region: process.env.REGION });
const docClient = DynamoDBDocumentClient.from(client, {
  marshallOptions: { removeUndefinedValues: true },
});

const TABLE_NAME = process.env.TABLE_NAME;

export async function putItem(item) {
  await docClient.send(new PutCommand({
    TableName: TABLE_NAME,
    Item: item,
  }));
}

export async function getItem(pk, sk) {
  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { PK: pk, SK: sk },
  }));
  return result.Item;
}

export async function queryItems(pk, skPrefix, limit = 100) {
  const result = await docClient.send(new QueryCommand({
    TableName: TABLE_NAME,
    KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
    ExpressionAttributeValues: {
      ':pk': pk,
      ':sk': skPrefix,
    },
    Limit: limit,
    ScanIndexForward: false,
  }));
  return result.Items;
}

export async function incrementClickCount(shortCode) {
  await docClient.send(new UpdateCommand({
    TableName: TABLE_NAME,
    Key: { PK: `URL#${shortCode}`, SK: 'META' },
    UpdateExpression: 'ADD clickCount :one',
    ExpressionAttributeValues: { ':one': 1 },
  }));
}

export async function queryByGSI1(gsi1pk, limit = 50) {
  const result = await docClient.send(new QueryCommand({
    TableName: TABLE_NAME,
    IndexName: 'GSI1',
    KeyConditionExpression: 'GSI1PK = :pk',
    ExpressionAttributeValues: { ':pk': gsi1pk },
    Limit: limit,
    ScanIndexForward: false,
  }));
  return result.Items;
}

src/shared/response.mjs

export function success(body, statusCode = 200) {
  return {
    statusCode,
    headers: {
      'Content-Type': 'application/json',
      'Access-Control-Allow-Origin': 'https://myapp.com',
    },
    body: JSON.stringify(body),
  };
}

export function redirect(url) {
  return {
    statusCode: 301,
    headers: {
      Location: url,
      'Cache-Control': 'no-cache',
    },
    body: '',
  };
}

export function error(message, statusCode = 500) {
  return {
    statusCode,
    headers: {
      'Content-Type': 'application/json',
      'Access-Control-Allow-Origin': 'https://myapp.com',
    },
    body: JSON.stringify({ error: message }),
  };
}

Step 5: Lambda Functions

Create Short URL — src/create-url/index.mjs

import crypto from 'node:crypto';
import { putItem, getItem } from '../shared/dynamo.mjs';
import { success, error } from '../shared/response.mjs';

function generateShortCode(length = 7) {
  const chars = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
  const bytes = crypto.randomBytes(length);
  let result = '';
  for (let i = 0; i < length; i++) {
    result += chars[bytes[i] % chars.length];
  }
  return result;
}

function isValidUrl(str) {
  try {
    const url = new URL(str);
    return url.protocol === 'http:' || url.protocol === 'https:';
  } catch {
    return false;
  }
}

export async function handler(event) {
  try {
    const body = JSON.parse(event.body || '{}');
    const { url, expiresInDays } = body;

    // Extract user ID from Cognito JWT
    const userId = event.requestContext?.authorizer?.jwt?.claims?.sub;
    if (!userId) {
      return error('Unauthorized', 401);
    }

    // Validate URL
    if (!url || !isValidUrl(url)) {
      return error('Invalid URL. Must be a valid http or https URL.', 400);
    }

    // Generate unique short code with collision check
    let shortCode;
    let attempts = 0;
    do {
      shortCode = generateShortCode();
      const existing = await getItem(`URL#${shortCode}`, 'META');
      if (!existing) break;
      attempts++;
    } while (attempts < 5);

    if (attempts >= 5) {
      return error('Failed to generate unique short code. Please retry.', 500);
    }

    const now = new Date().toISOString();
    const item = {
      PK: `URL#${shortCode}`,
      SK: 'META',
      GSI1PK: `USER#${userId}`,
      GSI1SK: `URL#${now}`,
      originalUrl: url,
      shortCode,
      userId,
      createdAt: now,
      clickCount: 0,
    };

    // Add TTL if expiration requested
    if (expiresInDays && expiresInDays > 0) {
      item.ttl = Math.floor(Date.now() / 1000) + (expiresInDays * 86400);
    }

    await putItem(item);

    return success({
      shortCode,
      shortUrl: `https://short.myapp.com/${shortCode}`,
      originalUrl: url,
      createdAt: now,
      expiresAt: item.ttl ? new Date(item.ttl * 1000).toISOString() : null,
    }, 201);
  } catch (err) {
    console.error('CreateUrl error:', err);
    return error('Internal server error', 500);
  }
}

Redirect — src/redirect/index.mjs

import { getItem, incrementClickCount, putItem } from '../shared/dynamo.mjs';
import { redirect, error } from '../shared/response.mjs';

export async function handler(event) {
  try {
    const { shortCode } = event.pathParameters;

    // Look up the URL
    const urlItem = await getItem(`URL#${shortCode}`, 'META');
    if (!urlItem) {
      return error('Short URL not found', 404);
    }

    // Record click asynchronously (fire and forget — do not block redirect)
    const clickPromise = recordClick(shortCode, event);
    const incrementPromise = incrementClickCount(shortCode);

    // Start redirect immediately, do not await click recording
    // In Lambda, the runtime will wait for the event loop to drain
    await Promise.allSettled([clickPromise, incrementPromise]);

    return redirect(urlItem.originalUrl);
  } catch (err) {
    console.error('Redirect error:', err);
    return error('Internal server error', 500);
  }
}

async function recordClick(shortCode, event) {
  const now = new Date().toISOString();
  const headers = event.headers || {};

  await putItem({
    PK: `URL#${shortCode}`,
    SK: `CLICK#${now}#${Math.random().toString(36).slice(2, 8)}`,
    timestamp: now,
    ip: headers['x-forwarded-for']?.split(',')[0]?.trim() || 'unknown',
    userAgent: headers['user-agent'] || 'unknown',
    referer: headers['referer'] || 'direct',
    country: headers['cloudfront-viewer-country'] || 'unknown',
  });
}

Get Stats — src/get-stats/index.mjs

import { getItem, queryItems } from '../shared/dynamo.mjs';
import { success, error } from '../shared/response.mjs';

export async function handler(event) {
  try {
    const { shortCode } = event.pathParameters;
    const userId = event.requestContext?.authorizer?.jwt?.claims?.sub;

    // Get URL metadata
    const urlItem = await getItem(`URL#${shortCode}`, 'META');
    if (!urlItem) {
      return error('Short URL not found', 404);
    }

    // Only the owner can see stats
    if (urlItem.userId !== userId) {
      return error('Forbidden', 403);
    }

    // Get recent clicks (last 100)
    const clicks = await queryItems(`URL#${shortCode}`, 'CLICK#', 100);

    // Aggregate stats
    const countryBreakdown = {};
    const dailyClicks = {};

    for (const click of clicks) {
      // Country breakdown
      const country = click.country || 'unknown';
      countryBreakdown[country] = (countryBreakdown[country] || 0) + 1;

      // Daily clicks
      const day = click.timestamp?.slice(0, 10) || 'unknown';
      dailyClicks[day] = (dailyClicks[day] || 0) + 1;
    }

    return success({
      shortCode,
      originalUrl: urlItem.originalUrl,
      createdAt: urlItem.createdAt,
      totalClicks: urlItem.clickCount || 0,
      recentClicks: clicks.length,
      countryBreakdown,
      dailyClicks,
      lastClick: clicks.length > 0 ? clicks[0].timestamp : null,
    });
  } catch (err) {
    console.error('GetStats error:', err);
    return error('Internal server error', 500);
  }
}

Step 6: Authentication with Cognito

The SAM template already defines a Cognito User Pool and wires it to the API Gateway authorizer. Here is how users interact with it:

# Sign up a new user
aws cognito-idp sign-up \
  --client-id YOUR_CLIENT_ID \
  --username [email protected] \
  --password MyP@ssw0rd1

# Confirm the user (after receiving email code)
aws cognito-idp confirm-sign-up \
  --client-id YOUR_CLIENT_ID \
  --username [email protected] \
  --confirmation-code 123456

# Sign in and get tokens
aws cognito-idp initiate-auth \
  --client-id YOUR_CLIENT_ID \
  --auth-flow USER_SRP_AUTH \
  --auth-parameters USERNAME=[email protected],SRP_A=...

# Use the ID token for API calls
curl -X POST https://api.myapp.com/prod/urls \
  -H "Authorization: Bearer eyJraWQiOi..." \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/very-long-article-url"}'

In production, your frontend handles the SRP auth flow using the Amplify SDK or amazon-cognito-identity-js library. The JWT token is passed in the Authorization header on every API call.

Step 7: Rate Limiting

API Gateway HTTP API provides built-in throttling at two levels:

Account-level throttle — 10,000 requests/second by default. Request an increase through AWS Support.

Route-level throttle — Set in the SAM template:

HttpApi:
  Type: AWS::Serverless::HttpApi
  Properties:
    ThrottlingBurstLimit: 100   # Max concurrent requests
    ThrottlingRateLimit: 50     # Requests per second

For per-user rate limiting, implement a token bucket in DynamoDB:

import { DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb';

async function checkRateLimit(userId, maxRequests = 100, windowSeconds = 3600) {
  const windowKey = Math.floor(Date.now() / 1000 / windowSeconds);

  try {
    const result = await docClient.send(new UpdateCommand({
      TableName: process.env.TABLE_NAME,
      Key: {
        PK: `RATELIMIT#${userId}`,
        SK: `WINDOW#${windowKey}`,
      },
      UpdateExpression: 'ADD requestCount :one SET #ttl = :ttl',
      ExpressionAttributeNames: { '#ttl': 'ttl' },
      ExpressionAttributeValues: {
        ':one': 1,
        ':ttl': Math.floor(Date.now() / 1000) + windowSeconds + 60,
        ':max': maxRequests,
      },
      ConditionExpression: 'attribute_not_exists(requestCount) OR requestCount < :max',
      ReturnValues: 'ALL_NEW',
    }));

    return { allowed: true, remaining: maxRequests - result.Attributes.requestCount };
  } catch (err) {
    if (err.name === 'ConditionalCheckFailedException') {
      return { allowed: false, remaining: 0 };
    }
    throw err;
  }
}

Step 8: Local Testing

SAM provides local testing capabilities:

# Start local API
sam local start-api --port 3000

# Invoke a single function
sam local invoke CreateUrlFunction \
  --event events/create-url.json

# Run with environment variables
sam local invoke CreateUrlFunction \
  --env-vars env.json \
  --event events/create-url.json

Create test events in the events/ directory:

{
  "httpMethod": "POST",
  "path": "/urls",
  "headers": {
    "Content-Type": "application/json"
  },
  "requestContext": {
    "authorizer": {
      "jwt": {
        "claims": {
          "sub": "user-123"
        }
      }
    }
  },
  "body": "{\"url\": \"https://example.com/long-url\"}"
}

Step 9: CI/CD with GitHub Actions

.github/workflows/deploy.yml

name: Deploy URL Shortener

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - uses: aws-actions/setup-sam@v2

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/GitHubActionsDeployRole
          aws-region: us-east-1

      - name: SAM Build
        run: sam build --use-container

      - name: SAM Deploy
        run: |
          sam deploy \
            --stack-name url-shortener-prod \
            --resolve-s3 \
            --capabilities CAPABILITY_IAM \
            --parameter-overrides Stage=prod \
            --no-fail-on-empty-changeset \
            --no-confirm-changeset

      - name: Smoke Test
        run: |
          API_URL=$(aws cloudformation describe-stacks \
            --stack-name url-shortener-prod \
            --query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' \
            --output text)
          echo "API URL: $API_URL"
          # Test public redirect endpoint returns 404 for non-existent code
          STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$API_URL/nonexistent")
          if [ "$STATUS" != "404" ]; then
            echo "Smoke test failed: expected 404, got $STATUS"
            exit 1
          fi
          echo "Smoke test passed"

Step 10: Deploy and Test

# Build the SAM application
sam build --use-container

# Deploy (first time — will prompt for configuration)
sam deploy --guided

# Subsequent deploys
sam deploy --no-confirm-changeset

# Test the API
API_URL="https://abc123.execute-api.us-east-1.amazonaws.com/prod"

# Create a short URL (requires auth token)
curl -X POST "$API_URL/urls" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/very-long-article", "expiresInDays": 30}'

# Response:
# {
#   "shortCode": "xK7mR2p",
#   "shortUrl": "https://short.myapp.com/xK7mR2p",
#   "originalUrl": "https://example.com/very-long-article",
#   "createdAt": "2026-03-31T10:30:00.000Z",
#   "expiresAt": "2026-04-30T10:30:00.000Z"
# }

# Test redirect
curl -v "$API_URL/xK7mR2p"
# < HTTP/2 301
# < location: https://example.com/very-long-article

# Get stats
curl "$API_URL/urls/xK7mR2p/stats" \
  -H "Authorization: Bearer $TOKEN"

Custom Domain Setup

To use a clean URL like short.myapp.com instead of the API Gateway URL:

# Add to template.yaml Resources section
CustomDomain:
  Type: AWS::ApiGatewayV2::DomainName
  Properties:
    DomainName: short.myapp.com
    DomainNameConfigurations:
      - EndpointType: REGIONAL
        CertificateArn: !Ref Certificate

Certificate:
  Type: AWS::CertificateManager::Certificate
  Properties:
    DomainName: short.myapp.com
    ValidationMethod: DNS

ApiMapping:
  Type: AWS::ApiGatewayV2::ApiMapping
  Properties:
    DomainName: short.myapp.com
    ApiId: !Ref HttpApi
    Stage: !Ref Stage

Then add a Route 53 ALIAS record pointing short.myapp.com to the API Gateway domain.

Cost Analysis

Here is what this service costs at various scales:

Scale Lambda DynamoDB API Gateway Total/Month
10K req/month $0.00 (free tier) $0.00 (free tier) $0.01 ~$0.01
100K req/month $0.02 $0.13 $0.10 ~$0.25
1M req/month $0.20 $1.25 $1.00 ~$2.45
10M req/month $2.00 $12.50 $10.00 ~$24.50

Breakdown assumptions:

  • Lambda: 256MB, 50ms average duration = $0.0000002083 per request
  • DynamoDB: PAY_PER_REQUEST at $1.25 per million writes, $0.25 per million reads (each redirect = 1 read + 1 write for click tracking)
  • API Gateway HTTP API: $1.00 per million requests

Serverless pricing is incredibly cost-effective for bursty workloads. You pay nothing when no one is using the service.

Production Hardening Checklist

Before going live, verify these items:

  • WAF — Attach AWS WAF to API Gateway to block common attacks (SQL injection patterns, known bad IPs)
  • Custom domain — Set up with ACM certificate for HTTPS
  • Alarms — CloudWatch alarms for error rates and latency spikes (already in template)
  • Dead letter queue — Add SQS DLQ to Lambda functions for failed invocations
  • Structured logging — Use JSON logs with correlation IDs
  • Input validation — Sanitize all user input (URL validation is already implemented)
  • Abuse prevention — Rate limit URL creation per user, block known spam domains
  • Backup — DynamoDB Point-in-Time Recovery is enabled in the template
  • Tagging — Tag all resources with environment, team, and cost-center tags
  • X-Ray tracing — Already enabled via Tracing: Active in the template

What You Should Remember

This project brings together Lambda, API Gateway, DynamoDB, Cognito, CloudWatch, and SAM into a cohesive, production-ready service. The key patterns — single-table DynamoDB design, JWT authentication, infrastructure as code, automated deployments, and cost-effective serverless architecture — are the same patterns used in real production systems at scale. The total cost for a low-traffic service is under a dollar per month. That is the power of serverless done right.