Build a URL Shortener on AWS

Deploy a URL shortener with Redis caching and PostgreSQL on AWS ECS.

Introduction

30 Second Summary

Every time you paste a long URL into a shortener and get back a tiny link, there is a full distributed system working behind the scenes. Caching, database lookups, encoding algorithms, and redirect logic all fire in milliseconds.

In this project, you will build and deploy a production-architecture URL shortening service. You will go from whiteboard system design to a real, working system with PostgreSQL persistence, Redis caching, and AWS cloud infrastructure.

What You'll Build

A URL shortening API that accepts long URLs, generates compact Base62-encoded short codes, and redirects visitors with sub-millisecond cache hits.

By the end of this project, you'll have:

  • A working Express.js REST API with Base62 encoding, PostgreSQL persistence, and a Redis cache-aside layer deployed to AWS with ECS Fargate, RDS, ElastiCache, and an Application Load Balancer.
  • A Docker Compose local development stack that spins up your app, database, and cache with a single command.
  • A portfolio-ready README.md and DESIGN.md with Mermaid architecture diagrams, capacity estimates for 100M users, and scaling strategies you can discuss in interviews.
  • Secret Mission: Click analytics with geographic data and TTL-based URL expiration with automatic cache invalidation.

Are there any prerequisites?

You will need an AWS account, Docker Desktop installed, and Node.js 20 on your machine. Basic familiarity with Express.js and SQL is helpful but not required.

Step 1 walks you through confirming your setup before you write any code.

Set Up the Local Development Environment

A URL shortener needs three pieces working together: the application server, a database for persistent storage, and a cache for fast redirects. Running all three locally without conflicts is the first challenge to solve.

Docker Compose lets you define and spin up all three services with a single command. In this step, you'll scaffold the Node.js project, create the database schema, and wire everything together in containers.

In this step, get ready to:
  • Initialize the Node.js project with all required dependencies.
  • Create the Docker Compose stack with PostgreSQL and Redis.
  • Write the Dockerfile for the Node.js app.
Initialize the Node.js project and install dependencies

Every Node.js project starts with a package.json file that declares its dependencies and scripts. You'll create this file, install the packages, and set up an environment variable template.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir url-shortener
cd url-shortener
  • Confirm the folder exists by running:
ls

You should see the url-shortener folder listed on your Desktop.

  • Create a file called package.json in the url-shortener folder by running:
touch package.json
  • Open VS Code in the current folder by running:
code .

code command not found?

If you see an error, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, type VS Code, and press Enter.

Then open the url-shortener folder from File > Open Folder.

help me set up the code command in my terminal

  • Open package.json in the VS Code editor and paste the following content:
{
  "name": "url-shortener",
  "version": "1.0.0",
  "description": "A scalable URL shortener with Redis caching and PostgreSQL persistence",
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js"
  },
  "dependencies": {
    "dotenv": "16.4.7",
    "express": "4.21.2",
    "express-rate-limit": "7.5.0",
    "express-validator": "7.2.1",
    "ioredis": "5.4.2",
    "pg": "8.13.1"
  }
}

What does this file do?

  • express is the web framework that handles HTTP requests and routing.
  • pg is the PostgreSQL client for reading and writing URL data.
  • ioredis is the Redis client for caching hot redirect lookups.
  • express-rate-limit throttles requests to prevent abuse.
  • express-validator validates incoming URLs before storing them.
  • dotenv loads environment variables from a .env file for local development.
  • Save package.json.
  • Install all dependencies by running this command in your terminal:
npm install

You should see output listing the installed packages and a node_modules folder appear in the VS Code sidebar.

Seeing permission errors or network failures?

Make sure you are inside the url-shortener folder (run pwd to check). If you see EACCES permission errors on macOS, prefix the command with sudo.

help me fix npm install errors

  • Create a file called .env.example in the url-shortener root folder and paste the following:
PORT=3000
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/urlshortener
REDIS_URL=redis://localhost:6379
BASE_URL=http://localhost:3000

What is this file for?

This template documents which environment variables the app expects. You won't load this file directly. Docker Compose injects these values into the container at runtime.

  • Save .env.example.
Create the Docker Compose stack

Your URL shortener needs a PostgreSQL database for persistent URL storage and a Redis cache for fast redirect lookups. Docker Compose lets you define all three services (app, database, cache) in one file and start them together.

  • Create the src directory by running:
mkdir src
  • Create a file called schema.sql inside the src folder and paste the following:
