AWS CDK Link Shortener Part 1: Project Setup & Basic Infrastructure
Set up a production-grade link shortener with AWS CDK, DynamoDB, and Lambda: architecture decisions, project layout, and the schema choices that hold up at scale.
Building a link shortener on AWS looks straightforward until redirect latency, URL validation, and per-link analytics become hard requirements. At millions of redirects per month, each architectural choice (storage engine, caching layer, CDN placement) affects both cost and tail latency.
The default that holds up is a single DynamoDB table, one small Lambda per route, and CloudFront in front of the redirect path. Get the project layout and the table schema right on day one, and the later parts of the series stay additive instead of turning into migrations. Redirect loops, malicious URLs, and abuse traffic are not late-stage concerns either: they decide what the create handler validates before it writes anything.
Architecture Overview
Sketch the architecture before writing CDK code. Storage and caching are the choices that are expensive to reverse later. Here is the layout the rest of the series builds on:
Each tier scales on its own, and a cached redirect never reaches Lambda or DynamoDB. 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 the CDK Project
Don’t stop at cdk init. A few minutes spent on project structure now saves a refactor once the stacks multiply. Separate stacks and reusable constructs also keep environment-specific configuration out of the handlers.
# 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 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 Schema Design
Most tutorials show a basic table with id and url. That layout runs out of room as soon as you need deduplication, per-link analytics, and custom slugs. A single table with two GSIs covers all three:
// 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 are the item shapes it stores:
// 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 Create Handler
The create handler validates the URL, deduplicates against GSI1, and retries when a generated short ID collides:
// src/handlers/create.ts
import type { APIGatewayProxyHandlerV2WithLambdaAuthorizer } 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!;
type AuthContext = { userId?: string };
export const handler: APIGatewayProxyHandlerV2WithLambdaAuthorizer<AuthContext> = async (event) => {
const startTime = Date.now();
const userId = event.requestContext.authorizer?.lambda?.userId;
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: `USER#${userId ?? 'ANONYMOUS'}`,
CreatedAt: Date.now(),
OriginalUrl: url,
ClickCount: 0,
ExpiresAt: Date.now() + (expiresInDays * 24 * 60 * 60 * 1000),
Tags: tags,
CreatedBy: 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;
}
}
}
// Every attempt collided: never report success for a row that was not written
if (attempts >= maxAttempts) {
return {
statusCode: 503,
body: JSON.stringify({ error: 'Could not allocate a short ID, retry' }),
};
}
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,
}),
};
}
};
ID Generator Design
The library matters less than the alphabet and the length. crypto.randomBytes over a reduced alphabet keeps codes short, unambiguous when read aloud, and hard to enumerate:
// src/utils/id-generator.ts
import { randomBytes } from 'crypto';
// Ambiguous characters (0, O, l, I) are dropped so codes survive being read aloud
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const ID_LENGTH = 7; // 58 characters, 58^7 is about 2.2 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;
}
// Validation rules for custom slugs
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 would shadow real routes
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 Setup
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 Configuration
// 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"
}
}
Common Pitfalls
-
Start with on-demand DynamoDB - Access patterns are unknown early. Once traffic becomes predictable, price a provisioned-capacity estimate against the on-demand bill before you switch.
-
Sample click logs - A log line per click puts your CloudWatch bill on the same growth curve as your traffic. Sample around 1% and use metrics for the rest.
-
Cache aggressively - One viral link can take 500,000 clicks in an hour. With a cache-friendly redirect response, CloudFront absorbs almost all of them.
-
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
Part 2 builds the redirect handler and its caching strategy, adds analytics that stay cheap at volume, and puts rate limiting in front of the create endpoint. The series then continues with Part 3 on custom domains, bulk operations, and the security layers; Part 4 on deployment, cost tuning, and monitoring; and Part 5 on multi-region scaling and long-term maintenance.
The complete code for this series is on GitHub.
The single-table, on-demand, cache-in-front setup holds while redirect traffic is read-heavy and short codes are opaque. Revisit it when analytics queries need scans across links, when steady traffic makes provisioned capacity the cheaper bill, or when a compliance requirement forces click data out of the same table.
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
Build a testing strategy for AWS Lambda, API Gateway, DynamoDB, and Step Functions with practical patterns for fast feedback and reliability.
Build the redirect engine, analytics collection, and API Gateway config: performance optimizations and debugging strategies for millions of daily redirects.
Before building an internal service layer, decide whether you need one: what it costs per call, the volume where VPC Lattice wins, and when direct invoke still beats it.
A private REST API structurally cannot carry gRPC, and every AWS surface that speaks gRPC excludes Lambda targets. What to keep from gRPC, and what to drop.
The private REST API, the resource policy that switches it on, per-route AWS_IAM grants, the two CDK stacks, and signing the call from a Node 22 Lambda.