
**Quick answer:** A modern URL shortener on AWS uses CloudFront for edge caching, API Gateway with Lambda for compute, DynamoDB for the link store, Kinesis Firehose for click events, S3 Files for shared file workflows, and Route 53 for DNS. With a GitHub Actions and AWS CodePipeline setup, every push moves through dev, staging, and production with safety checks. This serverless setup handles 10,000 redirects per second, costs around 350 dollars per month at moderate traffic, and runs itself once it is live.

This guide walks you through every step. By the end, you will have a working service in production, a CI/CD pipeline that ships changes safely, and a runbook for when things go wrong. The words are kept simple so anyone can follow along, but the depth is meant for people who ship real systems.

## What you will learn

- How to design the architecture from scratch
- How to pick the right AWS services for each part
- How to write the Lambda code for create and redirect
- How to use the new S3 Files feature for bulk imports and shared analytics
- How to build the front-end dashboard and host it on AWS Amplify
- How to set up a full CI/CD pipeline with GitHub Actions and AWS CodePipeline
- How to use multiple AWS accounts safely
- How to do canary deploys and one-click rollbacks
- How much it costs and how to keep costs low
- How to avoid the mistakes that catch most teams

## Key takeaways

- Use 301 redirects so browsers and CDNs cache the response.
- Pick DynamoDB for the link table because the access pattern is a key lookup.
- Put CloudFront in front to cut origin traffic by 70 percent or more.
- Send click events to Kinesis Firehose, never write them back to the link table.
- Use S3 Files for bulk CSV imports, shared analytics, and editable warning pages.
- Run dev, staging, and production in three separate AWS accounts.
- Ship changes through a CI/CD pipeline with automated tests and canary deploys.
- Plan for abuse from day one with WAF, rate limits, and Safe Browsing checks.
- Treat short codes as forever, because users print them on real paper.

A URL shortener is a service that turns a long web address into a short one. When someone visits the short address, the service sends them to the long one.

Our service does seven things:

1. Takes a long URL and returns a short one such as `sho.rt/aB3xY9`.
2. When someone visits the short URL, sends them to the original long URL fast.
3. Allows custom aliases so a marketing team can use `sho.rt/black-friday` instead of a random code.
4. Allows links to expire after a set date.
5. Tracks basic stats like total clicks, country, browser, and referrer.
6. Lets users upload a CSV with up to 100,000 long URLs at once.
7. Provides a small dashboard for managing links.

That sounds small. The hard part is doing it at scale, keeping it cheap, and making it boring to operate.

## Why URL shorteners are harder than they look

People underestimate this problem. Here is what makes it interesting.

**The read to write ratio is extreme.** For every link created, you might serve 100 to 10,000 redirects. The read path has to be fast and cheap.

**The traffic is spiky.** A normal link might get 5 clicks a day for a year. Then it shows up in a popular newsletter and pulls in a million clicks in two hours.

**The data is forever.** If you ever change what a short code points to, you break trust. Old emails, old documents, old QR codes printed on real paper still hold those links.

**Security matters more than people think.** URL shorteners are abused by phishing attacks, malware, and scrapers.

**Deploys can break millions of links.** A bad release in a normal app annoys users. A bad release in a URL shortener silently breaks links printed on real-world paper. The CI/CD pipeline has to be more careful than usual.

## Requirements

### Functional requirements

- Create a short link from any valid URL.
- Redirect from the short link to the long URL with low latency.
- Allow custom aliases such as `sho.rt/launch-2026`.
- Allow links to expire after a date.
- Allow link owners to delete links they made.
- Track click counts and basic metadata per link.
- Provide a small dashboard so users can see their links and stats.
- Support bulk uploads via CSV.

### Non-functional requirements

- Read to write ratio is around 100 to 1.
- Redirect latency under 50 ms at the 99th percentile.
- 99.99 percent availability for redirects.
- Links are immutable once assigned.
- Handle a peak of 10,000 redirects per second and 100 creates per second.
- Cost should stay under 500 dollars per month at moderate traffic.
- Every change ships through the CI/CD pipeline with tests, security scans, and canary monitoring.

## Back of the envelope math

Assume 100 million new links per year. That is about 3 new links per second on average, with bursts to 100 per second. Reads at 100 to 1 give us 300 redirects per second on average, with bursts to 10,000.

**Storage per link works out to about 650 bytes.** 100 million links times 650 bytes is about 65 GB per year. After 5 years we sit at 325 GB. That fits inside DynamoDB without any fuss.

**Bandwidth for redirects:** 10,000 per second times 500 bytes peak gives us 5 MB per second.

**Bandwidth for analytics:** every click writes about 300 bytes. At 10,000 clicks per second that is 3 MB per second, or about 250 GB per day.

## High level architecture

| Service            | Role                                                                                        |
| ------------------ | ------------------------------------------------------------------------------------------- |
| Route 53           | Holds the domain `sho.rt` and routes traffic                                                |
| CloudFront         | Sits at the edge and caches popular redirects                                               |
| AWS WAF            | Sits in front of CloudFront and blocks bad traffic                                          |
| API Gateway        | Routes requests to Lambda functions                                                         |
| Lambda             | Handles create, redirect, delete, and dashboard logic                                       |
| DynamoDB           | Stores the mapping between short codes and long URLs                                        |
| DynamoDB Streams   | Capture write events for downstream processing                                              |
| Cognito            | Handles user signup and login for the dashboard                                             |
| Kinesis Firehose   | Ships click events to S3 for analytics                                                      |
| S3                 | Holds raw click data and any static assets                                                  |
| **S3 Files**       | **Mounts buckets as file systems for bulk imports, shared analytics, and editable content** |
| Athena             | Lets us query click data with SQL                                                           |
| AWS Glue           | Runs hourly jobs that roll up click counts                                                  |
| Amplify Hosting    | Hosts the front-end dashboard                                                               |
| CloudWatch         | Watches everything and pages on-call when things break                                      |
| Secrets Manager    | Holds API keys and other secrets                                                            |
| Parameter Store    | Holds non-secret configuration                                                              |
| **CodePipeline**   | **Orchestrates the deployment pipeline**                                                    |
| **CodeBuild**      | **Runs builds, tests, and CDK deploys**                                                     |
| **CodeDeploy**     | **Performs canary deploys with automatic rollback**                                         |
| **GitHub Actions** | **Runs pre-merge tests and lint**                                                           |

## System Flow Infographic

```mermaid
graph TD
    subgraph Client_Layer [User Access]
        User((End User))
        Admin((Site Owner))
    end

    subgraph Edge_Layer [AWS Edge Network]
        CF[CloudFront CDN]
        WAF[AWS WAF Firewall]
        R53[Route 53 DNS]
    end

    subgraph Compute_Layer [Serverless Logic]
        APIGW[API Gateway]
        Lambda[AWS Lambda Functions]
        Cognito[Cognito Auth]
    end

    subgraph Data_Layer [Storage & Analytics]
        DDB[(DynamoDB Link Store)]
        Firehose[Kinesis Firehose]
        S3A[(S3 Analytics Bucket)]
        S3F[(S3 Files Mounts)]
    end

    User --> R53
    R53 --> WAF
    WAF --> CF
    CF --> APIGW
    APIGW --> Lambda
    Lambda --> Cognito
    Lambda --> DDB
    Lambda --> Firehose
    Firehose --> S3A

    Admin --> S3F
    S3F <--> Lambda

    style CF fill:#f9f,stroke:#333,stroke-width:4px
    style S3F fill:#00f,stroke:#fff,stroke-width:2px,color:#fff
    style Lambda fill:#ff9900,stroke:#333,stroke-width:2px
```