CREATE TABLE IF NOT EXISTS urls (
    id BIGSERIAL PRIMARY KEY,
    short_code VARCHAR(10) NOT NULL UNIQUE,
    original_url TEXT NOT NULL,
    click_count INTEGER DEFAULT 0,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_urls_short_code ON urls (short_code);

What does this schema do?

  • id BIGSERIAL PRIMARY KEY creates an auto-incrementing ID that the Base62 encoder will use to generate short codes.
  • short_code VARCHAR(10) NOT NULL UNIQUE stores the encoded short code with a uniqueness constraint.
  • The idx_urls_short_code index speeds up lookups on every redirect request. Without it, PostgreSQL would scan every row to find a matching code.
  • Save src/schema.sql.

Why define the schema as a SQL file?

PostgreSQL's official Docker image automatically runs any .sql files placed in /docker-entrypoint-initdb.d/ when the container starts for the first time. This means the database creates your table automatically without any manual setup.

  • Create a file called docker-compose.yml in the url-shortener root folder (not inside src) and paste the following:
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - PORT=3000
      - DATABASE_URL=postgresql://postgres:postgres@db:5432/urlshortener
      - REDIS_URL=redis://redis:6379
      - BASE_URL=http://localhost:3000
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=urlshortener
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./src/schema.sql:/docker-entrypoint-initdb.d/schema.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  pgdata:

What does each service do?

  • app builds your Node.js app from the local Dockerfile. It waits for the database to be healthy before starting.
  • db runs PostgreSQL 16. The volume mount copies schema.sql into the container's init directory so the urls table is created on first boot.
  • redis runs Redis 7 on port 6379. No persistence configured because the cache is rebuilt from PostgreSQL on any miss.
  • pgdata is a named volume that persists your database data across container restarts.

Notice the DATABASE_URL uses db as the hostname (not localhost). Inside Docker's network, services refer to each other by their service name.

  • Save docker-compose.yml.

✔️ Awesome, I've got everything!

Great. Make sure both src/schema.sql and docker-compose.yml are saved before continuing.

ⓧ I'd like to double check the full code

Here is src/schema.sql:

CREATE TABLE IF NOT EXISTS urls (
    id BIGSERIAL PRIMARY KEY,
    short_code VARCHAR(10) NOT NULL UNIQUE,
    original_url TEXT NOT NULL,
    click_count INTEGER DEFAULT 0,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_urls_short_code ON urls (short_code);

Here is docker-compose.yml:

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - PORT=3000
      - DATABASE_URL=postgresql://postgres:postgres@db:5432/urlshortener
      - REDIS_URL=redis://redis:6379
      - BASE_URL=http://localhost:3000
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=urlshortener
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./src/schema.sql:/docker-entrypoint-initdb.d/schema.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  pgdata:
Create the Dockerfile for the Node.js app

Docker needs a Dockerfile to know how to build your application into a container image. This file defines the base image, copies your code, installs dependencies, and sets the startup command.

  • Create a file called Dockerfile (no file extension) in the url-shortener root folder and paste the following:
FROM node:20-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY src ./src
EXPOSE 3000
CMD ["node", "src/index.js"]

What does each line do?

  • FROM node:20-alpine starts from a lightweight Node.js 20 image (about 50 MB instead of 900 MB for the full image).
  • COPY package.json package-lock.json copies dependency files first. Docker caches this layer so rebuilds are fast when only source code changes.
  • RUN npm ci --only=production installs exact versions from the lock file without dev dependencies.
  • COPY src ./src copies your application source code into the container.
  • CMD ["node", "src/index.js"] sets the command that runs when the container starts.
  • Save Dockerfile.
  • Verify the Docker image builds successfully by running:
docker build -t url-shortener .

You should see output ending with a line like Successfully tagged url-shortener:latest or a build ID confirmation.

Build failing?

Make sure Docker Desktop is running. Check that Dockerfile is in the root of your url-shortener folder (not inside src). Also confirm package-lock.json exists. It should have been generated by npm install earlier.

help me fix my Docker build error

  • Now start all three containers by running:
docker compose up --build

What should I see?

The db container will start PostgreSQL and run your schema.sql to create the urls table. The redis container will show Ready to accept connections in the logs.

The app container will exit with an error because src/index.js does not exist yet. You will see a message like Cannot find module from Node.js. This is expected. You will write the application code in the next step.

  • Confirm the database and cache containers are running by pressing Ctrl+C to stop the logs, then running:
docker compose ps

You should see db and redis listed as running. The app service will show as exited or restarting, which is fine for now.

Database or Redis container not starting?

If the db container fails, check that port 5432 is not already in use by a local PostgreSQL installation. Run lsof -i :5432 (macOS) or netstat -ano | findstr 5432 (Windows) to check.

If Redis fails, check port 6379 the same way. Stop any conflicting local services before retrying docker compose up --build.

help me fix my Docker Compose startup issue

  • Stop the containers for now by running:
docker compose down

✔️ Awesome, I've got everything!

Great. Double check that you have saved all five files: package.json, .env.example, src/schema.sql, docker-compose.yml, and Dockerfile.

ⓧ I'd like to double check the full code

Here is package.json:

{
  "name": "url-shortener",
  "version": "1.0.0",
  "description": "A scalable URL shortener with Redis caching and PostgreSQL persistence",
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js"
  },
  "dependencies": {
    "dotenv": "16.4.7",
    "express": "4.21.2",
    "express-rate-limit": "7.5.0",
    "express-validator": "7.2.1",
    "ioredis": "5.4.2",
    "pg": "8.13.1"
  }
}

Here is .env.example:

PORT=3000
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/urlshortener
REDIS_URL=redis://localhost:6379
BASE_URL=http://localhost:3000

Here is src/schema.sql:

CREATE TABLE IF NOT EXISTS urls (
    id BIGSERIAL PRIMARY KEY,
    short_code VARCHAR(10) NOT NULL UNIQUE,
    original_url TEXT NOT NULL,
    click_count INTEGER DEFAULT 0,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_urls_short_code ON urls (short_code);

Here is docker-compose.yml:

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - PORT=3000
      - DATABASE_URL=postgresql://postgres:postgres@db:5432/urlshortener
      - REDIS_URL=redis://redis:6379
      - BASE_URL=http://localhost:3000
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=urlshortener
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./src/schema.sql:/docker-entrypoint-initdb.d/schema.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  pgdata:

Here is Dockerfile:

FROM node:20-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY src ./src
EXPOSE 3000
CMD ["node", "src/index.js"]

Your local infrastructure is ready. Next up, you'll write the application code that shortens URLs, stores them in PostgreSQL, and redirects visitors.

Build the Core API

Your Docker Compose stack is running with PostgreSQL and Redis ready to accept connections. Now it is time to write the application code that makes your URL shortener actually work.

In this step, you will build the three core pieces of the service: a Base62 encoding utility that turns database IDs into compact short codes, a POST endpoint that accepts long URLs and stores them, and a GET endpoint that resolves short codes back to their original URLs with a 302 redirect.

In this step, get ready to:
  • Create a Base62 encoding utility that generates collision-free short codes.
  • Build a POST /api/shorten endpoint that accepts URLs and returns short codes.
  • Build a GET /:shortCode endpoint that redirects visitors to the original URL.
Implement the Base62 encoding utility

Every URL shortener needs a way to turn a long URL into a compact code. Your approach uses Base62 encoding on the auto-increment database ID, which guarantees every short code is unique without needing collision checks.

What is Base62 encoding?

Base62 uses 62 characters (0-9, a-z, A-Z) to represent numbers compactly. A 7-character Base62 string can represent over 3.5 trillion unique values.

Since each database row gets a unique auto-increment ID, encoding that ID to Base62 produces a unique short code by construction. No randomness, no collisions, no lookups needed.

  • Create the folder src/utils and a new file src/utils/base62.js with the following code:
const CHARSET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

// Convert a numeric ID to a Base62 string
function encodeBase62(num) {
  if (num === 0) return CHARSET[0];
  let result = '';
  while (num > 0) {
    result = CHARSET[num % 62] + result;
    num = Math.floor(num / 62);
  }
  return result;
}

// Convert a Base62 string back to a number
function decodeBase62(str) {
  let result = 0;
  for (const char of str) {
    result = result * 62 + CHARSET.indexOf(char);
  }
  return result;
}

module.exports = { encodeBase62, decodeBase62 };

What does this code do?

encodeBase62(num) repeatedly divides the number by 62 and maps each remainder to a character in the charset. The result is a compact alphanumeric string.

decodeBase62(str) reverses the process, turning a Base62 string back into its original number. This is useful for debugging or future features like URL analytics lookups.

  • Save src/utils/base62.js.
  • Verify the encoding works by running this command from your url-shortener folder:
node -e "const {encodeBase62} = require('./src/utils/base62'); console.log(encodeBase62(1000), encodeBase62(62))"

You should see g8 10 printed in your terminal.

Seeing "Cannot find module" error?

Make sure you created the utils folder inside src, and that the file is named base62.js exactly. Double check you are running the command from the url-shortener root directory, not from inside src.

Help me debug my Base62 module import error

✔️ Awesome, I've got everything!

Great. Make sure src/utils/base62.js is saved before moving on.

ⓧ I'd like to double check the full code

const CHARSET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

function encodeBase62(num) {
  if (num === 0) return CHARSET[0];
  let result = '';
  while (num > 0) {
    result = CHARSET[num % 62] + result;
    num = Math.floor(num / 62);
  }
  return result;
}

function decodeBase62(str) {
  let result = 0;
  for (const char of str) {
    result = result * 62 + CHARSET.indexOf(char);
  }
  return result;
}

module.exports = { encodeBase62, decodeBase62 };
Implement POST /api/shorten endpoint

Before building the route itself, you need two helper modules: a PostgreSQL connection pool and a Redis client. These encapsulate all database and cache configuration in one place so your routes stay clean.

  • Create a new file src/db.js with the following code:
const { Pool } = require('pg');

// Configure a connection pool for PostgreSQL
function createPool() {
  return new Pool({
    connectionString: process.env.DATABASE_URL,
    max: 20,
    idleTimeoutMillis: 30000,
    connectionTimeoutMillis: 2000,
  });
}

module.exports = { createPool };

What does this code do?

A connection pool keeps a set of reusable database connections open instead of opening a new one for every request. max: 20 means up to 20 connections can be active simultaneously.

idleTimeoutMillis closes idle connections after 30 seconds to free resources. connectionTimeoutMillis fails fast (2 seconds) if PostgreSQL is unreachable.

  • Save src/db.js.
  • Create a new file src/redis.js with the following code:
const Redis = require('ioredis');

// Create a Redis client with retry logic and event listeners
function createRedisClient() {
  const redis = new Redis(process.env.REDIS_URL, {
    maxRetriesPerRequest: 3,
    retryStrategy(times) {
      if (times > 3) return null;
      return Math.min(times * 200, 2000);
    },
  });

  redis.on('error', (err) => {
    console.warn('Redis connection error:', err.message);
  });

  redis.on('connect', () => {
    console.log('Connected to Redis');
  });

  return redis;
}

module.exports = { createRedisClient };

What does this code do?

maxRetriesPerRequest: 3 limits how many times a single Redis command retries before giving up. The retryStrategy function uses exponential backoff (200ms, 400ms, 600ms) then stops reconnecting after 3 attempts.

The event listeners log connection status so you can see in your container logs whether Redis is reachable. If Redis goes down, the client emits errors instead of crashing the process.

  • Save src/redis.js.
  • You will not see a change yet. The shorten route below uses both of these modules.

✔️ Awesome, I've got everything!

Great. Confirm both src/db.js and src/redis.js are saved.

ⓧ I'd like to double check the full code

const { Pool } = require('pg');

function createPool() {
  return new Pool({
    connectionString: process.env.DATABASE_URL,
    max: 20,
    idleTimeoutMillis: 30000,
    connectionTimeoutMillis: 2000,
  });
}

module.exports = { createPool };
const Redis = require('ioredis');

function createRedisClient() {
  const redis = new Redis(process.env.REDIS_URL, {
    maxRetriesPerRequest: 3,
    retryStrategy(times) {
      if (times > 3) return null;
      return Math.min(times * 200, 2000);
    },
  });

  redis.on('error', (err) => {
    console.warn('Redis connection error:', err.message);
  });

  redis.on('connect', () => {
    console.log('Connected to Redis');
  });

  return redis;
}

module.exports = { createRedisClient };

Now you can build the shorten route that uses both the pool and the Redis client.

  • Create the folder src/routes and a new file src/routes/shorten.js with the following code:
const { Router } = require('express');
const { body, validationResult } = require('express-validator');
const { encodeBase62 } = require('../utils/base62');

function createShortenRouter(pool, redis) {
  const router = Router();

  router.post(
    '/shorten',
    [
      body('url')
        .isURL({ require_protocol: true })
        .withMessage('A valid URL with protocol is required'),
    ],
    async (req, res) => {
      const errors = validationResult(req);
      if (!errors.isEmpty()) {
        return res.status(400).json({ errors: errors.array() });
      }

      const { url } = req.body;

      try {
        // Insert with a placeholder short_code to get the auto-increment ID
        const result = await pool.query(
          'INSERT INTO urls (short_code, original_url) VALUES ($1, $2) RETURNING id',
          ['placeholder', url]
        );

        const id = result.rows[0].id;
        const shortCode = encodeBase62(id);

        // Update the row with the real Base62 short code
        await pool.query('UPDATE urls SET short_code = $1 WHERE id = $2', [
          shortCode,
          id,
        ]);

        // Cache the mapping in Redis with a 24-hour TTL
        try {
          await redis.set(`url:${shortCode}`, url, 'EX', 86400);
        } catch (cacheErr) {
          console.warn('Redis write failed:', cacheErr.message);
        }

        const shortUrl = `${process.env.BASE_URL}/${shortCode}`;
        res.status(201).json({ shortCode, shortUrl, originalUrl: url });
      } catch (err) {
        console.error('Shorten error:', err.message);
        res.status(500).json({ error: 'Internal server error' });
      }
    }
  );

  return router;
}

module.exports = { createShortenRouter };

What does this code do?

The route uses express-validator to reject malformed URLs before they hit the database. Only URLs with a protocol (http:// or https://) pass validation.

The insert-then-update pattern is intentional. You insert with a placeholder short code to get the auto-increment ID, then encode that ID to Base62 and update the row. This guarantees every short code is unique without needing collision checks.

The Redis write uses a try/catch so that if Redis is down, the API still returns a valid response. The 24-hour TTL (86400 seconds) means cached entries expire automatically.

Why parameterized queries?

The $1 and $2 placeholders prevent SQL injection. PostgreSQL handles escaping automatically when you pass values as an array parameter instead of interpolating them into the query string.

  • Save src/routes/shorten.js.
  • You will not see a change yet. The entry point file in the next substep wires this route into the Express app.

Syntax error in shorten.js?

This file has nested callbacks and try/catch blocks. Check that every opening brace { has a matching close brace }, and that the router.post() call ends with a closing parenthesis after the async handler.

Help me find the syntax error in my shorten.js route file

✔️ Awesome, I've got everything!

Great. Confirm src/routes/shorten.js is saved and move on.

ⓧ I'd like to double check the full code

const { Router } = require('express');
const { body, validationResult } = require('express-validator');
const { encodeBase62 } = require('../utils/base62');

function createShortenRouter(pool, redis) {
  const router = Router();

  router.post(
    '/shorten',
    [
      body('url')
        .isURL({ require_protocol: true })
        .withMessage('A valid URL with protocol is required'),
    ],
    async (req, res) => {
      const errors = validationResult(req);
      if (!errors.isEmpty()) {
        return res.status(400).json({ errors: errors.array() });
      }

      const { url } = req.body;

      try {
        const result = await pool.query(
          'INSERT INTO urls (short_code, original_url) VALUES ($1, $2) RETURNING id',
          ['placeholder', url]
        );

        const id = result.rows[0].id;
        const shortCode = encodeBase62(id);

        await pool.query('UPDATE urls SET short_code = $1 WHERE id = $2', [
          shortCode,
          id,
        ]);

        try {
          await redis.set(`url:${shortCode}`, url, 'EX', 86400);
        } catch (cacheErr) {
          console.warn('Redis write failed:', cacheErr.message);
        }

        const shortUrl = `${process.env.BASE_URL}/${shortCode}`;
        res.status(201).json({ shortCode, shortUrl, originalUrl: url });
      } catch (err) {
        console.error('Shorten error:', err.message);
        res.status(500).json({ error: 'Internal server error' });
      }
    }
  );

  return router;
}

module.exports = { createShortenRouter };
Implement GET /:shortCode redirect endpoint

The redirect endpoint is the hot path of your service. Every time someone clicks a short link, this handler looks up the original URL and sends the browser there with an HTTP 302 response.

Why 302 instead of 301?

A 301 (permanent redirect) tells browsers to cache the redirect locally. Future clicks would never reach your server, which means you lose analytics visibility.

A 302 (temporary redirect) ensures every click hits the server so you can track click_count accurately. For a URL shortener that needs analytics, 302 is the correct choice.

  • Create a new file src/routes/redirect.js with the following code:
const { Router } = require('express');

function createRedirectRouter(pool, redis) {
  const router = Router();

  router.get('/:shortCode', async (req, res) => {
    const { shortCode } = req.params;

    // Reject obviously invalid short codes early
    if (shortCode.length > 10 || !/^[0-9a-zA-Z]+$/.test(shortCode)) {
      return res.status(404).json({ error: 'Not found' });
    }

    // Try Redis cache first
    try {
      const cached = await redis.get(`url:${shortCode}`);
      if (cached) {
        console.log(`Cache HIT: ${shortCode}`);
        pool.query(
          'UPDATE urls SET click_count = click_count + 1 WHERE short_code = $1',
          [shortCode]
        ).catch((err) => console.error('Click count update failed:', err.message));
        return res.redirect(302, cached);
      }
      console.log(`Cache MISS: ${shortCode}`);
    } catch (cacheErr) {
      console.warn('Redis read failed:', cacheErr.message);
    }

    // Fall back to PostgreSQL on cache miss
    try {
      const result = await pool.query(
        'SELECT original_url FROM urls WHERE short_code = $1',
        [shortCode]
      );

      if (result.rows.length === 0) {
        return res.status(404).json({ error: 'Short URL not found' });
      }

      const originalUrl = result.rows[0].original_url;

      // Backfill the cache for next time
      try {
        await redis.set(`url:${shortCode}`, originalUrl, 'EX', 86400);
      } catch (cacheErr) {
        console.warn('Redis backfill failed:', cacheErr.message);
      }

      pool.query(
        'UPDATE urls SET click_count = click_count + 1 WHERE short_code = $1',
        [shortCode]
      ).catch((err) => console.error('Click count update failed:', err.message));

      return res.redirect(302, originalUrl);
    } catch (err) {
      console.error('Redirect error:', err.message);
      res.status(500).json({ error: 'Internal server error' });
    }
  });

  return router;
}

module.exports = { createRedirectRouter };

What does this code do?

The handler checks Redis first with the key url:{shortCode}. On a cache hit, it redirects immediately without touching PostgreSQL. On a cache miss, it queries the database and backfills the cache for next time.

The click count update uses a fire-and-forget pattern (.catch() without await). This keeps the redirect response fast because the user does not wait for the analytics write to complete.

  • Save src/routes/redirect.js.
  • You will not see a change yet. The next file wires this route into the app.

✔️ Awesome, I've got everything!

Great. Confirm src/routes/redirect.js is saved.

ⓧ I'd like to double check the full code

const { Router } = require('express');

function createRedirectRouter(pool, redis) {
  const router = Router();

  router.get('/:shortCode', async (req, res) => {
    const { shortCode } = req.params;

    if (shortCode.length > 10 || !/^[0-9a-zA-Z]+$/.test(shortCode)) {
      return res.status(404).json({ error: 'Not found' });
    }

    try {
      const cached = await redis.get(`url:${shortCode}`);
      if (cached) {
        console.log(`Cache HIT: ${shortCode}`);
        pool.query(
          'UPDATE urls SET click_count = click_count + 1 WHERE short_code = $1',
          [shortCode]
        ).catch((err) => console.error('Click count update failed:', err.message));
        return res.redirect(302, cached);
      }
      console.log(`Cache MISS: ${shortCode}`);
    } catch (cacheErr) {
      console.warn('Redis read failed:', cacheErr.message);
    }

    try {
      const result = await pool.query(
        'SELECT original_url FROM urls WHERE short_code = $1',
        [shortCode]
      );

      if (result.rows.length === 0) {
        return res.status(404).json({ error: 'Short URL not found' });
      }

      const originalUrl = result.rows[0].original_url;

      try {
        await redis.set(`url:${shortCode}`, originalUrl, 'EX', 86400);
      } catch (cacheErr) {
        console.warn('Redis backfill failed:', cacheErr.message);
      }

      pool.query(
        'UPDATE urls SET click_count = click_count + 1 WHERE short_code = $1',
        [shortCode]
      ).catch((err) => console.error('Click count update failed:', err.message));

      return res.redirect(302, originalUrl);
    } catch (err) {
      console.error('Redirect error:', err.message);
      res.status(500).json({ error: 'Internal server error' });
    }
  });

  return router;
}

module.exports = { createRedirectRouter };

Now you need a health check route and the main entry point that wires everything together.

  • Create a new file src/routes/health.js with the following code:
const { Router } = require('express');

function createHealthRouter(pool, redis) {
  const router = Router();

  // Report the connection status of both dependencies
  router.get('/health', async (req, res) => {
    const health = { status: 'ok', postgres: 'unknown', redis: 'unknown' };

    try {
      await pool.query('SELECT 1');
      health.postgres = 'connected';
    } catch (err) {
      health.postgres = 'disconnected';
      health.status = 'degraded';
    }

    try {
      await redis.ping();
      health.redis = 'connected';
    } catch (err) {
      health.redis = 'disconnected';
      health.status = 'degraded';
    }

    const statusCode = health.status === 'ok' ? 200 : 503;
    res.status(statusCode).json(health);
  });

  return router;
}

module.exports = { createHealthRouter };

What does this code do?

The health endpoint pings both PostgreSQL and Redis. If either is unreachable, the status changes to degraded and the HTTP response code becomes 503.

Load balancers use health endpoints to decide whether to route traffic to a container. When you deploy to AWS later, the ALB will poll this endpoint to check if your service is ready.

  • Save src/routes/health.js.
  • Create the main entry point file src/index.js with the following code:
require('dotenv').config();
const express = require('express');
const { createPool } = require('./db');
const { createRedisClient } = require('./redis');
const { createShortenRouter } = require('./routes/shorten');
const { createRedirectRouter } = require('./routes/redirect');
const { createHealthRouter } = require('./routes/health');
const rateLimit = require('express-rate-limit');

const app = express();
const PORT = process.env.PORT || 3000;

// Parse JSON request bodies
app.use(express.json());

// Rate limit the API to 100 requests per 15-minute window
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
});
app.use('/api/', limiter);

// Initialize database pool and Redis client
const pool = createPool();
const redis = createRedisClient();

// Mount routes
app.use('/api', createShortenRouter(pool, redis));
app.use('/', createHealthRouter(pool, redis));
app.use('/', createRedirectRouter(pool, redis));

async function start() {
  try {
    const client = await pool.connect();
    await client.query('SELECT 1');
    client.release();
    console.log('Connected to PostgreSQL');
  } catch (err) {
    console.error('Failed to connect to PostgreSQL:', err.message);
    process.exit(1);
  }

  app.listen(PORT, () => {
    console.log(`URL Shortener running on port ${PORT}`);
  });
}

start();

What does this code do?

This is the application entry point that wires every module together. It creates the database pool and Redis client, then passes them into each route factory function.

The rate limiter protects the /api/ path with a maximum of 100 requests per 15-minute window per IP. The start() function verifies PostgreSQL is reachable before accepting traffic.

Route mounting order matters. The health router is mounted before the redirect router because /:shortCode is a catch-all pattern that would match /health if it came first.

  • Save src/index.js.
  • Rebuild and restart your Docker Compose stack by running:
docker compose up --build

You should see Connected to PostgreSQL and Connected to Redis in the logs, followed by URL Shortener running on port 3000.

  • Open a new terminal tab and test the shorten endpoint by running:
curl -X POST http://localhost:3000/api/shorten -H "Content-Type: application/json" -d '{"url":"https://example.com"}'

You should see a JSON response containing a shortCode, shortUrl, and originalUrl field.

  • Test the redirect endpoint by visiting the shortUrl from the response in your browser.

Your browser should redirect to https://example.com. In the Docker Compose logs, you should see a Cache HIT message because the shorten route already cached the mapping.

Not getting a response from localhost:3000?

Check the Docker Compose logs for error messages. A common cause is a typo in one of the require() paths in src/index.js. Make sure every path matches the actual file location (./db, ./redis, ./routes/shorten, etc.).

If the container exits immediately, look for Failed to connect to PostgreSQL in the logs. This means the database container is not ready yet. Run docker compose down and then docker compose up --build again to let the health check complete.

Help me debug why my URL shortener container is not responding on port 3000

✔️ Awesome, I've got everything!

Great. Confirm both src/routes/health.js and src/index.js are saved.

ⓧ I'd like to double check the full code

const { Router } = require('express');

function createHealthRouter(pool, redis) {
  const router = Router();

  router.get('/health', async (req, res) => {
    const health = { status: 'ok', postgres: 'unknown', redis: 'unknown' };

    try {
      await pool.query('SELECT 1');
      health.postgres = 'connected';
    } catch (err) {
      health.postgres = 'disconnected';
      health.status = 'degraded';
    }

    try {
      await redis.ping();
      health.redis = 'connected';
    } catch (err) {
      health.redis = 'disconnected';
      health.status = 'degraded';
    }

    const statusCode = health.status === 'ok' ? 200 : 503;
    res.status(statusCode).json(health);
  });

  return router;
}

module.exports = { createHealthRouter };
require('dotenv').config();
const express = require('express');
const { createPool } = require('./db');
const { createRedisClient } = require('./redis');
const { createShortenRouter } = require('./routes/shorten');
const { createRedirectRouter } = require('./routes/redirect');
const { createHealthRouter } = require('./routes/health');
const rateLimit = require('express-rate-limit');

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
});
app.use('/api/', limiter);

