2025-09-04
AWS CDK Link Shortener Part 1: Project Setup & Basic Infrastructure
Setting up a production-grade link shortener with AWS CDK, DynamoDB, and Lambda. Real architecture decisions, initial setup, and lessons learned from building URL shorteners at scale.
Series Navigation
This is Part 1 of a 5-part series on building a production-grade link shortener:
- Part 1: Project Setup & Basic Infrastructure (You are here)
- Part 2: Core Functionality & API Development
- Part 3: Advanced Features & Security
- Part 4: Production Deployment & Optimization
- Part 5: Scaling & Maintenance
Introduction: Building for Real-World Scale
A common scenario: a marketing team needs branded short links for campaigns and needs them fast. The easy answer is a SaaS solution, but when handling 5-10 million redirects per month and needing custom analytics, building your own makes sense.
Link shorteners seem simple until they hit production. Then come the fun edge cases: redirect loops, malicious URLs, analytics at scale, and the classic failure mode where someone accidentally creates a short link that points to another short link pointing back to the first one during a major campaign launch.
This post walks through building a production-grade link shortener with AWS CDK that won’t wake you up during your vacation.
The Architecture That Survived Black Friday
Before writing any code, spend a week sketching architectures. Here is an architecture that has proven durable under production load:
This architecture handles about 2,000 requests per second without breaking a sweat. The key decisions:
- CloudFront for caching - Why hit your Lambda for the same redirect 10,000 times?
- DynamoDB over RDS - Predictable performance at scale, no connection pooling headaches
- Separate Lambda functions - Easier to scale and debug when things go wrong
- DAX for hot paths - Because that one viral link will hammer your database
Setting Up Your CDK Project (The Right Way)
First lesson: don’t just run cdk init. Take five minutes to set up your project structure properly. You’ll thank yourself later when you’re not refactoring everything at 2x the scale.
# Create project with TypeScript from the start
mkdir link-shortener && cd link-shortener
npx cdk init app --language typescript
# Install dependencies we'll actually need (CDK v2)
npm install aws-cdk-lib@latest constructs@latest \
@aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb \
nanoid zod
# Dev dependencies for sanity
npm install -D @types/aws-lambda @types/node esbuild \
prettier eslint tsx \
@typescript-eslint/parser @typescript-eslint/eslint-plugin
Your project structure should look like this:
link-shortener/
├── bin/
│ └── link-shortener.ts # CDK app entry point
├── lib/
│ ├── stacks/
│ │ ├── api-stack.ts # API Gateway + Lambda
│ │ ├── database-stack.ts # DynamoDB tables
│ │ └── cdn-stack.ts # CloudFront distribution
│ └── constructs/
│ ├── link-table.ts # DynamoDB construct
│ └── lambda-function.ts # Reusable Lambda construct
├── src/
│ ├── handlers/
│ │ ├── create.ts # Create short link
│ │ ├── redirect.ts # Handle redirects
│ │ └── analytics.ts # Track clicks
│ └── utils/
│ ├── id-generator.ts # Short ID generation
│ └── url-validator.ts # URL validation
├── test/
└── cdk.json
DynamoDB Design: Lessons from High-Volume Production
Most tutorials show a basic table with id and url. That won’t survive production. After several database migrations, here is the schema that actually works:
// lib/constructs/link-table.ts
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import { RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
export class LinkTable extends Construct {
public readonly table: dynamodb.Table;
constructor(scope: Construct, id: string) {
super(scope, id);
this.table = new dynamodb.Table(this, 'LinksTable', {
partitionKey: {
name: 'PK',
type: dynamodb.AttributeType.STRING,
},
sortKey: {
name: 'SK',
type: dynamodb.AttributeType.STRING,
},
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, // Start here, switch to provisioned when you know your patterns
pointInTimeRecovery: true, // Because someone will delete something important
stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES, // For analytics and debugging
removalPolicy: RemovalPolicy.RETAIN, // Never accidentally delete production data
});
// GSI for looking up by original URL (deduplication)
this.table.addGlobalSecondaryIndex({
indexName: 'GSI1',
partitionKey: {
name: 'GSI1PK',
type: dynamodb.AttributeType.STRING,
},
sortKey: {
name: 'GSI1SK',
type: dynamodb.AttributeType.STRING,
},
});
// GSI for analytics queries
this.table.addGlobalSecondaryIndex({
indexName: 'GSI2',
partitionKey: {
name: 'GSI2PK',
type: dynamodb.AttributeType.STRING,
},
sortKey: {
name: 'CreatedAt',
type: dynamodb.AttributeType.NUMBER,
},
});
}
}
Why this schema? Here it is with real data:
// Example records in the table
const linkRecord = {
PK: 'LINK#abc123', // Short code
SK: 'METADATA', // Allows future expansion
GSI1PK: 'URL#https://example.com/very/long/url',
GSI1SK: 'LINK#abc123', // For deduplication
GSI2PK: 'USER#user123', // Who created it
CreatedAt: 1706544000000, // Timestamp for sorting
OriginalUrl: 'https://example.com/very/long/url',
ClickCount: 0,
ExpiresAt: 1738080000000, // TTL
Tags: ['campaign-2024', 'email'],
CustomSlug: 'summer-sale', // Optional custom slug
};
const clickRecord = {
PK: 'LINK#abc123',
SK: `CLICK#${Date.now()}#${uuid}`, // Unique click event
UserAgent: 'Mozilla/5.0...',
IPHash: 'hashed-ip', // Privacy-compliant
Referer: 'https://twitter.com',
Timestamp: 1706544000000,
};
This design lets you:
- Query all data for a link with one request
- Deduplicate URLs efficiently
- Track individual clicks for analytics
- Support custom slugs without conflicts
- Expire links automatically with TTL
The Lambda That Handles Everything
Here’s the create handler that’s processed millions of links:
// src/handlers/create.ts
import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
import { generateShortId } from '../utils/id-generator';
import { validateUrl } from '../utils/url-validator';
const client = new DynamoDBClient({});
const ddb = DynamoDBDocumentClient.from(client, {
marshallOptions: { removeUndefinedValues: true },
});
const TABLE_NAME = process.env.TABLE_NAME!;
const DOMAIN = process.env.SHORT_DOMAIN!;
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
const startTime = Date.now();
try {
const body = JSON.parse(event.body || '{}');
const { url, customSlug, expiresInDays = 365, tags = [] } = body;
// Validate URL (a frequent source of production issues)
const validation = await validateUrl(url);
if (!validation.isValid) {
return {
statusCode: 400,
body: JSON.stringify({
error: validation.error,
details: validation.details
}),
};
}
// Check for existing short link (deduplication)
const existing = await ddb.send(new QueryCommand({
TableName: TABLE_NAME,
IndexName: 'GSI1',
KeyConditionExpression: 'GSI1PK = :pk',
ExpressionAttributeValues: {
':pk': `URL#${url}`,
},
Limit: 1,
}));
if (existing.Items?.length) {
const existingLink = existing.Items[0];
console.log(`Deduplication hit: ${existingLink.PK}`);
return {
statusCode: 200,
body: JSON.stringify({
shortUrl: `${DOMAIN}/${existingLink.PK.replace('LINK#', '')}`,
isNew: false,
processingTime: Date.now() - startTime,
}),
};
}
// Generate short ID with collision detection
let shortId = customSlug || generateShortId();
let attempts = 0;
const maxAttempts = 5;
while (attempts < maxAttempts) {
try {
await ddb.send(new PutCommand({
TableName: TABLE_NAME,
Item: {
PK: `LINK#${shortId}`,
SK: 'METADATA',
GSI1PK: `URL#${url}`,
GSI1SK: `LINK#${shortId}`,
GSI2PK: event.requestContext?.authorizer?.userId || 'ANONYMOUS',
CreatedAt: Date.now(),
OriginalUrl: url,
ClickCount: 0,
ExpiresAt: Date.now() + (expiresInDays * 24 * 60 * 60 * 1000),
Tags: tags,
CreatedBy: event.requestContext?.authorizer?.userId,
SourceIP: event.requestContext?.http?.sourceIp,
},
ConditionExpression: 'attribute_not_exists(PK)',
}));
break; // Success!
} catch (error: any) {
if (error.name === 'ConditionalCheckFailedException') {
if (customSlug) {
return {
statusCode: 409,
body: JSON.stringify({
error: 'Custom slug already exists',
suggestion: generateShortId(),
}),
};
}
shortId = generateShortId(); // Try another ID
attempts++;
} else {
throw error;
}
}
}
return {
statusCode: 201,
body: JSON.stringify({
shortUrl: `${DOMAIN}/${shortId}`,
shortId,
expiresAt: new Date(Date.now() + (expiresInDays * 24 * 60 * 60 * 1000)).toISOString(),
processingTime: Date.now() - startTime,
}),
};
} catch (error) {
console.error('Error creating short link:', error);
return {
statusCode: 500,
body: JSON.stringify({
error: 'Internal server error',
requestId: event.requestContext?.requestId,
}),
};
}
};
The ID Generator That Won’t Fail You
After trying nanoid, shortid, and a bunch of other libraries, here’s what actually works in production:
// src/utils/id-generator.ts
import { randomBytes } from 'crypto';
// Removed ambiguous characters (0, O, l, I) after support got confused
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const ID_LENGTH = 7; // Gives us 3.5 trillion combinations
export function generateShortId(length: number = ID_LENGTH): string {
const bytes = randomBytes(length);
let id = '';
for (let i = 0; i < length; i++) {
id += ALPHABET[bytes[i] % ALPHABET.length];
}
return id;
}
// For custom slugs - validation rules from production experience
export function validateCustomSlug(slug: string): { valid: boolean; reason?: string } {
if (slug.length < 3) {
return { valid: false, reason: 'Too short (min 3 characters)' };
}
if (slug.length > 50) {
return { valid: false, reason: 'Too long (max 50 characters)' };
}
// Only alphanumeric and hyphens, must start/end with alphanumeric
if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$/.test(slug)) {
return { valid: false, reason: 'Invalid characters or format' };
}
// Reserved words that caused issues
const reserved = ['api', 'admin', 'dashboard', 'login', 'logout', 'static', 'health'];
if (reserved.includes(slug.toLowerCase())) {
return { valid: false, reason: 'Reserved keyword' };
}
return { valid: true };
}
Local Development That Doesn’t Suck
Set up local development properly from day one. Deploying to AWS on every console.log change gets expensive and slow fast:
// local-dev.ts
import express from 'express';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { handler as createHandler } from './src/handlers/create';
import { handler as redirectHandler } from './src/handlers/redirect';
const app = express();
app.use(express.json());
// Mock AWS services locally
process.env.TABLE_NAME = 'local-links';
process.env.SHORT_DOMAIN = 'http://localhost:3000';
process.env.AWS_REGION = 'us-east-1';
// Wrap Lambda handlers for Express
const lambdaToExpress = (handler: any) => async (req: any, res: any) => {
const event = {
body: JSON.stringify(req.body),
pathParameters: req.params,
queryStringParameters: req.query,
requestContext: {
http: {
sourceIp: req.ip,
},
requestId: Math.random().toString(36),
},
};
const result = await handler(event);
res.status(result.statusCode).json(JSON.parse(result.body));
};
app.post('/create', lambdaToExpress(createHandler));
app.get('/:id', lambdaToExpress(redirectHandler));
app.listen(3000, () => {
console.log('Local dev server running on http://localhost:3000');
console.log('DynamoDB Local required on port 8000');
});
Run DynamoDB locally:
docker run -p 8000:8000 amazon/dynamodb-local \
-jar DynamoDBLocal.jar -sharedDb -inMemory
Deploy Script That Won’t Ruin Your Day
// package.json scripts
{
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"test": "jest",
"cdk": "cdk",
"local": "tsx watch local-dev.ts",
"deploy:dev": "cdk deploy --all --context environment=dev",
"deploy:prod": "cdk deploy --all --context environment=prod --require-approval never",
"destroy:dev": "cdk destroy --all --context environment=dev",
"synth": "cdk synth --quiet",
"diff": "cdk diff --all"
}
}
Performance Numbers from Production
Production numbers after 6 months:
- Create endpoint: p50: 45ms, p99: 120ms
- Redirect endpoint (cold start): p50: 15ms, p99: 80ms
- Redirect endpoint (warm): p50: 8ms, p99: 25ms
- DynamoDB costs: ~$6.25/month for 5-10M redirects (25M read units @ $0.25 per million)
- Lambda costs: $12/month (most redirects served from CloudFront)
- CloudFront costs: $85/month (worth every penny for caching)
Lessons Learned the Hard Way
-
Start with on-demand DynamoDB - Access patterns are unknown early. Switching to provisioned after 3 months typically saves 60%.
-
Log everything, retain nothing - Logging every click initially produces CloudWatch bills that are educational. Sampling 1% and using metrics for the rest is a better approach.
-
Cache aggressively - A viral link receiving 500,000 clicks in an hour is where CloudFront prevents a massive Lambda bill.
-
Validate URLs properly - Someone will try to create a short link to
javascript:alert('xss'). Someone will create redirect loops. Someone will use the service for phishing. Plan for it. -
Rate limiting from day one - Without it, a script can create 100,000 links in 10 minutes during a product launch.
Next Steps in This Series
Ready to implement the core functionality? In Part 2: Core Functionality & API Development, we’ll:
- Build the redirect handler with smart caching strategies
- Implement analytics that won’t break the bank
- Add rate limiting and abuse prevention
- Set up monitoring that actually tells you when things are broken
Quick Preview of the Complete Series:
- Part 3: Advanced features including custom domains, QR codes, and bulk operations
- Part 4: Production deployment with blue-green deployments and zero-downtime migrations
- Part 5: Scaling strategies and long-term maintenance patterns
The complete code for this series is on GitHub, including migration scripts and performance tests.
Remember: link shorteners are simple until they’re not. Build for scale from the start, but deploy what works today. And always, always validate those URLs.
References
- Tutorial: Create a CRUD HTTP API with Lambda and DynamoDB - Official API Gateway tutorial for building a serverless HTTP API backed by Lambda and DynamoDB - the core pattern behind a link shortener.
- Deploying Lambda functions with AWS CDK - Official CDK tutorial for defining and deploying Lambda functions in TypeScript.
- Tutorial: Create a serverless Hello World application - AWS CDK v2 - End-to-end CDK example combining API Gateway REST API and a Lambda function.
- Best practices for designing and using partition keys effectively in DynamoDB - DynamoDB guidance on partition key design for uniform throughput - essential for short-code table layout.
- Best practices for developing and deploying cloud infrastructure with the AWS CDK - CDK best practices on construct reuse, environment configuration, and stateful resource management.
- AWS CDK API Reference (v2) - Complete API reference for all CDK constructs including aws-lambda, aws-apigateway, and aws-dynamodb.
AWS CDK Link Shortener: From Zero to Production
A comprehensive 5-part series on building a production-grade link shortener service with AWS CDK, Node.js Lambda, and DynamoDB. Real war stories, performance optimization, and cost management included.
All posts in this series
Related posts
Learn how to build a comprehensive testing strategy for AWS Lambda, API Gateway, DynamoDB, and Step Functions with practical patterns for fast feedback and production reliability.
Building the redirect engine, analytics collection, and API Gateway configuration. Real performance optimizations and debugging strategies from handling millions of daily redirects.
A practical comparison of TypeScript AI SDKs for building AI agents - Vercel AI SDK, OpenAI Agents SDK, and AWS Bedrock integration. Includes code examples, decision frameworks, and production patterns.
A comprehensive technical guide to Amazon Cognito's advanced features including custom authentication flows, federation patterns, multi-tenancy architectures, migration strategies, and production-grade security implementation.
A comprehensive guide to building scalable real-time APIs with AWS AppSync, covering JavaScript resolvers, subscription filtering, caching strategies, and infrastructure as code patterns.