## Multi-account AWS setup

A real production system never lives in a single AWS account. We use four accounts under one AWS Organization:

| Account    | Purpose                                                  |
| ---------- | -------------------------------------------------------- |
| Management | Holds the AWS Organization, billing, and Identity Center |
| Tooling    | Hosts the CI/CD pipeline and shared artifact buckets     |
| Dev        | A throwaway environment where every push lands           |
| Staging    | A near-production copy for final testing                 |
| Production | The real thing, with strict controls                     |

The tooling account holds the pipeline. The pipeline assumes a deploy role in each target account using cross-account IAM. This means a leak in one account does not give an attacker access to production.

### Setting up AWS Organizations

```shellscript
# In the management account
aws organizations create-organization --feature-set ALL
aws organizations create-account --email aws+tooling@your-company.com --account-name Tooling
aws organizations create-account --email aws+dev@your-company.com --account-name Dev
aws organizations create-account --email aws+staging@your-company.com --account-name Staging
aws organizations create-account --email aws+prod@your-company.com --account-name Production
```

Use AWS IAM Identity Center for single sign-on into each account. Never create long-lived IAM users for engineers.

### Cross-account deploy roles

In each target account (dev, staging, prod), create a role the tooling account can assume.

```typescript
// lib/cross-account-role-stack.ts
import * as iam from "aws-cdk-lib/aws-iam";
import * as cdk from "aws-cdk-lib";

export class CrossAccountRoleStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props: { toolingAccountId: string }) {
    super(scope, id);

    new iam.Role(this, "DeployRole", {
      roleName: "pipeline-deploy-role",
      assumedBy: new iam.AccountPrincipal(props.toolingAccountId),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName("PowerUserAccess"),
        iam.ManagedPolicy.fromAwsManagedPolicyName("IAMFullAccess"),
      ],
    });
  }
}
```

We grant `PowerUserAccess` plus `IAMFullAccess` because CDK needs to create roles. For tighter security, write a custom policy that allows only the actions CDK needs.

## API design

```mermaid
sequenceDiagram
    participant User as End User
    participant CF as CloudFront (Edge)
    participant L as Lambda (Compute)
    participant DDB as DynamoDB (Store)
    participant K as Kinesis (Analytics)

    User->>CF: GET sho.rt/aB3xY
    alt Cache Hit
        CF-->>User: 301 Redirect (Location)
    else Cache Miss
        CF->>L: Invoke Redirect Function
        L->>DDB: Query short_code
        DDB-->>L: Long URL
        L->>K: Emit Click Event (Async)
        L-->>CF: 301 Redirect + Cache-Control
        CF-->>User: 301 Redirect (Location)
    end
```

Seven endpoints do the job. Three are core, three support the dashboard, and one supports bulk uploads.

### Core endpoints

- **POST /links** creates a new short link.
- **GET /short_code** redirects to the long URL with a 301 response.
- **DELETE /links/short_code** soft deletes a link.

### Dashboard endpoints

- **GET /me/links** lists links owned by the signed-in user.
- **GET /me/links/short_code/stats** returns click stats.
- **POST /me/links/bulk** creates many links in one call.

### Bulk upload endpoint

- **POST /me/imports** accepts a CSV upload of up to 100,000 rows, processed asynchronously through S3 Files.

## Picking the database

DynamoDB is the right pick because the access pattern is a single key lookup.

### Main table: links

| Attribute  | Type                   | Description                     |
| ---------- | ---------------------- | ------------------------------- |
| short_code | String (Partition Key) | The short code                  |
| long_url   | String                 | The original URL                |
| created_at | String (ISO 8601)      | When the link was made          |
| expires_at | String (ISO 8601)      | Optional expiry                 |
| owner_id   | String                 | The Cognito user ID             |
| status     | String                 | active, deleted, or flagged     |
| ttl        | Number                 | Unix timestamp for auto-cleanup |

### Global Secondary Index: by_owner

Lets us list a user's links in newest-first order.

## Short code generation

Random base62 with a collision check is the right balance.

```javascript
// lib/code-generator.js
import { randomBytes } from "crypto";

const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const CODE_LENGTH = 7;
const REJECTION_LIMIT = 248;

export function generateShortCode() {
  let code = "";
  while (code.length < CODE_LENGTH) {
    const buffer = randomBytes(CODE_LENGTH * 2);
    for (const byte of buffer) {
      if (byte >= REJECTION_LIMIT) continue;
      code += ALPHABET[byte % ALPHABET.length];
      if (code.length === CODE_LENGTH) break;
    }
  }
  return code;
}
```

## Repository layout and CDK app structure

A clean repo layout makes the rest of the work easier. Here is the shape:

```plaintext
url-shortener/
  bin/
    url-shortener.ts          # CDK app entry point
  lib/
    stages/
      app-stage.ts            # Wires all stacks for one environment
      pipeline-stack.ts       # The CodePipeline definition
    stacks/
      data-stack.ts           # DynamoDB, S3 buckets, S3 Files
      compute-stack.ts        # Lambdas, API Gateway
      edge-stack.ts           # CloudFront, WAF, Route 53
      auth-stack.ts           # Cognito
      analytics-stack.ts      # Firehose, Glue, Athena
      monitoring-stack.ts     # CloudWatch alarms, dashboards
  lambdas/
    create-link/
    redirect/
    delete-link/
    list-links/
    get-stats/
    bulk-import/
    safety-scan/
  frontend/
    src/
    package.json              # Next.js dashboard
  scripts/
    smoke-test.sh
    seed-dev-data.ts
  test/
    unit/
    integration/
    load/
  .github/
    workflows/
      pr-checks.yml
      release.yml
  cdk.json
  package.json
  tsconfig.json
  buildspec-build.yml
  buildspec-deploy.yml
  buildspec-smoke.yml
```

### CDK app entry point

```typescript
// bin/url-shortener.ts
import * as cdk from "aws-cdk-lib";
import { PipelineStack } from "../lib/stages/pipeline-stack";

const app = new cdk.App();

new PipelineStack(app, "UrlShortenerPipeline", {
  env: {
    account: process.env.TOOLING_ACCOUNT_ID,
    region: "us-east-1",
  },
  devAccountId: process.env.DEV_ACCOUNT_ID!,
  stagingAccountId: process.env.STAGING_ACCOUNT_ID!,
  prodAccountId: process.env.PROD_ACCOUNT_ID!,
});

app.synth();
```

The pipeline stack lives in the tooling account. It deploys the application stacks into dev, staging, and production accounts.

### App stage

The app stage groups all the stacks that make up one environment.