const pool = createPool();
const redis = createRedisClient();

app.use('/api', createShortenRouter(pool, redis));
app.use('/', createHealthRouter(pool, redis));
app.use('/', createRedirectRouter(pool, redis));

async function start() {
  try {
    const client = await pool.connect();
    await client.query('SELECT 1');
    client.release();
    console.log('Connected to PostgreSQL');
  } catch (err) {
    console.error('Failed to connect to PostgreSQL:', err.message);
    process.exit(1);
  }

  app.listen(PORT, () => {
    console.log(`URL Shortener running on port ${PORT}`);
  });
}

start();

Your core API is working. URLs go in, short codes come out, and visitors get redirected. Next up, you will add a Redis caching layer that makes the redirect path blazing fast by serving repeat lookups from memory instead of hitting the database every time.

Add Redis Caching with the Cache-Aside Pattern

Your URL shortener works, but every redirect hits PostgreSQL directly. For a URL shortener in production, reads outnumber writes by roughly 100:1.

In this step, you will implement the cache-aside pattern. The API checks Redis first on every redirect. On a cache hit, the response returns instantly without touching the database. On a miss, it queries PostgreSQL, stores the result in Redis for next time, then redirects.

In this step, get ready to:
  • Implement the cache-aside read path in your redirect handler.
  • Verify write-through on create populates the cache immediately.
  • Add graceful Redis degradation so the API never crashes if the cache goes down.
