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.
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-shortenerReplace 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.ymlStep 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 UserPoolClientStep 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_withCLICK# - Time-range click queries: Query PK =
URL#abc123, SK betweenCLICK#2026-03-01andCLICK#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 secondFor 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.jsonCreate 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 StageThen 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: Activein 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.