```typescript
// lib/stages/app-stage.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { DataStack } from "../stacks/data-stack";
import { ComputeStack } from "../stacks/compute-stack";
import { EdgeStack } from "../stacks/edge-stack";
import { AuthStack } from "../stacks/auth-stack";
import { AnalyticsStack } from "../stacks/analytics-stack";
import { MonitoringStack } from "../stacks/monitoring-stack";

export interface AppStageProps extends cdk.StageProps {
  envName: "dev" | "staging" | "prod";
  domainName: string;
}

export class AppStage extends cdk.Stage {
  constructor(scope: Construct, id: string, props: AppStageProps) {
    super(scope, id, props);

    const data = new DataStack(this, "Data", { envName: props.envName });
    const auth = new AuthStack(this, "Auth", { envName: props.envName });
    const analytics = new AnalyticsStack(this, "Analytics", { envName: props.envName });
    const compute = new ComputeStack(this, "Compute", {
      envName: props.envName,
      linksTable: data.linksTable,
      importsBucket: data.importsBucket,
      statsFileSystem: data.statsFileSystem,
      contentFileSystem: data.contentFileSystem,
      clickStream: analytics.clickStream,
      userPool: auth.userPool,
    });
    const edge = new EdgeStack(this, "Edge", {
      envName: props.envName,
      api: compute.api,
      domainName: props.domainName,
    });
    new MonitoringStack(this, "Monitoring", {
      envName: props.envName,
      api: compute.api,
      linksTable: data.linksTable,
      distribution: edge.distribution,
    });
  }
}
```

## Building the back-end services

I will skip repeating the create-link and redirect Lambdas from the previous version of this guide. Both stay the same. The new piece is wiring them into the multi-stack CDK structure with environment-aware naming so dev, staging, and prod do not collide.

### Data stack

```typescript
// lib/stacks/data-stack.ts
import * as cdk from "aws-cdk-lib";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import { Construct } from "constructs";

export interface DataStackProps extends cdk.StackProps {
  envName: string;
}

export class DataStack extends cdk.Stack {
  public readonly linksTable: dynamodb.Table;
  public readonly importsBucket: s3.Bucket;
  public readonly statsFileSystem: cdk.CfnResource;
  public readonly contentFileSystem: cdk.CfnResource;
  public readonly vpc: ec2.Vpc;

  constructor(scope: Construct, id: string, props: DataStackProps) {
    super(scope, id, props);

    this.vpc = new ec2.Vpc(this, "AppVpc", {
      maxAzs: 2,
      natGateways: props.envName === "prod" ? 2 : 1,
    });

    this.linksTable = new dynamodb.Table(this, "LinksTable", {
      tableName: `links-${props.envName}`,
      partitionKey: { name: "short_code", type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      timeToLiveAttribute: "ttl",
      pointInTimeRecovery: props.envName === "prod",
      removalPolicy:
        props.envName === "prod" ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
      stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES,
    });

    this.linksTable.addGlobalSecondaryIndex({
      indexName: "by_owner",
      partitionKey: { name: "owner_id", type: dynamodb.AttributeType.STRING },
      sortKey: { name: "created_at", type: dynamodb.AttributeType.STRING },
      projectionType: dynamodb.ProjectionType.ALL,
    });

    this.importsBucket = new s3.Bucket(this, "ImportsBucket", {
      bucketName: `url-shortener-imports-${props.envName}-${this.account}`,
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true,
      versioned: true,
      lifecycleRules: [{ expiration: cdk.Duration.days(90) }],
    });

    const statsBucket = new s3.Bucket(this, "StatsBucket", {
      bucketName: `url-shortener-stats-${props.envName}-${this.account}`,
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true,
    });

    const contentBucket = new s3.Bucket(this, "ContentBucket", {
      bucketName: `url-shortener-content-${props.envName}-${this.account}`,
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true,
      versioned: true,
    });

    this.statsFileSystem = new cdk.CfnResource(this, "StatsFileSystem", {
      type: "AWS::S3::FileSystem",
      properties: {
        BucketName: statsBucket.bucketName,
        PerformanceMode: "generalPurpose",
        ThroughputMode: "elastic",
      },
    });

    this.contentFileSystem = new cdk.CfnResource(this, "ContentFileSystem", {
      type: "AWS::S3::FileSystem",
      properties: {
        BucketName: contentBucket.bucketName,
        PerformanceMode: "generalPurpose",
        ThroughputMode: "elastic",
      },
    });
  }
}
```

Note how `pointInTimeRecovery` and `removalPolicy` change based on environment. In dev we destroy on stack delete to keep things clean. In prod we always retain.

## Bulk imports with S3 Files

Marketing teams often have a CSV with 50,000 long URLs they want to shorten in one go. Posting 50,000 API calls is slow and noisy. With S3 Files, they drop the CSV into a mounted folder and a Lambda picks it up.

### Why S3 Files fits this job

S3 Files mounts an S3 bucket as a real file system on Lambda. The Lambda can then read CSVs with normal file reads, write progress to a status file, and write the output back to the same folder. No SDK calls needed on the producer side.

This works for a URL shortener because:

- CSVs are file-shaped data, not key-value lookups.
- Multiple producers (laptops, scripts, EC2 jobs) can write to the same folder.
- The processing Lambda can stream a 200 MB CSV without holding it in memory.
- Status files (e.g. `import.status.json`) are easy to update with regular file operations.

### The bulk import Lambda

```javascript
// lambdas/bulk-import/index.js
import { promises as fs } from "fs";
import { createReadStream } from "fs";
import { createInterface } from "readline";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, BatchWriteCommand } from "@aws-sdk/lib-dynamodb";
import { generateShortCode } from "./code-generator.js";

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.TABLE_NAME;
const MOUNT_PATH = "/mnt/imports";

export const handler = async (event) => {
  const { filename, owner_id } = event.detail;
  const inputPath = `${MOUNT_PATH}/inbox/${filename}`;
  const statusPath = `${MOUNT_PATH}/status/${filename}.json`;
  const outputPath = `${MOUNT_PATH}/output/${filename}`;

  await writeStatus(statusPath, { state: "running", total: 0, done: 0, failed: 0 });

  const stream = createReadStream(inputPath, { encoding: "utf-8" });
  const lines = createInterface({ input: stream, crlfDelay: Infinity });

  let batch = [];
  let total = 0,
    done = 0,
    failed = 0;
  const outputLines = ["short_code,short_url,long_url,status"];

  for await (const line of lines) {
    if (!line.trim() || line.startsWith("#")) continue;
    total++;
    const longUrl = line.trim();
    const shortCode = generateShortCode();

    batch.push({
      PutRequest: {
        Item: {
          short_code: shortCode,
          long_url: longUrl,
          owner_id,
          created_at: new Date().toISOString(),
          status: "active",
        },
      },
    });

    if (batch.length === 25) {
      const result = await flushBatch(batch);
      done += result.done;
      failed += result.failed;
      outputLines.push(...result.lines);
      batch = [];
      await writeStatus(statusPath, { state: "running", total, done, failed });
    }
  }

  if (batch.length > 0) {
    const result = await flushBatch(batch);
    done += result.done;
    failed += result.failed;
    outputLines.push(...result.lines);
  }

  await fs.writeFile(outputPath, outputLines.join("\n"), "utf-8");
  await writeStatus(statusPath, { state: "complete", total, done, failed });
};

async function flushBatch(batch) {
  let done = 0,
    failed = 0;
  const lines = [];
  try {
    await docClient.send(new BatchWriteCommand({ RequestItems: { [TABLE_NAME]: batch } }));
    for (const item of batch) {
      const it = item.PutRequest.Item;
      done++;
      lines.push(`${it.short_code},https://sho.rt/${it.short_code},${it.long_url},ok`);
    }
  } catch (err) {
    for (const item of batch) {
      const it = item.PutRequest.Item;
      failed++;
      lines.push(`${it.short_code},,${it.long_url},error: ${err.name}`);
    }
  }
  return { done, failed, lines };
}