Implement the cache-aside read path

The cache-aside pattern works in three steps: check the cache, fall back to the database on a miss, then populate the cache for next time. Your redirect handler needs this logic so repeated visits to the same short URL skip the database entirely.

  • In your editor, open src/routes/redirect.js.
  • Replace the entire file contents with the following code:
const { Router } = require('express');

function createRedirectRouter(pool, redis) {
  const router = Router();

  router.get('/:shortCode', async (req, res) => {
    const { shortCode } = req.params;

    if (shortCode.length > 10 || !/^[0-9a-zA-Z]+$/.test(shortCode)) {
      return res.status(404).json({ error: 'Not found' });
    }

    // Step 1: Check Redis cache first
    try {
      const cached = await redis.get(`url:${shortCode}`);
      if (cached) {
        console.log(`Cache HIT: ${shortCode}`);
        // Fire-and-forget click count update
        pool.query(
          'UPDATE urls SET click_count = click_count + 1 WHERE short_code = $1',
          [shortCode]
        ).catch((err) => console.error('Click count update failed:', err.message));
        return res.redirect(302, cached);
      }
      console.log(`Cache MISS: ${shortCode}`);
    } catch (cacheErr) {
      // If Redis fails, fall through to PostgreSQL
      console.warn('Redis read failed:', cacheErr.message);
    }

    // Step 2: Cache miss - query PostgreSQL
    try {
      const result = await pool.query(
        'SELECT original_url FROM urls WHERE short_code = $1',
        [shortCode]
      );

      if (result.rows.length === 0) {
        return res.status(404).json({ error: 'Short URL not found' });
      }

      const originalUrl = result.rows[0].original_url;

      // Step 3: Backfill the cache with 24-hour TTL
      try {
        await redis.set(`url:${shortCode}`, originalUrl, 'EX', 86400);
      } catch (cacheErr) {
        console.warn('Redis backfill failed:', cacheErr.message);
      }

      pool.query(
        'UPDATE urls SET click_count = click_count + 1 WHERE short_code = $1',
        [shortCode]
      ).catch((err) => console.error('Click count update failed:', err.message));

      return res.redirect(302, originalUrl);
    } catch (err) {
      console.error('Redirect error:', err.message);
      res.status(500).json({ error: 'Internal server error' });
    }
  });

  return router;
}

module.exports = { createRedirectRouter };

What does this code do?

This is the cache-aside pattern in action. Each redirect request follows a three-step flow:

  • redis.get(`url:${shortCode}`) checks the cache. On a hit, the response goes out immediately without touching PostgreSQL.
  • On a miss, the handler queries PostgreSQL for the original URL and stores it in Redis with a 24-hour TTL (86400 seconds) so the next request hits the cache.
  • The click count update uses a fire-and-forget pattern (.catch() without await) so it never blocks the redirect response.

Why 302 instead of 301?

HTTP 302 (temporary redirect) ensures every click hits the server for analytics tracking. A 301 (permanent redirect) would be cached by the browser, meaning repeat visits would never reach your server and click counts would stop incrementing.

  • Save src/routes/redirect.js.
  • Rebuild and restart the containers by running:
docker compose up --build -d
  • Create a short URL to test with by running:
curl -X POST http://localhost:3000/api/shorten -H "Content-Type: application/json" -d '{"url":"https://example.com"}'
  • Copy the shortCode value from the JSON response.
  • Test the redirect and check the logs by running (replace g8 with your actual short code):
curl -I http://localhost:3000/g8
  • Check the app container logs by running:
docker compose logs app --tail 10
  • You should see Cache HIT in the logs. This is because the shorten endpoint already wrote the URL to Redis when you created it.

Seeing Cache MISS instead?

  • Make sure the Redis container is running. Run docker compose ps and confirm redis shows as Up.
  • Check that you created the short URL AFTER rebuilding the containers. Older URLs created before the Redis write-through code was added will not be in the cache yet.

Help me debug why my Redis cache is not returning hits.

Verify write-through on create

The cache-aside pattern handles cache misses gracefully. But there is an optimization already built into your shorten endpoint: it writes the URL to Redis immediately on create, so the very first redirect is a cache hit too.

  • In src/routes/shorten.js, find this block inside the try block:
try {
  await redis.set(`url:${shortCode}`, url, 'EX', 86400);
} catch (cacheErr) {
  console.warn('Redis write failed:', cacheErr.message);
}

What is write-through on create?

Without this, the first person to click a brand-new short URL would always get a cache miss (slower PostgreSQL lookup). By writing to Redis at creation time, even the first redirect is instant. This is the "write-through on create" optimization.

  • Test this by creating a fresh short URL and immediately redirecting. Run:
curl -X POST http://localhost:3000/api/shorten -H "Content-Type: application/json" -d '{"url":"https://github.com"}'
  • Immediately redirect using the short code from the response:
curl -I http://localhost:3000/g8
  • Check the logs by running:
docker compose logs app --tail 5
  • You should see Cache HIT on the very first redirect. No miss required.
Test graceful Redis degradation

A cache is supposed to speed things up, not become a single point of failure. If Redis goes down, your API should keep working by falling back to PostgreSQL. Every Redis call in your redirect handler is wrapped in a try/catch block that logs a warning instead of crashing.

  • Stop the Redis container to simulate an outage by running:
docker compose stop redis
  • Try shortening a new URL without Redis running:
curl -X POST http://localhost:3000/api/shorten -H "Content-Type: application/json" -d '{"url":"https://nodejs.org"}'
  • You should still get a successful JSON response with a shortCode. The API did not crash.
  • Test a redirect without Redis by running (use the short code from the response above):
curl -I http://localhost:3000/g8
  • You should still see the 302 redirect with a Location header pointing to the original URL. The app fell back to PostgreSQL successfully.
  • Check the logs for the degradation warning by running:
docker compose logs app --tail 10
  • You should see warning messages like Redis read failed or Redis write failed in the logs, but no crash. The service stayed up.

Why graceful degradation matters

In production, Redis can go down for maintenance, network blips, or memory pressure. The try/catch wrappers ensure your users still get redirected (just slightly slower) instead of seeing a 500 error. This is a key point in system design interviews: your cache should improve performance, never become a hard dependency.

  • Bring Redis back up by running:
docker compose start redis
  • Confirm everything is healthy by running:
curl http://localhost:3000/health
  • You should see "status":"ok" with both postgres and redis showing "connected".

API crashing when Redis is stopped?

  • Make sure every redis.get() and redis.set() call is inside its own try/catch block. A single unwrapped Redis call will throw and crash the handler.
  • Check that your src/redis.js has maxRetriesPerRequest: 3 set. Without this, ioredis retries forever and the request hangs.

