Skip to content
Ayhan Sipahi Ayhan Sipahi

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:

Monitoring

Storage Layer

API Layer

User Flow

HTTP Request

Route

POST /create

GET /:id

GET /analytics

Write

Read

Query

Cache Hit

Cache Miss

Logs/Metrics

Logs/Metrics

Logs/Metrics

Threshold

User

CloudFront CDN

API Gateway

Create Lambda (Node.js 20)

Redirect Lambda (Node.js 20)

Analytics Lambda (Node.js 20)

DynamoDB On-Demand

DAX Cache Layer

CloudWatch

Alarms (SNS)

Each tier scales on its own, and a cached redirect never reaches Lambda or DynamoDB. The key decisions:

  1. CloudFront for caching - Why hit your Lambda for the same redirect 10,000 times?
  2. DynamoDB over RDS - Predictable performance at scale, no connection pooling headaches
  3. Separate Lambda functions - Easier to scale and debug when things go wrong
  4. 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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

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.

Progress 1/5 posts completed

Related posts