async function writeStatus(path, status) {
  await fs.writeFile(path, JSON.stringify(status), "utf-8");
}
```

The Lambda mounts S3 Files at `/mnt/imports` and treats it like any other folder.

### Mounting S3 Files on Lambda

```typescript
const bulkImportFn = new nodejs.NodejsFunction(this, "BulkImportFn", {
  entry: "lambdas/bulk-import/index.js",
  runtime: lambda.Runtime.NODEJS_20_X,
  memorySize: 1024,
  timeout: cdk.Duration.minutes(15),
  vpc: this.vpc,
  filesystem: lambda.FileSystem.fromS3FilesAccessPoint(importsAccessPoint.attrArn, "/mnt/imports"),
  environment: {
    TABLE_NAME: linksTable.tableName,
  },
});
```

## Shared analytics with S3 Files

The dashboard needs to show stats per link without paying for an Athena query on every page load. The fix is to run an hourly Glue job that writes one small JSON file per short code, then mount the bucket on the dashboard Lambda.

### The hourly Glue rollup

```python
# scripts/glue/rollup.py
import sys
from awsglue.transforms import *
from awsglue.utils import getResolvedOptions
from pyspark.context import SparkContext
from awsglue.context import GlueContext
from pyspark.sql.functions import col, count, collect_list, struct

args = getResolvedOptions(sys.argv, ['JOB_NAME', 'INPUT_PATH', 'OUTPUT_PATH'])

sc = SparkContext()
glueContext = GlueContext(sc)
spark = glueContext.spark_session
job = Job(glueContext)
job.init(args['JOB_NAME'], args)

clicks = spark.read.parquet(args['INPUT_PATH'])

per_link = clicks.groupBy('short_code').agg(
    count('*').alias('total_clicks'),
    collect_list(struct('country', 'referer', 'timestamp')).alias('events'),
)

per_link.write.partitionBy('short_code').json(args['OUTPUT_PATH'])

job.commit()
```

The Glue job writes one JSON file per short code into the stats bucket. Because S3 Files keeps the bucket and the file system in sync, the dashboard Lambda sees the new files within seconds.

### The dashboard stats Lambda

```javascript
// lambdas/get-stats/index.js
import { promises as fs } from "fs";
import path from "path";

const MOUNT_PATH = process.env.MOUNT_PATH || "/mnt/stats";

export const handler = async (event) => {
  const shortCode = event.pathParameters?.short_code;
  if (!shortCode) return response(400, { error: "Missing short_code" });

  const shard = shortCode.slice(0, 2);
  const filePath = path.join(MOUNT_PATH, "links", shard, `${shortCode}.json`);

  try {
    const data = await fs.readFile(filePath, "utf-8");
    return response(200, JSON.parse(data));
  } catch (err) {
    if (err.code === "ENOENT") {
      return response(200, {
        short_code: shortCode,
        total_clicks: 0,
        daily: [],
        countries: [],
        referrers: [],
      });
    }
    return response(500, { error: "Could not load stats" });
  }
};

function response(statusCode, body) {
  return {
    statusCode,
    headers: { "Content-Type": "application/json", "Cache-Control": "private, max-age=300" },
    body: JSON.stringify(body),
  };
}
```

No DynamoDB, no Athena. The dashboard returns stats in under 10 ms.

## Editable warning pages with S3 Files

When a link is flagged for malware or phishing, we serve a warning page instead of redirecting. With S3 Files, the trust and safety team edits the HTML directly without a deploy.

```javascript
// inside the redirect Lambda, when status === 'flagged'
import { promises as fs } from "fs";

const CONTENT_PATH = "/mnt/content/warning-page.html";
let cachedPage = null;
let cachedAt = 0;

async function getWarningPage() {
  const now = Date.now();
  if (cachedPage && now - cachedAt < 60_000) return cachedPage;
  cachedPage = await fs.readFile(CONTENT_PATH, "utf-8");
  cachedAt = now;
  return cachedPage;
}
```

Within a minute, every redirect Lambda picks up the new content.

## Front-end dashboard on Amplify

The dashboard is a Next.js app that calls the API. We host it on AWS Amplify Hosting so deploys are tied to the same Git workflow.

### Setting up Amplify in CDK

```typescript
// lib/stacks/frontend-stack.ts
import * as amplify from "aws-cdk-lib/aws-amplify";
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";

export interface FrontendStackProps extends cdk.StackProps {
  envName: string;
  apiUrl: string;
  userPoolId: string;
  userPoolClientId: string;
}

export class FrontendStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: FrontendStackProps) {
    super(scope, id, props);

    const app = new amplify.CfnApp(this, "DashboardApp", {
      name: `url-shortener-dashboard-${props.envName}`,
      repository: "https://github.com/your-org/url-shortener",
      accessToken: cdk.SecretValue.secretsManager("github-token").unsafeUnwrap(),
      buildSpec: `
        version: 1
        applications:
          - appRoot: frontend
            frontend:
              phases:
                preBuild:
                  commands:
                    - npm ci
                build:
                  commands:
                    - npm run build
              artifacts:
                baseDirectory: .next
                files:
                  - '**/*'
      `,
      environmentVariables: [
        { name: "NEXT_PUBLIC_API_URL", value: props.apiUrl },
        { name: "NEXT_PUBLIC_USER_POOL_ID", value: props.userPoolId },
        { name: "NEXT_PUBLIC_USER_POOL_CLIENT_ID", value: props.userPoolClientId },
      ],
    });

    new amplify.CfnBranch(this, "MainBranch", {
      appId: app.attrAppId,
      branchName: props.envName === "prod" ? "main" : props.envName,
      enableAutoBuild: true,
    });
  }
}
```

Amplify handles the build, the CDN, and the rollback in one service. Each environment gets its own branch: `dev`, `staging`, and `main`.

## CI/CD with GitHub Actions and AWS CodePipeline

```mermaid
graph LR
    subgraph GitHub [Source Control]
        PR[Pull Request]
        Main[Main Branch]
    end

    subgraph GHA [Layer 1: CI]
        Lint[Lint & Type Check]
        Unit[Unit Tests]
        Sec[Security Scan]
    end

    subgraph AWS_Pipeline [Layer 2: CD]
        Synth[CDK Synth]
        Dev[Deploy Dev]
        Smoke[Smoke Test]
        Stage[Deploy Staging]
        Manual{Manual Approval}
        Prod[Deploy Prod Canary]
        Rollback[Auto Rollback]
    end

    PR --> Lint
    Lint --> Unit
    Unit --> Sec
    Sec -- Success --> Main

    Main --> Synth
    Synth --> Dev
    Dev --> Smoke
    Smoke --> Stage
    Stage --> Manual
    Manual -- Approved --> Prod
    Prod -- Alarm! --> Rollback
```

We split CI/CD into two layers.

**GitHub Actions** runs on every pull request: lint, type-check, unit tests, security scan. It blocks merges if anything fails.

**AWS CodePipeline** runs after merge to `main`: build, deploy to dev, smoke test, deploy to staging, manual approval, canary deploy to production, automatic rollback on alarm.

### Layer 1: GitHub Actions for pre-merge checks

```yaml
# .github/workflows/pr-checks.yml
name: PR Checks

on:
  pull_request:
    branches: [main]