Help me figure out why my API crashes when Redis goes down.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/routes/redirect.js and that your Docker Compose stack is running with all three services up.

ⓧ I'd like to double check the full code

Here is the complete src/routes/redirect.js file with the full cache-aside pattern:

const { Router } = require('express');

function createRedirectRouter(pool, redis) {
  const router = Router();

  router.get('/:shortCode', async (req, res) => {
    const { shortCode } = req.params;

    if (shortCode.length > 10 || !/^[0-9a-zA-Z]+$/.test(shortCode)) {
      return res.status(404).json({ error: 'Not found' });
    }

    try {
      const cached = await redis.get(`url:${shortCode}`);
      if (cached) {
        console.log(`Cache HIT: ${shortCode}`);
        pool.query(
          'UPDATE urls SET click_count = click_count + 1 WHERE short_code = $1',
          [shortCode]
        ).catch((err) => console.error('Click count update failed:', err.message));
        return res.redirect(302, cached);
      }
      console.log(`Cache MISS: ${shortCode}`);
    } catch (cacheErr) {
      console.warn('Redis read failed:', cacheErr.message);
    }

    try {
      const result = await pool.query(
        'SELECT original_url FROM urls WHERE short_code = $1',
        [shortCode]
      );

      if (result.rows.length === 0) {
        return res.status(404).json({ error: 'Short URL not found' });
      }

      const originalUrl = result.rows[0].original_url;

      try {
        await redis.set(`url:${shortCode}`, originalUrl, 'EX', 86400);
      } catch (cacheErr) {
        console.warn('Redis backfill failed:', cacheErr.message);
      }

      pool.query(
        'UPDATE urls SET click_count = click_count + 1 WHERE short_code = $1',
        [shortCode]
      ).catch((err) => console.error('Click count update failed:', err.message));

      return res.redirect(302, originalUrl);
    } catch (err) {
      console.error('Redirect error:', err.message);
      res.status(500).json({ error: 'Internal server error' });
    }
  });

  return router;
}

module.exports = { createRedirectRouter };

Your caching layer is complete. The redirect path checks Redis first, falls back to PostgreSQL on a miss, and stays fully functional even if Redis disappears. Next up, you will create portfolio-ready documentation that showcases this architecture to interviewers.

Create the Portfolio Documentation

Your URL shortener is fully functional with caching, graceful degradation, and three working endpoints. Now it is time to make this project stand out in your portfolio and in interviews.

A working API is impressive, but interviewers want to see that you can articulate why you made specific design choices. In this step, you will write a system design document and a polished README that demonstrate your architectural thinking.

In this step, get ready to:
  • Write a DESIGN.md with architecture diagrams, sequence diagrams, and schema documentation.
  • Add capacity estimates and scaling strategies for a Bitly-scale system.
  • Create a README.md with setup instructions, API reference, and design decisions.
Write DESIGN.md with architecture overview and Mermaid diagrams

System design documents show that you think beyond code. This document will cover requirements, architecture, the redirect flow, your database schema, and your encoding strategy.

  • Create a new file called DESIGN.md in the root of your url-shortener folder (same level as package.json).
  • Paste the following content into DESIGN.md:
# System Design: URL Shortener

## 1. Requirements

### Functional
- Given a long URL, generate a unique short URL
- Given a short URL, redirect to the original (HTTP 302)
- Track click count per short URL
- Short codes are 1-7 characters, Base62 encoded

### Non-Functional
- High availability (service degrades gracefully, does not crash)
- Low-latency redirects (sub-10ms with cache hit)
- Read-heavy workload (100:1 read-to-write ratio)
- Scalable to millions of URLs

## 2. Architecture

```mermaid
graph TB
    Client[Client/Browser]
    ALB[AWS ALB]
    ECS1[ECS Fargate Task]
    ECS2[ECS Fargate Task]
    Redis[(ElastiCache Redis)]
    PG[(RDS PostgreSQL)]

    Client -->|HTTPS| ALB
    ALB --> ECS1
    ALB --> ECS2
    ECS1 -->|Cache lookup| Redis
    ECS2 -->|Cache lookup| Redis
    ECS1 -->|Persistent storage| PG
    ECS2 -->|Persistent storage| PG
```

## 3. Redirect Sequence Diagram

```mermaid
sequenceDiagram
    participant C as Client
    participant ALB as Load Balancer
    participant App as ECS Task
    participant R as Redis
    participant DB as PostgreSQL

    C->>ALB: GET /abc123
    ALB->>App: Forward request
    App->>R: GET url:abc123
    alt Cache HIT
        R-->>App: https://original-url.com
        App-->>C: 302 Redirect
    else Cache MISS
        R-->>App: null
        App->>DB: SELECT original_url WHERE short_code='abc123'
        DB-->>App: https://original-url.com
        App->>R: SET url:abc123 (TTL 24h)
        App-->>C: 302 Redirect
    end
    Note over App,DB: Click count update is fire-and-forget (async)
```

## 4. Database Schema

```sql
CREATE TABLE urls (
    id BIGSERIAL PRIMARY KEY,
    short_code VARCHAR(10) NOT NULL UNIQUE,
    original_url TEXT NOT NULL,
    click_count INTEGER DEFAULT 0,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_urls_short_code ON urls (short_code);
```

Index strategy: The unique index on `short_code` provides O(log n) lookups. At 1 billion rows, this index fits in ~8 GB of memory. PostgreSQL's B-tree index handles this efficiently.

## 5. Short Code Generation

Algorithm: Base62 encoding of the auto-increment database ID.

- Character set: `0-9a-zA-Z` (62 characters)
- 7-character code capacity: 62^7 = 3.5 trillion unique URLs
- Collision-free by construction (every ID is unique)
- Trade-off: Sequential codes are guessable. Production mitigation: use format-preserving encryption (FPE) on the counter for unpredictability while maintaining bijectivity.

What does this document cover so far?

  • Sections 1-2 define what the system does and how the components connect.
  • Section 3 shows the exact request flow for a redirect, including the cache HIT and MISS paths.
  • Section 4 documents the schema and explains why the B-tree index on short_code is efficient.
  • Section 5 explains why Base62 on auto-increment IDs guarantees zero collisions.
  • Save DESIGN.md.
  • Open the Markdown preview by pressing Cmd+Shift+V (macOS) or Ctrl+Shift+V (Windows).
  • You should see the Mermaid architecture diagram rendered as a visual flowchart, and the sequence diagram showing the cache HIT/MISS paths.

Diagrams showing as raw text?

VS Code has built-in Mermaid rendering in the Markdown preview. Make sure you are viewing the Markdown preview (not the raw file).

If you are using an older version of VS Code, update to the latest version or search for a Mermaid preview extension in the Extensions panel.

Help me fix my Mermaid diagrams

Add capacity estimates and scaling discussion

Interviewers love back-of-envelope math. This section shows you can reason about a system at Bitly scale (100 million monthly active users).

  • Open DESIGN.md and add the following content after the Short Code Generation section:
## 6. Capacity Estimates (Bitly-Scale: 100M MAU)

| Metric | Estimate |
|--------|----------|
| New URLs/month | 100M users x 0.1 URLs/user = 10M |
| Writes/sec (avg) | 10M / 30 days / 86400 sec = ~3.8 |
| Writes/sec (peak, 5x) | ~19 |
| Reads/sec (avg, 100:1) | ~380 |
| Reads/sec (peak, 10x) | ~3,800 |
| Storage/year | 10M x 12 x 500 bytes = ~60 GB |
| Storage/5 years | ~300 GB |
| Cache memory (20% hot) | 12M URLs x 500 bytes = ~6 GB |

## 7. Scaling Strategies

### Database Scaling
- Vertical: Scale to larger RDS instance (up to db.r6g.16xlarge)
- Read replicas: Route redirect lookups to read replicas
- Sharding: Partition by short_code hash when single-node capacity is exceeded
- Connection pooling: PgBouncer in front of RDS for connection multiplexing

### Cache Scaling
- Redis Cluster: Consistent hashing across multiple nodes
- Hot key handling: Local in-memory LRU cache for viral URLs (prevents Redis hotspot)
- TTL tuning: Longer TTL for frequently accessed URLs, shorter for long-tail

### Application Scaling
- ECS auto-scaling: Scale tasks based on CPU/request count
- Stateless design: Any task can handle any request
- CDN layer: CloudFront edge caching for redirects (with short max-age)

### Failure Handling
- Redis down: Application falls back to PostgreSQL (graceful degradation)
- Database down: Return 503 (service unavailable), no data loss
- Task crash: ECS auto-restarts, ALB health checks route away from unhealthy tasks

## 8. Design Decisions

| Decision | Choice | Alternative | Why |
|----------|--------|-------------|-----|
| Encoding | Base62 on auto-increment ID | MD5 hash + truncation | Zero collisions, simpler, shorter codes |
| Redirect | HTTP 302 | HTTP 301 | 302 ensures every click hits server for analytics |
| Database | PostgreSQL | DynamoDB | Schema flexibility, index discussion, SQL familiarity |
| Cache | Cache-aside | Write-through | Tolerates Redis failure, simpler invalidation |
| ID source | Single DB sequence | Distributed counter (Snowflake) | Sufficient for project scale, avoids coordination complexity |

## 9. Security Considerations

- Input validation: URL format validation with express-validator
- Rate limiting: 100 requests/15 min per IP via express-rate-limit
- SQL injection: Parameterized queries throughout
- HTTPS: Terminated at ALB level (ACM certificate)
- Environment variables: Secrets in environment, never in code
- Short code enumeration: Acceptable risk at this scale; mitigate with FPE at production scale

## 10. What I Would Add for Production