jobs:
  validate:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Type check
        run: npm run typecheck

      - name: Unit tests
        run: npm run test:unit

      - name: CDK synth
        run: npx cdk synth --quiet

      - name: Security scan
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: "fs"
          ignore-unfixed: true
          format: "sarif"
          output: "trivy-results.sarif"

      - name: Upload SARIF
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: "trivy-results.sarif"

  integration-tests:
    runs-on: ubuntu-latest
    services:
      dynamodb:
        image: amazon/dynamodb-local
        ports:
          - 8000:8000
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - run: npm run test:integration
        env:
          DYNAMODB_ENDPOINT: http://localhost:8000
```

The PR check workflow uses OIDC to authenticate to AWS without long-lived credentials. Set this up by creating an OIDC provider in the tooling account.

### Layer 2: AWS CodePipeline for deploy

The pipeline lives in the tooling account. It uses the CDK Pipelines library, which is the modern way to do CI/CD with CDK.

```typescript
// lib/stages/pipeline-stack.ts
import * as cdk from "aws-cdk-lib";
import * as codepipeline from "aws-cdk-lib/aws-codepipeline";
import * as codepipelineActions from "aws-cdk-lib/aws-codepipeline-actions";
import * as codebuild from "aws-cdk-lib/aws-codebuild";
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
import { Construct } from "constructs";
import {
  CodePipeline,
  CodePipelineSource,
  ShellStep,
  ManualApprovalStep,
} from "aws-cdk-lib/pipelines";
import { AppStage } from "./app-stage";

export interface PipelineStackProps extends cdk.StackProps {
  devAccountId: string;
  stagingAccountId: string;
  prodAccountId: string;
}

export class PipelineStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: PipelineStackProps) {
    super(scope, id, props);

    const pipeline = new CodePipeline(this, "Pipeline", {
      pipelineName: "url-shortener-pipeline",
      synth: new ShellStep("Synth", {
        input: CodePipelineSource.gitHub("your-org/url-shortener", "main", {
          authentication: cdk.SecretValue.secretsManager("github-token"),
        }),
        commands: [
          "npm ci",
          "npm run lint",
          "npm run typecheck",
          "npm run test:unit",
          "npx cdk synth",
        ],
      }),
      crossAccountKeys: true,
      dockerEnabledForSynth: true,
    });

    // Stage 1: Dev
    const dev = new AppStage(this, "Dev", {
      env: { account: props.devAccountId, region: "us-east-1" },
      envName: "dev",
      domainName: "dev.sho.rt",
    });
    pipeline.addStage(dev, {
      post: [
        new ShellStep("SmokeTestDev", {
          envFromCfnOutputs: { API_URL: dev.apiUrlOutput },
          commands: ["npm ci", "npm run test:smoke -- --url $API_URL"],
        }),
      ],
    });

    // Stage 2: Staging
    const staging = new AppStage(this, "Staging", {
      env: { account: props.stagingAccountId, region: "us-east-1" },
      envName: "staging",
      domainName: "staging.sho.rt",
    });
    pipeline.addStage(staging, {
      post: [
        new ShellStep("SmokeTestStaging", {
          envFromCfnOutputs: { API_URL: staging.apiUrlOutput },
          commands: [
            "npm ci",
            "npm run test:smoke -- --url $API_URL",
            "npm run test:load -- --url $API_URL --short",
          ],
        }),
      ],
    });

    // Stage 3: Production with manual approval and canary
    const prod = new AppStage(this, "Prod", {
      env: { account: props.prodAccountId, region: "us-east-1" },
      envName: "prod",
      domainName: "sho.rt",
    });
    pipeline.addStage(prod, {
      pre: [
        new ManualApprovalStep("PromoteToProd", {
          comment: "Confirm canary deploy to production",
        }),
      ],
      post: [
        new ShellStep("SmokeTestProd", {
          envFromCfnOutputs: { API_URL: prod.apiUrlOutput },
          commands: ["npm ci", "npm run test:smoke -- --url $API_URL"],
        }),
      ],
    });
  }
}
```

### What the pipeline does step by step

1. **Source.** GitHub triggers the pipeline on every push to `main`.
2. **Synth.** CodeBuild runs lint, type-check, unit tests, and `cdk synth` to produce CloudFormation templates.
3. **Self-mutate.** The pipeline updates itself if the pipeline definition changed.
4. **Deploy to Dev.** CDK deploys all stacks into the dev account.
5. **Smoke test Dev.** A shell step calls the dev API to verify it works.
6. **Deploy to Staging.** Same as dev, but in the staging account.
7. **Smoke and short load test Staging.** Verifies the system handles a moderate load.
8. **Manual approval.** A human in your team clicks approve.
9. **Canary deploy to Prod.** CodeDeploy shifts 10 percent of traffic to the new Lambda version.
10. **Bake.** CloudWatch alarms watch error rates for 10 minutes.
11. **Promote.** If alarms stay green, CodeDeploy shifts 100 percent of traffic.
12. **Smoke test Prod.** Final verification.
13. **Rollback.** If alarms trip, CodeDeploy rolls back automatically.

## Canary deploys and automatic rollback

CDK Pipelines deploys via CloudFormation, but for the redirect Lambda we want canary behavior. Here is how to add it.

### Lambda alias with canary traffic shifting

```typescript
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as codedeploy from "aws-cdk-lib/aws-codedeploy";

const redirectFn = new nodejs.NodejsFunction(this, "RedirectFn", {
  // ... function config
});

// Always create a version for the current code.
const version = redirectFn.currentVersion;

// Alias points to the live version.
const liveAlias = new lambda.Alias(this, "RedirectFnLive", {
  aliasName: "live",
  version,
});

// Canary deploy: 10 percent for 10 minutes, then 100 percent.
new codedeploy.LambdaDeploymentGroup(this, "RedirectDeployment", {
  alias: liveAlias,
  deploymentConfig: codedeploy.LambdaDeploymentConfig.CANARY_10PERCENT_10MINUTES,
  alarms: [
    new cloudwatch.Alarm(this, "CanaryErrorRate", {
      metric: liveAlias.metricErrors({ period: cdk.Duration.minutes(1) }),
      threshold: 5,
      evaluationPeriods: 2,
    }),
    new cloudwatch.Alarm(this, "CanaryLatency", {
      metric: liveAlias.metricDuration({ statistic: "p99", period: cdk.Duration.minutes(1) }),
      threshold: 200,
      evaluationPeriods: 3,
    }),
  ],
  autoRollback: {
    failedDeployment: true,
    stoppedDeployment: true,
    deploymentInAlarm: true,
  },
});
```

If either alarm trips during the canary window, CodeDeploy rolls the alias back to the previous version. Users see the old code again within seconds. No paging at 3 a.m.

### Wiring API Gateway to the alias

The API Gateway integration must point at the alias, not the function:

```typescript
api.addRoutes({
  path: "/{short_code}",
  methods: [apigwv2.HttpMethod.GET],
  integration: new integrations.HttpLambdaIntegration("RedirectInt", liveAlias),
});
```

This is what makes the canary work. Traffic goes through the alias, which is the thing CodeDeploy shifts.

## Secrets and configuration management

There are two kinds of values your service needs at runtime: secrets and configuration. Treat them differently.

### Secrets in AWS Secrets Manager

Anything sensitive (API keys, third-party tokens) lives in Secrets Manager.

```typescript
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";

const safeBrowsingKey = new secretsmanager.Secret(this, "SafeBrowsingApiKey", {
  secretName: `url-shortener/${props.envName}/safe-browsing-key`,
  description: "Google Safe Browsing API key",
});

createLinkFn.addToRolePolicy(
  new iam.PolicyStatement({
    actions: ["secretsmanager:GetSecretValue"],
    resources: [safeBrowsingKey.secretArn],
  })
);
```

The Lambda fetches the secret at cold start and caches it.

```javascript
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";

const sm = new SecretsManagerClient({});
let cachedKey = null;

async function getSafeBrowsingKey() {
  if (cachedKey) return cachedKey;
  const result = await sm.send(
    new GetSecretValueCommand({
      SecretId: process.env.SAFE_BROWSING_SECRET_ARN,
    })
  );
  cachedKey = result.SecretString;
  return cachedKey;
}
```

### Configuration in Parameter Store

Non-secret config (rate limits, feature flags, allowed origins) lives in SSM Parameter Store.

```typescript
import * as ssm from "aws-cdk-lib/aws-ssm";

new ssm.StringParameter(this, "AllowedOrigins", {
  parameterName: `/url-shortener/${props.envName}/allowed-origins`,
  stringValue: JSON.stringify(["https://app.sho.rt", "https://staging.app.sho.rt"]),
});
```

Parameter Store is free for the first 10,000 standard parameters, which is way more than you need.

### Loading config at startup

Lambda extensions can load config once and serve it to your code via a local HTTP endpoint. This avoids hitting SSM on every cold start.

```typescript
const paramsLayer = lambda.LayerVersion.fromLayerVersionArn(
  this,
  "ParamsLayer",
  `arn:aws:lambda:${this.region}:177933569100:layer:AWS-Parameters-and-Secrets-Lambda-Extension:11`
);

createLinkFn.addLayers(paramsLayer);
```

Then in your Lambda:

```javascript
const port = process.env.PARAMETERS_SECRETS_EXTENSION_HTTP_PORT;
const res = await fetch(
  `http://localhost:${port}/systemsmanager/parameters/get?name=/url-shortener/prod/allowed-origins`,
  { headers: { "X-Aws-Parameters-Secrets-Token": process.env.AWS_SESSION_TOKEN } }
);
```

## Observability stack

CloudWatch alone is not enough at production scale. We use a layered approach.

### Structured logs

Every Lambda log line is JSON. CloudWatch Insights can then query them.

```javascript
function log(level, message, extra = {}) {
  console.log(
    JSON.stringify({
      level,
      message,
      timestamp: new Date().toISOString(),
      requestId: process.env.AWS_LAMBDA_REQUEST_ID,
      env: process.env.ENV_NAME,
      ...extra,
    })
  );
}
```

### Distributed tracing with X-Ray

Turn on X-Ray on every Lambda for end-to-end traces.

```typescript
const redirectFn = new nodejs.NodejsFunction(this, "RedirectFn", {
  // ...
  tracing: lambda.Tracing.ACTIVE,
});
```

X-Ray gives you a flame graph showing exactly where time goes in each request.

### Custom metrics

Emit business metrics from your Lambda code using CloudWatch Embedded Metric Format.

```javascript
function emitMetric(name, value, unit = "Count") {
  console.log(
    JSON.stringify({
      _aws: {
        Timestamp: Date.now(),
        CloudWatchMetrics: [
          {
            Namespace: "UrlShortener",
            Dimensions: [["Environment"]],
            Metrics: [{ Name: name, Unit: unit }],
          },
        ],
      },
      Environment: process.env.ENV_NAME,
      [name]: value,
    })
  );
}

// Usage
emitMetric("LinksCreated", 1);
emitMetric("CacheHitLatency", latencyMs, "Milliseconds");
```

CloudWatch picks these up automatically. No SDK call.

### CloudWatch dashboards as code

```typescript
import * as cw from "aws-cdk-lib/aws-cloudwatch";

const dashboard = new cw.Dashboard(this, "OpsDashboard", {
  dashboardName: `url-shortener-${props.envName}`,
});

dashboard.addWidgets(
  new cw.GraphWidget({
    title: "Redirect latency p99",
    left: [redirectFn.metricDuration({ statistic: "p99" })],
  }),
  new cw.GraphWidget({
    title: "Cache hit ratio",
    left: [
      /* CloudFront cache hit metric */
    ],
  }),
  new cw.SingleValueWidget({
    title: "Links created today",
    metrics: [
      /* custom metric */
    ],
  })
);
```

### Alarms

```typescript
const alertTopic = new sns.Topic(this, "AlertTopic");
alertTopic.addSubscription(new subs.EmailSubscription("oncall@your-company.com"));

new cw.Alarm(this, "HighErrorRate", {
  metric: redirectFn.metricErrors({ period: cdk.Duration.minutes(5) }),
  threshold: 10,
  evaluationPeriods: 2,
}).addAlarmAction(new actions.SnsAction(alertTopic));

new cw.Alarm(this, "HighLatency", {
  metric: redirectFn.metricDuration({ statistic: "p99", period: cdk.Duration.minutes(5) }),
  threshold: 200,
  evaluationPeriods: 3,
}).addAlarmAction(new actions.SnsAction(alertTopic));

new cw.Alarm(this, "LowCacheHitRatio", {
  metric: new cw.Metric({
    namespace: "AWS/CloudFront",
    metricName: "CacheHitRate",
    statistic: "Average",
    dimensionsMap: { DistributionId: distribution.distributionId, Region: "Global" },
  }),
  threshold: 0.5,
  comparisonOperator: cw.ComparisonOperator.LESS_THAN_THRESHOLD,
  evaluationPeriods: 4,
}).addAlarmAction(new actions.SnsAction(alertTopic));
```

## Smoke tests

The pipeline runs a smoke test after each deploy. It verifies the deployed system actually works.

```shellscript
#!/bin/bash
# scripts/smoke-test.sh
set -e

API_URL=$1
if [ -z "$API_URL" ]; then
  echo "Usage: $0 <api-url>"
  exit 1
fi

echo "Smoke testing $API_URL"

# Test 1: Create a link
RESPONSE=$(curl -sf -X POST "$API_URL/links" \
  -H 'Content-Type: application/json' \
  -d '{"long_url":"https://example.com/smoke-test"}')

SHORT_CODE=$(echo $RESPONSE | jq -r '.short_code')
echo "Created link: $SHORT_CODE"

# Test 2: Follow the redirect
LOCATION=$(curl -sf -o /dev/null -w '%{redirect_url}' "$API_URL/$SHORT_CODE")

if [ "$LOCATION" != "https://example.com/smoke-test" ]; then
  echo "Redirect mismatch: got $LOCATION"
  exit 1
fi

# Test 3: Verify 404 for unknown code
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$API_URL/this-does-not-exist-123456")

if [ "$HTTP_CODE" != "404" ]; then
  echo "Expected 404, got $HTTP_CODE"
  exit 1
fi