- Kafka/SQS for async click analytics (decouple from redirect path)
- CloudWatch dashboards for latency percentiles and cache hit rates
- WAF on the ALB for bot/abuse protection
- Custom domain with Route 53 and ACM certificate
- Blue/green deployment via ECS with CodeDeploy
- Distributed ID generation (Snowflake) if sharding the database

Why do these numbers matter in interviews?

Capacity estimates prove you can reason about scale without guessing. The key insight here is that a URL shortener is extremely read-heavy (100:1 ratio), which is why the cache layer is critical.

The scaling strategies section shows you can identify the next bottleneck before it happens. Each strategy maps to a specific capacity threshold.

  • Save DESIGN.md.
  • Check the Markdown preview again. You should see the capacity table rendered with clean alignment and the design decisions table with four columns.

Tables not rendering?

Make sure each table row has the correct number of pipe characters matching the header. A missing pipe will break the table rendering.

Help me fix my markdown tables

✔️ Awesome, I've got everything!

Great. Double check you have saved DESIGN.md with all 10 sections.

ⓧ I'd like to double check the full code

Here is the complete DESIGN.md file:

# System Design: URL Shortener

## 1. Requirements

### Functional
- Given a long URL, generate a unique short URL
- Given a short URL, redirect to the original (HTTP 302)
- Track click count per short URL
- Short codes are 1-7 characters, Base62 encoded

### Non-Functional
- High availability (service degrades gracefully, does not crash)
- Low-latency redirects (sub-10ms with cache hit)
- Read-heavy workload (100:1 read-to-write ratio)
- Scalable to millions of URLs

## 2. Architecture

```mermaid
graph TB
    Client[Client/Browser]
    ALB[AWS ALB]
    ECS1[ECS Fargate Task]
    ECS2[ECS Fargate Task]
    Redis[(ElastiCache Redis)]
    PG[(RDS PostgreSQL)]

    Client -->|HTTPS| ALB
    ALB --> ECS1
    ALB --> ECS2
    ECS1 -->|Cache lookup| Redis
    ECS2 -->|Cache lookup| Redis
    ECS1 -->|Persistent storage| PG
    ECS2 -->|Persistent storage| PG
```

## 3. Redirect Sequence Diagram

```mermaid
sequenceDiagram
    participant C as Client
    participant ALB as Load Balancer
    participant App as ECS Task
    participant R as Redis
    participant DB as PostgreSQL

    C->>ALB: GET /abc123
    ALB->>App: Forward request
    App->>R: GET url:abc123
    alt Cache HIT
        R-->>App: https://original-url.com
        App-->>C: 302 Redirect
    else Cache MISS
        R-->>App: null
        App->>DB: SELECT original_url WHERE short_code='abc123'
        DB-->>App: https://original-url.com
        App->>R: SET url:abc123 (TTL 24h)
        App-->>C: 302 Redirect
    end
    Note over App,DB: Click count update is fire-and-forget (async)
```

## 4. Database Schema

```sql
CREATE TABLE urls (
    id BIGSERIAL PRIMARY KEY,
    short_code VARCHAR(10) NOT NULL UNIQUE,
    original_url TEXT NOT NULL,
    click_count INTEGER DEFAULT 0,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_urls_short_code ON urls (short_code);
```

Index strategy: The unique index on `short_code` provides O(log n) lookups. At 1 billion rows, this index fits in ~8 GB of memory. PostgreSQL's B-tree index handles this efficiently.

## 5. Short Code Generation

Algorithm: Base62 encoding of the auto-increment database ID.

- Character set: `0-9a-zA-Z` (62 characters)
- 7-character code capacity: 62^7 = 3.5 trillion unique URLs
- Collision-free by construction (every ID is unique)
- Trade-off: Sequential codes are guessable. Production mitigation: use format-preserving encryption (FPE) on the counter for unpredictability while maintaining bijectivity.

## 6. Capacity Estimates (Bitly-Scale: 100M MAU)

| Metric | Estimate |
|--------|----------|
| New URLs/month | 100M users x 0.1 URLs/user = 10M |
| Writes/sec (avg) | 10M / 30 days / 86400 sec = ~3.8 |
| Writes/sec (peak, 5x) | ~19 |
| Reads/sec (avg, 100:1) | ~380 |
| Reads/sec (peak, 10x) | ~3,800 |
| Storage/year | 10M x 12 x 500 bytes = ~60 GB |
| Storage/5 years | ~300 GB |
| Cache memory (20% hot) | 12M URLs x 500 bytes = ~6 GB |

## 7. Scaling Strategies

### Database Scaling
- Vertical: Scale to larger RDS instance (up to db.r6g.16xlarge)
- Read replicas: Route redirect lookups to read replicas
- Sharding: Partition by short_code hash when single-node capacity is exceeded
- Connection pooling: PgBouncer in front of RDS for connection multiplexing

### Cache Scaling
- Redis Cluster: Consistent hashing across multiple nodes
- Hot key handling: Local in-memory LRU cache for viral URLs (prevents Redis hotspot)
- TTL tuning: Longer TTL for frequently accessed URLs, shorter for long-tail

### Application Scaling
- ECS auto-scaling: Scale tasks based on CPU/request count
- Stateless design: Any task can handle any request
- CDN layer: CloudFront edge caching for redirects (with short max-age)

### Failure Handling
- Redis down: Application falls back to PostgreSQL (graceful degradation)
- Database down: Return 503 (service unavailable), no data loss
- Task crash: ECS auto-restarts, ALB health checks route away from unhealthy tasks

## 8. Design Decisions

| Decision | Choice | Alternative | Why |
|----------|--------|-------------|-----|
| Encoding | Base62 on auto-increment ID | MD5 hash + truncation | Zero collisions, simpler, shorter codes |
| Redirect | HTTP 302 | HTTP 301 | 302 ensures every click hits server for analytics |
| Database | PostgreSQL | DynamoDB | Schema flexibility, index discussion, SQL familiarity |
| Cache | Cache-aside | Write-through | Tolerates Redis failure, simpler invalidation |
| ID source | Single DB sequence | Distributed counter (Snowflake) | Sufficient for project scale, avoids coordination complexity |

## 9. Security Considerations

- Input validation: URL format validation with express-validator
- Rate limiting: 100 requests/15 min per IP via express-rate-limit
- SQL injection: Parameterized queries throughout
- HTTPS: Terminated at ALB level (ACM certificate)
- Environment variables: Secrets in environment, never in code
- Short code enumeration: Acceptable risk at this scale; mitigate with FPE at production scale

## 10. What I Would Add for Production

- Kafka/SQS for async click analytics (decouple from redirect path)
- CloudWatch dashboards for latency percentiles and cache hit rates
- WAF on the ALB for bot/abuse protection
- Custom domain with Route 53 and ACM certificate
- Blue/green deployment via ECS with CodeDeploy
- Distributed ID generation (Snowflake) if sharding the database
Write the README with project overview, setup instructions, and API reference

The README is the first thing anyone sees when they open your repository. A polished README with runnable examples signals that you ship complete, professional work.

  • Create a new file called README.md in the root of your url-shortener folder.
  • Paste the following content into README.md:
# URL Shortener

A scalable URL shortening service built with Node.js, PostgreSQL, and Redis. Demonstrates production architecture patterns including cache-aside caching, Base62 encoding, graceful degradation, and containerized AWS deployment.

## Architecture

```mermaid
graph LR
    Client-->ALB[Application Load Balancer]
    ALB-->ECS1[ECS Task 1]
    ALB-->ECS2[ECS Task 2]
    ECS1-->Redis[(Redis Cache)]
    ECS2-->Redis
    ECS1-->PG[(PostgreSQL)]
    ECS2-->PG
```

## Key Features

- Base62 short code generation (collision-free, deterministic)
- Redis cache-aside pattern with 24-hour TTL
- Graceful degradation when Redis is unavailable
- Rate limiting (100 requests per 15-minute window)
- Input validation and parameterized SQL queries
- Health check endpoint with dependency status
- Docker Compose for local development
- AWS deployment with ECS Fargate, RDS, and ElastiCache

## Tech Stack

| Layer | Technology |
|-------|------------|
| Runtime | Node.js 20 LTS |
| Framework | Express.js |
| Database | PostgreSQL 16 |
| Cache | Redis 7 |
| Container | Docker, Docker Compose |
| Cloud | AWS ECS Fargate, RDS, ElastiCache, ALB |

## Quick Start (Local)

```bash
git clone <your-repo-url>
cd url-shortener
docker compose up --build
```

The API is available at http://localhost:3000.

## API Reference

### Shorten a URL

```bash
curl -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/very/long/path"}'
```

Response:
```json
{
  "shortCode": "g8",
  "shortUrl": "http://localhost:3000/g8",
  "originalUrl": "https://example.com/very/long/path"
}
```

### Redirect

```bash
curl -I http://localhost:3000/g8
# HTTP/1.1 302 Found
# Location: https://example.com/very/long/path
```

### Health Check

```bash
curl http://localhost:3000/health
```

Response:
```json
{
  "status": "ok",
  "postgres": "connected",
  "redis": "connected"
}
```

## Design Decisions

| Decision | Choice | Rationale |
|----------|--------|-----------|
| Short code generation | Base62 on auto-increment ID | Collision-free by construction, simple, interview-standard |
| Redirect status | 302 Temporary | Preserves click analytics (301 is browser-cached) |
| Cache strategy | Cache-aside with write-through on create | Tolerates Redis failures, simple invalidation |
| Database | PostgreSQL | Schema flexibility, indexing discussion, familiar SQL |

## System Design Deep Dive

See [DESIGN.md](./DESIGN.md) for the full system design document including:
- Capacity estimates for 100M MAU scale
- Scaling strategies (sharding, replication, CDN)
- Mermaid sequence diagrams
- Trade-off analysis