echo "All smoke tests passed"
```

Save this as `scripts/smoke-test.sh` and reference it from the pipeline.

## Domain setup walkthrough

Setting up a custom domain is the part where most teams get stuck. Here is the order that works.

### Step 1: Buy the domain

Use Route 53 or any registrar. If you use a third-party registrar, point its nameservers at Route 53.

### Step 2: Create a hosted zone

```shellscript
aws route53 create-hosted-zone --name sho.rt --caller-reference $(date +%s)
```

Copy the four NS records into your registrar.

### Step 3: Request a certificate in us-east-1

CloudFront requires the certificate in `us-east-1`, regardless of where the rest of your stack lives.

```typescript
const cert = new acm.Certificate(this, "Cert", {
  domainName: "sho.rt",
  subjectAlternativeNames: ["www.sho.rt", "app.sho.rt", "staging.sho.rt", "dev.sho.rt"],
  validation: acm.CertificateValidation.fromDns(zone),
});
```

DNS validation completes in a few minutes.

### Step 4: Attach the certificate to CloudFront

```typescript
const distribution = new cloudfront.Distribution(this, "CdnDistribution", {
  // ...
  domainNames: ["sho.rt"],
  certificate: cert,
});
```

### Step 5: Create the alias record

```typescript
new route53.ARecord(this, "AliasRecordRoot", {
  zone,
  recordName: "sho.rt",
  target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)),
});

new route53.AaaaRecord(this, "AliasRecordRootIpv6", {
  zone,
  recordName: "sho.rt",
  target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)),
});
```

The IPv6 record matters. About 40 percent of mobile traffic uses IPv6.

## Cost analysis

At the traffic we sized for (300 redirects per second average, 3 creates per second), here is the monthly cost in 2026 pricing.

| Service                              | Estimated monthly cost  |
| ------------------------------------ | ----------------------- |
| Lambda                               | 15 to 30 dollars        |
| API Gateway HTTP API                 | 70 dollars              |
| DynamoDB on-demand                   | 30 dollars              |
| CloudFront                           | 30 to 60 dollars        |
| Firehose                             | 100 dollars             |
| S3 storage                           | 5 dollars               |
| S3 Files (imports + stats + content) | 25 to 40 dollars        |
| Athena and Glue                      | 30 dollars              |
| Cognito                              | Free up to 50,000 users |
| WAF                                  | 30 dollars              |
| Route 53 and certs                   | 5 dollars               |
| Amplify Hosting                      | 15 dollars              |
| CodePipeline + CodeBuild             | 20 dollars              |
| Secrets Manager                      | 5 dollars               |
| CloudWatch logs and metrics          | 30 dollars              |

**Total at this scale: roughly 410 to 510 dollars per month.**

If you want to cut costs further:

- Switch DynamoDB to provisioned with auto scaling.
- Use Lambda function URLs instead of API Gateway for the redirect path.
- Move very hot links into ElastiCache Redis.
- Sample analytics rather than capturing every click.
- Trim CloudWatch log retention.

## Scaling beyond a single region

For global presence, replicate DynamoDB with Global Tables. CloudFront is already global. Cognito user pools are regional, so you need a sync tool. Firehose is regional. Secrets Manager replicates across regions natively. S3 Files file systems are also regional, so you mount per-region file systems with cross-region replication on the underlying buckets.

For multi-region CI/CD, the CDK Pipelines library has a `crossAccountKeys` option and supports deploying the same stage to multiple regions in parallel. Add Tokyo and Frankfurt:

```typescript
const prodTokyo = new AppStage(this, "ProdTokyo", {
  env: { account: props.prodAccountId, region: "ap-northeast-1" },
  envName: "prod",
  domainName: "sho.rt",
});
pipeline.addStage(prodTokyo, { pre: [new ManualApprovalStep("PromoteTokyo")] });
```

Route 53 latency-based routing sends each user to the closest region.

## Security checklist

- All endpoints behind HTTPS only.
- WAF rate limit on the public endpoint.
- Create endpoint requires authentication.
- Lambda execution roles grant least privilege.
- DynamoDB encryption at rest is on.
- S3 buckets are private with Block Public Access on.
- S3 Files mount targets sit inside private subnets.
- POSIX permissions enforced on shared file systems.
- Secrets live in Secrets Manager, not in environment variables.
- CloudTrail is enabled on all accounts.
- IAM Identity Center is the only way to log in. No long-lived access keys.
- Root account has MFA and is not used for anything.
- GuardDuty is enabled.
- Security Hub is on as the central place to track findings.
- Cross-account roles use external IDs and condition keys.
- The pipeline uses OIDC to talk to AWS, never long-lived keys.
- All commits are signed (GitHub branch protection enforces this).
- Dependabot is on for both npm and CDK packages.

## Common pitfalls

**Storing clicks in the same DynamoDB row.** Every click does a conditional update. For a viral link this becomes a hot key. Fix: stream to Firehose.

**Using 302 redirects without thinking.** They are not cached as well by browsers.

**Skipping CloudFront.** People expose API Gateway directly and wonder why latency is high in Asia.

**Awaiting analytics calls.** Do not block the redirect on Firehose.

**Putting secrets in environment variables in plain text.** Use Secrets Manager.

**Forgetting to set log retention.** CloudWatch keeps logs forever by default.

**Mounting S3 Files from public Lambdas.** The mount target lives in a VPC, so the Lambda has to be in the same VPC.

**Using S3 Files for the redirect hot path.** DynamoDB is faster for that pattern. S3 Files shines for shared file workloads, not key lookups.

**Pointing API Gateway at the function instead of the alias.** Canary deploys do not work because traffic does not flow through the alias.

**Single AWS account for everything.** A bug in dev should not be able to wipe production data. Use Organizations from day one.

**No manual approval before prod.** Even with great tests, production deploys deserve a human gate, especially at first.

**Pipeline that cannot self-update.** CDK Pipelines can update themselves. If you forget this, every pipeline change requires a manual deploy from a laptop.

## Operational playbook

**Redirects are returning 503.** Check Lambda errors and DynamoDB throttles. Roll back recent deploys with the CodeDeploy rollback button.

**A specific link returns 404.** Check the item in DynamoDB. Status may be `deleted` or `flagged`, or `expires_at` may be in the past.

**Cost spiked overnight.** Open Cost Explorer and filter by service.

**A specific link is being used for phishing.** Set its status to `flagged` in DynamoDB.

**Bulk import stuck at 50 percent.** Read the status file at `/mnt/imports/status/{filename}.json`.

**Pipeline is stuck on a stage.** Look at the stage's CodeBuild logs. Most failures are network timeouts that resolve on retry.

**Canary alarm tripped.** CodeDeploy already rolled back. Look at the canary metrics, fix the bug, push again.

**Cross-account permission error.** The deploy role in the target account may have been removed. Re-deploy the bootstrap stack in that account.

## FAQ

### What is a URL shortener?

A URL shortener is a tool that turns a long web address into a short one. The short link works as an alias. When someone clicks it, the service looks up the long URL and sends the browser to it.

### How does a URL shortener work?

A URL shortener works in three steps. You submit a long URL to the service. The service stores it in a database and gives you back a short code. When someone visits the short link, the service looks up the code and sends the browser a redirect response to the original URL.

### Are short URLs safe to click?

Short URLs are not always safe because the destination is hidden. Reputable shorteners scan links against safety lists like Google Safe Browsing. Before clicking, you can preview the destination using a link expander tool.

### What is AWS S3 Files?

AWS S3 Files is a feature launched on April 7, 2026 that lets you mount any S3 bucket as a file system from EC2, ECS, EKS, or Lambda. It uses Amazon EFS for the high-performance cache and supports NFS v4.1+ semantics. Changes appear back in S3 within minutes.

### How does S3 Files help a URL shortener?

S3 Files helps in three ways. It lets marketing teams drop CSV files into a mounted folder for bulk imports. It gives the dashboard millisecond reads on aggregated stats files. It lets the security team edit warning pages without a deploy.

### What is the best CI/CD setup for AWS Lambda?

The best CI/CD for AWS Lambda combines GitHub Actions for pre-merge checks with AWS CodePipeline for deploys. CodePipeline uses CDK Pipelines to deploy through dev, staging, and prod accounts, with CodeDeploy doing canary traffic shifting on each Lambda. Automatic rollback is wired to CloudWatch alarms.

### How do canary deploys work on Lambda?

Canary deploys on Lambda use a Lambda alias and CodeDeploy. CodeDeploy shifts a small percentage of traffic to the new version, watches CloudWatch alarms for a bake period, then either promotes to 100 percent or rolls back. The alias is what API Gateway points at, so traffic shifts happen at the alias level without touching the function code.

### How do I roll back a bad deploy?

CodeDeploy rolls back automatically when a CloudWatch alarm trips during the canary window. To roll back manually, open the CodeDeploy console, find the deployment, and click Stop and Roll Back. The alias points at the previous version within seconds.

### Should I use one AWS account or multiple?

Use multiple accounts. A real production setup uses one account each for tooling, dev, staging, and prod, all under one AWS Organization. This contains blast radius, simplifies billing, and makes IAM clearer.

### How do I authenticate GitHub Actions to AWS without storing keys?

Use OIDC. Create an OIDC identity provider in your AWS account that trusts GitHub, then create an IAM role that the workflow assumes. The workflow gets temporary credentials with no long-lived secrets in the repo.

### What database is best for a URL shortener?

DynamoDB is the best because the access pattern is a simple key lookup. It gives single-digit millisecond reads at any scale.

### How much does it cost to build a URL shortener on AWS?

Around 410 to 510 dollars per month for moderate traffic of about 300 redirects per second, including all the CI/CD, monitoring, and S3 Files components. At lower traffic it can drop under 100 dollars per month.

### Should short URLs use 301 or 302 redirects?

301 in most cases. A 301 is permanent, so browsers and CDNs cache it. Use 302 only if you need to change the destination later.

### How do I prevent abuse of a public URL shortener?

Use three layers. AWS WAF blocks bad traffic patterns and rate-limits by IP. Google Safe Browsing checks every new URL. A daily scan catches links that became malicious after creation.

### How do I handle viral traffic spikes on a short link?

Edge caching with CloudFront. With 301 redirects and a 24-hour cache header, CloudFront serves the redirect from edge locations without calling the origin. A million-click spike pulls only about 100 requests from your Lambda.

### Can I bulk upload links to a URL shortener?

Yes. With S3 Files, marketing and partner teams drop a CSV into a shared folder. A background Lambda picks up the file, generates short codes, writes them to DynamoDB in batches, and writes a result CSV back. A status file shows live progress.

### How do I host the dashboard for a URL shortener?

Use AWS Amplify Hosting. It connects to your GitHub repo, builds Next.js or any other framework, deploys to a global CDN, and supports per-environment branches that mirror your CDK stages.

### What happens if my URL shortener goes offline?

Every short link stops working. Use multi-region DynamoDB Global Tables, CloudFront origin failover, and 35 days of point-in-time backups to make this rare.

### How long should a short URL code be?

6 to 8 characters. Seven characters from a 62-character alphabet give 3.5 trillion combinations, which is enough for most services for many years.

## Closing thoughts

A URL shortener teaches you almost everything you need to know about web-scale systems. Caching, partitioning, hot keys, async pipelines, edge networks, IAM, monitoring, abuse prevention, deploys, rollbacks, and graceful degradation. The 2026 addition of S3 Files brings a clean way to mix file-based workflows into a serverless system, and the CDK Pipelines library gives you a real CI/CD foundation that grows with the team.

The piece I want you to take away most is this: every decision is a trade-off. There is no right answer. DynamoDB is great here but Postgres would also work if traffic stays moderate. Random codes are fine but counter-based codes save bytes. S3 Files is great for shared files but a poor fit for hot-path key lookups. Pick the trade-off that fits your constraints, write it down so the next engineer understands, and move on.

Build the boring version first. Add complexity only when a real metric tells you to. The systems that hum along year after year are the ones where someone resisted the urge to over-engineer on day one.

A URL shortener lives forever. People print short URLs on business cards, on posters, and in books. If your service goes away, those artifacts go with it. Take that responsibility seriously. Back up your data, document your design, treat your URL space as a public good, and ship every change through a pipeline that protects you from yourself.

Now go build it. And when you do, keep it boring.


---

<!-- METADATA_START -->
## Metadata & Citations

### Further Reading
- [Breaking Production on Purpose: A Guide to Chaos Engineering](https://www.ranti.dev/blog/chaos-engineering-aws-fis.md)
- [Disaster Recovery: The Art of Sleeping at Night](https://www.ranti.dev/blog/disaster-recovery-rto-rpo.md)
- [The Perfect Pipeline: How to Ship Code Without Crashing Production](https://www.ranti.dev/blog/perfect-pipeline-blue-green.md)

### Navigation
- [Back to Bio Hub](https://www.ranti.dev/.md)
- [Full Site Manifest](https://www.ranti.dev/llms.txt)

---
title: How to Build a URL Shortener on AWS in 2026: The Complete End-to-End Guide
author: Rantideb Howlader
date: 2026-04-28T00:00:00.000Z
canonical_url: https://www.ranti.dev/blog/how-to-build-a-url-shortener-on-aws-in-2026
license: CC-BY-4.0
---
```json
{
  "@context": "https://schema.org",
  "@type": "TechArticle",
  "headline": "How to Build a URL Shortener on AWS in 2026: The Complete End-to-End Guide",
  "author": {
    "@type": "Person",
    "name": "Rantideb Howlader"
  },
  "datePublished": "2026-04-28T00:00:00.000Z",
  "url": "https://www.ranti.dev/blog/how-to-build-a-url-shortener-on-aws-in-2026",
  "license": "https://creativecommons.org/licenses/by/4.0/",
  "isAccessibleForFree": true
}
```

### BibTeX
```bibtex
@article{how-to-build-a-url-shortener-on-aws-in-2026_2026,
  author = {Rantideb Howlader},
  title = {How to Build a URL Shortener on AWS in 2026: The Complete End-to-End Guide},
  journal = {Rantideb Howlader Portfolio},
  year = {2026},
  url = {https://www.ranti.dev/blog/how-to-build-a-url-shortener-on-aws-in-2026},
  note = {Accessed: 2026-05-12}
}
```

### IEEE
Rantideb Howlader, "How to Build a URL Shortener on AWS in 2026: The Complete End-to-End Guide," Rantideb Howlader Portfolio, 2026. [Online]. Available: https://www.ranti.dev/blog/how-to-build-a-url-shortener-on-aws-in-2026. [Accessed: 2026-05-12].

### APA
Rantideb Howlader. (2026). How to Build a URL Shortener on AWS in 2026: The Complete End-to-End Guide. Rantideb Howlader. Retrieved from https://www.ranti.dev/blog/how-to-build-a-url-shortener-on-aws-in-2026

--- 
*This content is provided in research-grade Markdown format. Required Attribution: Cite as Rantideb Howlader (2026).*
<!-- METADATA_END -->