## Project Structure

```
url-shortener/
  src/
    index.js            # Express app entry point
    db.js               # PostgreSQL pool configuration
    redis.js            # Redis client with retry strategy
    schema.sql          # Database schema
    utils/
      base62.js         # Base62 encode/decode
    routes/
      shorten.js        # POST /api/shorten
      redirect.js       # GET /:shortCode
      health.js         # GET /health
  Dockerfile
  docker-compose.yml
  DESIGN.md
  README.md
  .env.example
```

## License

MIT

What makes this README effective?

  • The architecture diagram gives an instant visual overview without reading anything.
  • Curl examples are copy-pasteable, so anyone can test the API immediately.
  • The design decisions table shows intentional trade-offs, not just what you used.
  • Linking to DESIGN.md invites deeper reading without cluttering the README.
  • Save README.md.
  • Open the Markdown preview by pressing Cmd+Shift+V (macOS) or Ctrl+Shift+V (Windows).
  • You should see the architecture diagram rendered, the tech stack table with clean columns, and the curl examples in syntax-highlighted code blocks.

README not rendering as expected?

Check that the triple backtick fences are on their own lines with no extra spaces before them. A misplaced backtick will break everything below it.

Help me fix my README formatting

  • Confirm both files exist by running:
ls *.md
  • You should see DESIGN.md and README.md in the output.

✔️ Awesome, I've got everything!

Great. Double check you have saved both README.md and DESIGN.md.

ⓧ I'd like to double check the full code

Here is the complete README.md file:

# URL Shortener

A scalable URL shortening service built with Node.js, PostgreSQL, and Redis. Demonstrates production architecture patterns including cache-aside caching, Base62 encoding, graceful degradation, and containerized AWS deployment.

## Architecture

```mermaid
graph LR
    Client-->ALB[Application Load Balancer]
    ALB-->ECS1[ECS Task 1]
    ALB-->ECS2[ECS Task 2]
    ECS1-->Redis[(Redis Cache)]
    ECS2-->Redis
    ECS1-->PG[(PostgreSQL)]
    ECS2-->PG
```

## Key Features

- Base62 short code generation (collision-free, deterministic)
- Redis cache-aside pattern with 24-hour TTL
- Graceful degradation when Redis is unavailable
- Rate limiting (100 requests per 15-minute window)
- Input validation and parameterized SQL queries
- Health check endpoint with dependency status
- Docker Compose for local development
- AWS deployment with ECS Fargate, RDS, and ElastiCache

## Tech Stack

| Layer | Technology |
|-------|------------|
| Runtime | Node.js 20 LTS |
| Framework | Express.js |
| Database | PostgreSQL 16 |
| Cache | Redis 7 |
| Container | Docker, Docker Compose |
| Cloud | AWS ECS Fargate, RDS, ElastiCache, ALB |

## Quick Start (Local)

```bash
git clone <your-repo-url>
cd url-shortener
docker compose up --build
```

The API is available at http://localhost:3000.

## API Reference

### Shorten a URL

```bash
curl -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/very/long/path"}'
```

Response:
```json
{
  "shortCode": "g8",
  "shortUrl": "http://localhost:3000/g8",
  "originalUrl": "https://example.com/very/long/path"
}
```

### Redirect

```bash
curl -I http://localhost:3000/g8
# HTTP/1.1 302 Found
# Location: https://example.com/very/long/path
```

### Health Check

```bash
curl http://localhost:3000/health
```

Response:
```json
{
  "status": "ok",
  "postgres": "connected",
  "redis": "connected"
}
```

## Design Decisions

| Decision | Choice | Rationale |
|----------|--------|-----------|
| Short code generation | Base62 on auto-increment ID | Collision-free by construction, simple, interview-standard |
| Redirect status | 302 Temporary | Preserves click analytics (301 is browser-cached) |
| Cache strategy | Cache-aside with write-through on create | Tolerates Redis failures, simple invalidation |
| Database | PostgreSQL | Schema flexibility, indexing discussion, familiar SQL |

## System Design Deep Dive

See [DESIGN.md](./DESIGN.md) for the full system design document including:
- Capacity estimates for 100M MAU scale
- Scaling strategies (sharding, replication, CDN)
- Mermaid sequence diagrams
- Trade-off analysis

## Project Structure

```
url-shortener/
  src/
    index.js            # Express app entry point
    db.js               # PostgreSQL pool configuration
    redis.js            # Redis client with retry strategy
    schema.sql          # Database schema
    utils/
      base62.js         # Base62 encode/decode
    routes/
      shorten.js        # POST /api/shorten
      redirect.js       # GET /:shortCode
      health.js         # GET /health
  Dockerfile
  docker-compose.yml
  DESIGN.md
  README.md
  .env.example
```

## License

MIT

Your portfolio documentation is complete. Next up, you will deploy this entire system to AWS with ECS Fargate, RDS, ElastiCache, and an Application Load Balancer.

Deploy to AWS with ECS Fargate

Your URL shortener works locally with Docker Compose. Now it is time to deploy it to a production-like environment on AWS.

In this step, you will push your container image to AWS, provision managed PostgreSQL and Redis services, and expose the whole system behind a load balancer. This mirrors how production URL shorteners serve traffic at scale.

In this step, get ready to:
  • Push your Docker image to Amazon ECR and create an ECS Fargate cluster.
  • Provision RDS PostgreSQL and ElastiCache Redis in the same VPC.
  • Create an Application Load Balancer and expose the service publicly.
Push the Docker image to ECR and create the ECS cluster

Amazon ECR (Elastic Container Registry) is a managed Docker registry. You will push your local image there so that ECS Fargate can pull and run it.

Cost warning

ECS Fargate at 0.25 vCPU / 0.5 GB costs approximately $0.004/hour. RDS db.t3.micro is free-tier eligible for 12 months. ElastiCache cache.t3.micro costs approximately $0.017/hour (about $0.41/day).

Full cleanup instructions are provided in the Before You Go section. Do not leave resources running if you are done with the project.

  • First, get your AWS account ID by running:
aws sts get-caller-identity --query Account --output text
  • Note the 12-digit account ID that is returned. You will use it in the next commands.
  • Create an ECR repository for the URL shortener by running:
aws ecr create-repository --repository-name url-shortener
  • Authenticate Docker to your ECR registry by running:
aws ecr get-login-password --region [[AWS_REGION="your AWS region, e.g. us-east-1"]] | docker login --username AWS --password-stdin [[AWS_ACCOUNT_ID="your 12-digit account ID"]].dkr.ecr.[[AWS_REGION="your AWS region, e.g. us-east-1"]].amazonaws.com

What does this command do?

This fetches a temporary authentication token from AWS and pipes it into docker login. The token is valid for 12 hours. After that, Docker can push and pull images from your private ECR registry.

  • Build the Docker image for production by running:
docker build -t url-shortener .
  • Tag the image for ECR by running:
docker tag url-shortener:latest [[AWS_ACCOUNT_ID="your 12-digit account ID"]].dkr.ecr.[[AWS_REGION="your AWS region, e.g. us-east-1"]].amazonaws.com/url-shortener:latest
  • Push the image to ECR by running:
docker push [[AWS_ACCOUNT_ID="your 12-digit account ID"]].dkr.ecr.[[AWS_REGION="your AWS region, e.g. us-east-1"]].amazonaws.com/url-shortener:latest

You should see layer uploads and a final digest confirming the push succeeded.

  • Create the ECS cluster by running:
aws ecs create-cluster --cluster-name url-shortener-cluster

What is an ECS cluster?

An ECS cluster is a logical grouping of tasks and services. With Fargate, AWS manages the underlying servers. You only define how much CPU and memory each task needs.

  • You should see a JSON response with "clusterName": "url-shortener-cluster" and "status": "ACTIVE" in the output.

Push failed or access denied?

Make sure Docker Desktop is running. Verify that your AWS CLI credentials have permissions for ECR (the AmazonEC2ContainerRegistryFullAccess policy covers this).

If you see an authorization error, re-run the aws ecr get-login-password command. Tokens expire after 12 hours.

Help me troubleshoot my ECR push. I am seeing this error in my terminal and have already tried re-authenticating.

Provision RDS PostgreSQL and ElastiCache Redis

Your application needs managed database and cache services that persist independently of the ECS tasks. You will create an RDS PostgreSQL instance and an ElastiCache Redis cluster in the same VPC so your tasks can reach both.

  • Open the AWS Console and navigate to the VPC service.
  • Note your default VPC ID and the subnet IDs in at least two availability zones. You will need these for RDS and ElastiCache.
  • Create a security group for the database and cache. Navigate to Security Groups and click Create security group.
  • Name it url-shortener-data-sg and add two inbound rules:
  • Rule 1: Type PostgreSQL, port 5432, source set to the VPC CIDR (e.g. 10.0.0.0/16 or your default VPC CIDR).
  • Rule 2: Type Custom TCP, port 6379, source set to the same VPC CIDR.
  • Click Create security group.

Why use the VPC CIDR as the source?

ECS Fargate tasks get their own elastic network interfaces inside the VPC. By allowing traffic from the entire VPC CIDR, any task in the VPC can reach the database and cache on their respective ports.

  • Navigate to the RDS service in the AWS Console.
  • Click Create database.
  • Select Standard create.
  • For Engine type, choose PostgreSQL.
  • For Engine version, select 16 (the latest PostgreSQL 16.x available).
  • Under Templates, select Free tier.
  • For DB instance identifier, enter url-shortener-db.
  • Set the Master username to postgres.
  • Set a Master password and note it down securely. You will need it for your task definition.
  • For DB instance class, confirm db.t3.micro is selected (it should be under Free tier).
  • Under Connectivity, ensure the database is in the same VPC as your ECS cluster.
  • For VPC security group, choose Choose existing and select url-shortener-data-sg.
  • Under Additional configuration, set Initial database name to urlshortener.
  • Click Create database. This takes 5-10 minutes.
  • While RDS is provisioning, navigate to the ElastiCache service.
  • Click Create cluster (or Redis caches then Create, depending on your console version).
  • Select Redis OSS as the engine.
  • Choose Design your own cache and Standard create.
  • For Name, enter url-shortener-redis.
  • For Node type, select cache.t3.micro.
  • Set Number of replicas to 0 (single node for this project).
  • Under Connectivity, choose the same VPC and subnet group.
  • For Security group, select url-shortener-data-sg.
  • Click Create. This also takes a few minutes.
  • Once both are available, note the RDS endpoint (looks like url-shortener-db.xxxx.us-east-1.rds.amazonaws.com) from the RDS database details page.
  • Note the ElastiCache Primary endpoint (looks like url-shortener-redis.xxxx.0001.use1.cache.amazonaws.com) from the ElastiCache cluster details page.

Database stuck in creating state?

RDS instances can take up to 10 minutes to provision. Make sure you selected db.t3.micro and Free tier template. If it takes longer than 15 minutes, check the Events tab for any error messages.

Help me debug why my RDS instance is not becoming available. I selected Free tier and db.t3.micro.

Create the ALB and expose the service

Now you will create a task definition that tells ECS how to run your container, register it with an Application Load Balancer, and create the service. The ALB gives you a public DNS name that routes traffic to your Fargate tasks.

  • Navigate to the ECS service in the AWS Console.
  • Click Task definitions in the left sidebar, then Create new task definition.
  • For Task definition family, enter url-shortener.
  • Under Infrastructure requirements, select AWS Fargate as the launch type.
  • Set CPU to .25 vCPU and Memory to .5 GB.
  • For Task execution role, select Create new role (or select ecsTaskExecutionRole if it already exists).
  • Under Container details, set the container name to url-shortener.
  • For Image URI, paste your ECR image URI (the same URI you pushed to earlier, ending in /url-shortener:latest).
  • Set the Container port to 3000 with protocol TCP.
  • Under Environment variables, add the following key-value pairs:
  • PORT = 3000.
  • DATABASE_URL = postgresql://postgres:YOUR_PASSWORD@YOUR_RDS_ENDPOINT:5432/urlshortener (replace with your RDS endpoint and password).
  • REDIS_URL = redis://YOUR_ELASTICACHE_ENDPOINT:6379 (replace with your ElastiCache endpoint).
  • BASE_URL = leave this blank for now. You will update it after creating the ALB.
  • Click Create to register the task definition.
  • Navigate to the EC2 service, then Load Balancers in the left sidebar.
  • Click Create load balancer.
  • Choose Application Load Balancer.
  • For Name, enter url-shortener-alb.
  • Scheme: Internet-facing.
  • Under Network mapping, select your VPC and at least two availability zone subnets.
  • Under Security groups, create a new security group that allows inbound HTTP on port 80 from 0.0.0.0/0 (anywhere).
  • Under Listeners, keep the default HTTP listener on port 80.
  • For Default action, click Create target group.
  • In the target group wizard: select target type IP addresses (required for Fargate).
  • Name it url-shortener-tg.
  • Set Protocol HTTP, Port 3000.
  • For Health check path, enter /health.
  • Skip adding targets (ECS will register them automatically). Click Create target group.
  • Back on the ALB page, select your new target group as the default action.
  • Click Create load balancer.
  • Once the ALB is active, copy its DNS name from the load balancer details page (looks like url-shortener-alb-123456.us-east-1.elb.amazonaws.com).
  • Go back to ECS, click Task definitions, select your url-shortener task definition, and create a new revision.
  • Update the BASE_URL environment variable to http://YOUR_ALB_DNS_NAME (using the DNS name you just copied).
  • Click Create to save the new revision.
  • Navigate to your url-shortener-cluster in ECS.
  • Click the Services tab, then Create.
  • For Launch type, select FARGATE.
  • For Task definition family, select url-shortener (the latest revision).
  • For Service name, enter url-shortener-service.
  • Set Desired tasks to 1.
  • Under Networking, select your VPC subnets. For Security group, use the same url-shortener-data-sg (or create a new one that allows outbound traffic and inbound from the ALB).
  • Under Load balancing, select Application Load Balancer.
  • Select your existing url-shortener-alb and target group url-shortener-tg.
  • Click Create.
  • Wait 2-3 minutes for the task to reach RUNNING status. Check the Tasks tab in your service.
  • Click on the running task and open the Logs tab. You should see Connected to PostgreSQL and Connected to Redis in the output.
  • Test the deployed service by shortening a URL through the ALB:
curl -X POST http://[[ALB_DNS="your ALB DNS name"]]/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/very/long/path"}'

You should see a JSON response with a shortCode and shortUrl pointing to your ALB.

  • Visit the short URL in your browser. It should redirect to the original URL.

Task not starting or showing STOPPED?

Check the task's Logs tab for connection errors. The most common cause is a security group misconfiguration preventing the task from reaching RDS or ElastiCache.

Verify that url-shortener-data-sg allows inbound on port 5432 and 6379 from the VPC CIDR. Also check that your DATABASE_URL and REDIS_URL environment variables have the correct endpoints and credentials.

Help me debug my ECS Fargate task that is not starting. The logs show a connection error to my database.

Your URL shortener is now live on AWS. Anyone with the ALB DNS name can shorten URLs and get redirected. Next up, you will tackle the Secret Mission and add analytics plus URL expiration.

Secret mission

Add Click Analytics and URL Expiration

Your URL shortener works, but it's flying blind. Every click vanishes into the void with no record of who clicked, when, or from where. And once a URL is shortened, it lives forever. In this mission, you'll add a stats endpoint that reveals click-by-click analytics and implement time-based URL expiration with automatic cache invalidation.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses AWS resources that accrue charges while active.

Cost warning

ElastiCache (cache.t3.micro) costs approximately $0.017/hour, which adds up to roughly $12/month if left running. The ALB costs approximately $0.0225/hour plus data transfer. ECS Fargate at this size costs approximately $0.01/hour.

Combined, leaving everything running costs approximately $1-2/day. RDS db.t3.micro is free-tier eligible for 12 months (750 hours/month). We recommend deleting all resources if you are done with this project.

Resources you used:

  • ECS Fargate service and cluster.
  • Application Load Balancer and target group.
  • RDS PostgreSQL instance (db.t3.micro).
  • ElastiCache Redis cluster (cache.t3.micro).
  • ECR repository (Docker image storage).
  • Local project files (url-shortener directory).

Keep everything running

No action needed. Choose this if you are still actively building or want the API accessible at the ALB endpoint.

  • Your AWS resources will continue to accrue charges (approximately $1-2/day with free tier offsets).
  • Docker containers remain available locally via docker compose up.
  • The local project files remain on your machine.

Pause - I'll come back to this later

Shut down running ECS tasks to stop Fargate charges. Note that RDS and ElastiCache continue to accrue charges even when idle.

  • Scale down the ECS service to zero tasks. In the AWS Console, navigate to ECS > Clusters > url-shortener-cluster > your service. Click Update and set Desired tasks to 0.
  • This stops the Fargate tasks (saving ~$0.01/hour) but keeps the service definition intact so you can scale back up later.
  • RDS and ElastiCache remain running and will continue to charge. To fully stop charges, choose the Delete tab.
  • Stop local Docker containers by running:
docker compose down
  • Your local project files remain on your machine for when you come back.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step in order to avoid dependency errors.

  • Delete the ECS service by running:
aws ecs delete-service --cluster url-shortener-cluster --service [[ECS_SERVICE_NAME="your ECS service name"]] --force
  • Delete the ECS cluster by running:
aws ecs delete-cluster --cluster url-shortener-cluster
  • Delete the Application Load Balancer. In the AWS Console, navigate to EC2 > Load Balancers. Select your load balancer, click Actions, then Delete load balancer.
  • Delete the target group. In the AWS Console, navigate to EC2 > Target Groups. Select your target group, click Actions, then Delete.
  • Delete the ElastiCache Redis cluster by running:
aws elasticache delete-cache-cluster --cache-cluster-id [[CACHE_CLUSTER_ID="your ElastiCache cluster ID"]]
  • Delete the RDS PostgreSQL instance (skip final snapshot to avoid storage charges) by running:
aws rds delete-db-instance --db-instance-identifier [[DB_INSTANCE_ID="your RDS instance identifier"]] --skip-final-snapshot
  • Delete the ECR repository and all images inside it by running:
aws ecr delete-repository --repository-name url-shortener --force
  • Stop and remove local Docker containers and volumes by running:
docker compose down -v
  • Delete the local project folder by running:
cd ~/Desktop
rm -rf url-shortener

What about the security groups?

AWS automatically cleans up unused security groups associated with deleted ECS tasks once those resources are removed. If you created custom security groups manually, you can delete them from the EC2 console under Security Groups once all dependent resources are gone.

That's a Wrap!

That's a Wrap!

Nice work! You just built and deployed a production-architecture URL shortener that demonstrates real distributed systems fundamentals.

You've learned how to:

  • Build a URL shortener API with Base62 encoding, PostgreSQL persistence, and a Redis cache-aside pattern with graceful degradation.
  • Deploy a containerized service to AWS with ECS Fargate, RDS, ElastiCache, and an Application Load Balancer.
  • Create portfolio-ready documentation (README.md and DESIGN.md) with Mermaid architecture diagrams, capacity estimates for 100M MAU, and scaling strategies for interview discussions.
  • Secret Mission: Added click analytics with device and geographic data, plus TTL-based URL expiration with automatic cache invalidation.

Ready to quiz yourself?