URL Shortener with Live Cache Stats

Build a horizontally scaled URL shortener with Redis caching and live stats.

Introduction

30 Second Summary

Every time you click a short link, a server has to look up where to send you. Do that once and it's instant. Do it thousands of times per second and your database starts sweating. The trick to handling that pressure is caching and horizontal scaling.

In this project, you will build a full-stack URL shortener with React, Express, PostgreSQL, and Redis. You will add a cache layer that drops response times to sub-millisecond, then scale to three backend instances behind an Nginx load balancer to prove the system holds under real traffic.

What You'll Build

Paste a long URL into your app, get back a short code, click it and watch the redirect happen. Then open the live stats dashboard, blast the system with hundreds of requests per second, and watch the cache absorb the pressure across three backend instances.

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

  • A working URL shortener where you paste any link, get a short code, and click it to redirect instantly.
  • A live cache dashboard showing hit/miss rates and response time comparisons that update in real time as you use the app.
  • A horizontally scaled architecture with three Express replicas behind Nginx, load-tested to prove requests distribute evenly and Redis serves them all.
  • Secret Mission: Deploy the entire Docker Compose stack to an AWS EC2 instance and access your URL shortener via a public IP from any device.

Are there any prerequisites?

This project assumes you have basic familiarity with Express (building routes) and React (components and state). You do not need prior experience with Redis, Docker, or Nginx.

Before We Start

You're about to build a URL shortener that goes beyond the basics. It won't just shorten links. It will cache lookups in Redis, show live cache performance stats on a dashboard, and scale horizontally behind a load balancer.

Before any of that, you need a project directory and the right packages installed. This step gets your foundation ready so you can start building the API in the next step.

In this step, get ready to:
  • Create a project directory on your Desktop.
  • Initialize npm and install the backend dependencies.
  • Open the project in VS Code.
Create your project directory
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • 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 you're in the right place by running:
pwd

You should see a path ending in /url-shortener.

Don't see url-shortener at the end?

Make sure you ran both mkdir url-shortener and cd url-shortener as separate lines. If you only ran mkdir, you created the folder but didn't move into it.

help me get into my url-shortener directory

Initialize npm and install dependencies

Your project needs a package.json to track its metadata and dependencies. You'll initialize that file and install five packages that power the backend.

  • Initialize your project by running:
npm init -y

You should see a JSON object printed to the terminal showing your new package.json contents.

What does the -y flag do?

The -y flag accepts all default values so you skip the interactive prompts. It creates package.json immediately with the folder name as the project name.

  • Install the backend dependencies by running:
npm install express pg ioredis cors nanoid

You should see output showing packages added and a node_modules folder created.

What are these packages?

  • express is the web framework for your API.
  • pg is the PostgreSQL client for storing URLs.
  • ioredis is the Redis client for caching.
  • cors enables cross-origin requests from the React frontend.
  • nanoid generates short, URL-safe codes for your shortened links.

🙋‍♀️ Seeing permission errors or EACCES?

On macOS, this sometimes happens if node_modules has wrong permissions. Try running the install with sudo npm install express pg ioredis cors nanoid or fix your npm permissions following the npm docs.

help me fix npm permission errors

Open the project in VS Code and update package.json
  • Open VS Code on your project folder by running:
code .

Getting command not found?

On macOS, you need to add the code command to your PATH manually. Open VS Code from Spotlight (Cmd+Space, type Visual Studio Code). Then press Cmd+Shift+P, type shell command, and run Shell Command: Install 'code' command in PATH. Restart your terminal and try again.

On Windows, this should work automatically after installation. If not, reinstall VS Code and check the Add to PATH option during setup.

help me get the code command working in my terminal

VS Code opens with your url-shortener folder in the Explorer sidebar. You should see package.json, package-lock.json, and a node_modules folder listed.

  • Click on package.json in the Explorer sidebar to open it.
  • Find the "main" field and the "scripts" block. They currently look like this:
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  • Replace those lines with the following:
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  },

What did we change?

  • "main" now points to server.js, the file you'll create in the next step.
  • The start script runs the server in production mode.
  • The dev script uses Node's --watch flag to auto-restart the server whenever you save a file.
  • You can also delete the "description", "keywords", "author", and "license" fields if you'd like a cleaner file. They won't affect the project.
  • Save package.json with Cmd+S (macOS) or Ctrl+S (Windows).

Before moving on, what do you think you'll see if you check the file's scripts section now?

  • Confirm your changes saved by running this in the VS Code integrated terminal (Ctrl+` to open it):
cat package.json

You should see "main": "server.js" and your two new scripts (start and dev) in the output. The dependencies section lists all five packages.

✔️ Awesome, I've got everything!

Great. Double check you've saved package.json and move on.

ⓧ I'd like to double check the full code

Your package.json should look like this. Dependency versions may differ slightly depending on when you ran npm install.

{
  "name": "url-shortener",
  "version": "1.0.0",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  },
  "dependencies": {
    "cors": "^2.8.5",
    "express": "^4.21.2",
    "ioredis": "^5.4.2",
    "nanoid": "^5.1.5",
    "pg": "^8.13.3"
  }
}

Your project is initialized and ready to go. Next up, you'll spin up PostgreSQL and Redis with Docker Compose so your data layer is running before you write any application code.

Set Up Docker Compose for PostgreSQL and Redis

Your URL shortener needs two data services before you can build anything on top of it. PostgreSQL stores the URL mappings permanently, and Redis caches frequent lookups in memory for sub-millisecond responses.

Docker Compose lets you define both services in a single YAML file and spin them up with one command. No manual installation of PostgreSQL or Redis on your machine.

In this step, get ready to:
  • Create a docker-compose.yml defining PostgreSQL and Redis services.
  • Write a SQL initialization script for the database schema.
  • Start both containers and confirm they're healthy.
Create the Docker Compose file

Docker Compose uses a docker-compose.yml file to define which services to run, what images to pull, and how to connect them together.

  • Create a new file called docker-compose.yml in your url-shortener folder and paste in the following configuration:
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: shortener
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: apppass
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d shortener"]
      interval: 5s
      timeout: 3s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pgdata:

What does this configuration do?

  • postgres pulls a lightweight Alpine image and creates a database called shortener with credentials you'll use later in your Express app.
  • The volumes section mounts an init.sql file (you'll create this next) that runs automatically when the database starts for the first time.
  • redis pulls Redis 7 Alpine and exposes port 6379 for your app to connect to.
  • Both services include healthcheck blocks so Docker knows when they're ready to accept connections.
  • Save docker-compose.yml.
Create the database initialization script

PostgreSQL will automatically run any SQL file mounted into /docker-entrypoint-initdb.d/ the first time the container starts. This is where you define your URL storage table.

  • Create a new file called init.sql in your url-shortener folder and paste in this SQL:
CREATE TABLE IF NOT EXISTS urls (
  id SERIAL PRIMARY KEY,
  code VARCHAR(10) UNIQUE NOT NULL,
  original_url TEXT NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_urls_code ON urls(code);

What does this SQL do?

  • The urls table stores each shortened URL with a unique short code, the original URL, and a timestamp.
  • The CREATE INDEX on code makes lookups by short code fast, since that's how every redirect will query the database.
  • Save init.sql.
  • Confirm both files exist by running:
ls

You should see docker-compose.yml, init.sql, package.json, and node_modules in the output.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the full docker-compose.yml:

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: shortener
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: apppass
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d shortener"]
      interval: 5s
      timeout: 3s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pgdata:

And here is init.sql:

CREATE TABLE IF NOT EXISTS urls (
  id SERIAL PRIMARY KEY,
  code VARCHAR(10) UNIQUE NOT NULL,
  original_url TEXT NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_urls_code ON urls(code);
Start the containers and verify they're healthy

With both files in place, you can bring up the entire data layer with a single command.

  • Make sure Docker Desktop is running on your machine.
  • Start both services in detached mode by running:
docker compose up -d

What does the -d flag do?

The -d flag runs the containers in detached mode, meaning they run in the background and give you your terminal back. Without it, your terminal would show a stream of container logs and you'd need a new terminal window for other commands.

Docker will pull the PostgreSQL and Redis images (this takes a minute the first time), create the containers, and start them in the background.

Before you check the status, what do you expect to see? Both services should report as healthy if the health checks pass.

  • Check the status of your containers by running:
docker compose ps

You should see both postgres and redis services listed with a Status that includes "(healthy)".

Give it a few seconds

If you see "(health: starting)" instead of "(healthy)", wait 10-15 seconds and run docker compose ps again. The health checks need a moment to confirm the services are ready.

🙋‍♀️ Containers not starting?

  • Make sure Docker Desktop is running. You should see the Docker whale icon in your system tray or menu bar.
  • If you see a port conflict error mentioning 5432 or 6379, another service is already using that port. Stop the conflicting service or change the port mapping in docker-compose.yml.
  • If you see an error about init.sql not found, double check the file is in the same folder as docker-compose.yml.

Why aren't my Docker containers starting?

Your data layer is live. PostgreSQL is ready to store URLs and Redis is ready to cache them. Next up, you'll build the Express API and React frontend that put these services to work.

Build the Express API and React Frontend

Your PostgreSQL and Redis containers are running. Now it is time to build the actual application that uses them.

This step delivers the core product: paste a long URL, get a short code, click it and get redirected. No caching yet. Every single redirect hits PostgreSQL directly. You will see proof of this in the terminal logs.

In this step, get ready to:
  • Build an Express API with endpoints to shorten URLs and redirect short codes.
  • Scaffold a React frontend with a URL input form and live stats panel.
  • Test the full end-to-end flow and confirm every redirect hits the database.
Create the Express server with the shorten endpoint

The Express API is the backbone of your URL shortener. It receives long URLs, generates short codes, and handles redirects.

  • Create a new file called server.js in your project root (the url-shortener folder) and add the following setup code:
const express = require('express');
const { Pool } = require('pg');
const Redis = require('ioredis');
const cors = require('cors');
const os = require('os');

const app = express();
app.use(cors());
app.use(express.json());

// Connect to PostgreSQL and Redis
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = new Redis(process.env.REDIS_URL);

// Identify this server instance by hostname
const INSTANCE_ID = os.hostname();
const CACHE_TTL = 60;

console.log(`Starting instance: ${INSTANCE_ID}`);

What does this code do?

This sets up the Express app with CORS (so the React frontend can talk to it) and connects to both PostgreSQL and Redis using environment variables. The INSTANCE_ID will identify which server instance handled a request, which becomes useful when you scale to multiple instances later.

  • Below the setup code, add the POST /shorten endpoint that generates short codes:
app.post('/shorten', async (req, res) => {
  const { url } = req.body;
  if (!url) return res.status(400).json({ error: 'url is required' });

  // nanoid 5.x is ESM-only, so use dynamic import
  const { nanoid } = await import('nanoid');
  const code = nanoid(7);

  await pool.query(
    'INSERT INTO urls (code, original_url) VALUES ($1, $2)',
    [code, url]
  );

  res.json({ code, shortUrl: `http://localhost:3000/${code}` });
});

What does this code do?

When someone submits a URL, this endpoint generates a random 7-character code using nanoid, stores the mapping in PostgreSQL, and returns the short URL. The dynamic import('nanoid') is needed because nanoid 5.x is an ESM-only package and this project uses CommonJS.

  • Next, add the GET /stats endpoint that returns cache hit/miss counters:
app.get('/stats', async (req, res) => {
  const hits = await redis.get('stats:hits') || '0';
  const misses = await redis.get('stats:misses') || '0';
  res.json({ hits: parseInt(hits), misses: parseInt(misses), instance: INSTANCE_ID });
});
  • Now add the GET /:code redirect endpoint. For now, it always queries PostgreSQL directly:
app.get('/:code', async (req, res) => {
  const { code } = req.params;

  // Look up the code in PostgreSQL
  const result = await pool.query('SELECT original_url FROM urls WHERE code = $1', [code]);
  if (result.rows.length === 0) {
    return res.status(404).json({ error: 'Not found' });
  }

  const originalUrl = result.rows[0].original_url;
  await redis.incr('stats:misses');

  // Log that we hit the database
  console.log(`[${INSTANCE_ID}] DB lookup for ${code}`);
  res.set('X-Cache', 'MISS');
  res.set('X-Instance', INSTANCE_ID);
  res.redirect(302, originalUrl);
});

Why does every redirect say MISS?

Right now there is no caching logic. Every redirect goes straight to PostgreSQL, increments the misses counter, and sets X-Cache: MISS on the response. In the next step, you will add Redis cache-aside so repeated lookups skip the database entirely.

  • Finally, add the server listener at the bottom of server.js:
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT} (instance: ${INSTANCE_ID})`);
});
  • Save server.js.
  • Create a file called .env in your url-shortener folder with the following connection strings:
DATABASE_URL=postgres://appuser:apppass@localhost:5432/shortener
REDIS_URL=redis://localhost:6379
  • Save .env.
  • Start the server using Node's built-in env-file flag by running:
node --env-file=.env server.js

You should see output like: Server running on port 3000 (instance: your-hostname).

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

You should see a JSON response with a code and shortUrl field. The API is working.

Seeing a connection error?

Make sure your Docker containers are still running. Run docker compose ps to check. If they stopped, run docker compose up -d to restart them.

Also confirm your .env file has the correct DATABASE_URL and REDIS_URL values, and that you started the server with node --env-file=.env server.js.

help me debug my Express server connection error

Scaffold the React frontend

The frontend gives you a visual interface to shorten URLs and watch cache stats update in real time.

What prompts will Vite ask?

Since you are passing --template react in the command, Vite skips the framework selection. It will ask whether to use ESLint or Oxlint (select No (Oxlint)) and whether to install and start immediately (select No). You will install dependencies manually in the next bullet.

  • In your second terminal (not the one running the server), scaffold the React app by running:
npm create vite@latest client -- --template react
  • When prompted about ESLint vs Oxlint, select No (Oxlint).
  • When asked to install and start now, select No.
  • Move into the client folder and install dependencies by running:
cd client
npm install
  • Confirm the scaffold worked by running:
ls src

You should see App.jsx and main.jsx listed among the files.

Command not found or scaffold failed?

Make sure you have Node.js 20.19+ or 22.12+ installed. Run node --version to check. Vite requires these minimum versions.

help me fix the Vite scaffold command

Build the frontend UI and test the full flow

Now you will replace the default Vite boilerplate with a form to shorten URLs and a live stats panel.

  • Open client/src/App.jsx in your editor and delete all of its contents.
  • Add the following imports and component setup at the top of the empty file:
import { useState, useEffect } from 'react';

// Point at the Express API running on port 3000
const API_URL = 'http://localhost:3000';

function App() {
  const [url, setUrl] = useState('');
  const [shortUrl, setShortUrl] = useState('');
  const [stats, setStats] = useState({ hits: 0, misses: 0 });
  const [message, setMessage] = useState('');

  // Poll the stats endpoint every second
  useEffect(() => {
    const interval = setInterval(async () => {
      try {
        const res = await fetch(`${API_URL}/stats`);
        const data = await res.json();
        setStats(data);
      } catch (err) {
        console.error('Failed to fetch stats');
      }
    }, 1000);
    return () => clearInterval(interval);
  }, []);

What does this code do?

The useEffect hook starts a timer that fetches cache stats from your Express API every second. This powers the live dashboard. The state variables track the form input, the shortened URL result, and the stats data.

  • Below the useEffect block (still inside the App function), add the form handler:
  const handleShorten = async (e) => {
    e.preventDefault();
    setMessage('');
    try {
      const res = await fetch(`${API_URL}/shorten`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ url }),
      });
      const data = await res.json();
      if (data.shortUrl) {
        setShortUrl(data.shortUrl);
        setMessage(`Shortened! Code: ${data.code}`);
      }
    } catch (err) {
      setMessage('Error shortening URL');
    }
  };

What does this code do?

When the learner submits the form, this function POSTs the URL to your Express API and stores the returned short code. It handles errors gracefully with a user-facing message.

  • Below the handleShorten function, add the hit rate calculation and the opening of the return statement with the form:
  const hitRate = stats.hits + stats.misses > 0
    ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(1)
    : '0.0';

  return (
    <div style={{ maxWidth: '600px', margin: '2rem auto', fontFamily: 'sans-serif' }}>
      <h1>URL Shortener</h1>

      <form onSubmit={handleShorten}>
        <input
          type="url"
          value={url}
          onChange={(e) => setUrl(e.target.value)}
          placeholder="https://example.com/very-long-url"
          style={{ width: '70%', padding: '8px' }}
          required
        />
        <button type="submit" style={{ padding: '8px 16px', marginLeft: '8px' }}>
          Shorten
        </button>
      </form>

      {message && <p>{message}</p>}
      {shortUrl && (
        <p>
          Short URL: <a href={shortUrl} target="_blank" rel="noreferrer">{shortUrl}</a>
        </p>
      )}
  • Directly below (continuing the return statement), add the stats dashboard section and close out the component:
      <hr style={{ margin: '2rem 0' }} />

      <h2>Cache Stats (Live)</h2>
      <div style={{ display: 'flex', gap: '2rem' }}>
        <div>
          <h3>{stats.hits}</h3>
          <p>Cache Hits</p>
        </div>
        <div>
          <h3>{stats.misses}</h3>
          <p>Cache Misses</p>
        </div>
        <div>
          <h3>{hitRate}%</h3>
          <p>Hit Rate</p>
        </div>
      </div>
      {stats.instance && <p>Last responding instance: {stats.instance}</p>}
    </div>
  );
}

export default App;

What does the stats section show?

The dashboard displays cache hits, misses, and the calculated hit rate in real time. Right now hits will stay at 0 because there is no caching yet. In the next step, you will add Redis cache-aside and watch the hit counter climb.

  • Save client/src/App.jsx.
  • Start the Vite dev server by running:
npm run dev

The dev server starts on http://localhost:5173. Open it in your browser.

  • In the URL input field, paste https://www.example.com and click Shorten.
  • You should see a success message with a short code and a clickable link.

The short URL points to localhost:3000 because that is where your Express server is running right now. In Step 4, you will move the API behind Nginx on port 80 and update this URL to match.

  • Test the redirect headers by running this command in your second terminal (replace the code with the one you received):
curl -I http://localhost:3000/[[SHORTCODE="the 7-character code from your shorten response"]]

What do you expect to see in the response headers?

You should see a 302 redirect with X-Cache: MISS in the headers. Switch to the terminal running your Express server. You should see a log line that says DB lookup followed by your code.

  • Run the same curl command again.

You will see the same result: another DB lookup log, another X-Cache: MISS. Every single redirect hammers the database because there is no caching layer yet.

Why is this a problem?

If 1000 people click the same short link, that is 1000 identical PostgreSQL queries for the same row. Under load, this becomes slow and expensive. The cache stats panel in your React app will confirm it: misses keep climbing, hits stay at zero.

The next step fixes this with Redis cache-aside. The first lookup hits the database, but every subsequent one gets served from memory.

✔️ Awesome, I've got everything!

Great. Double check that you have saved both server.js and client/src/App.jsx. You should have the Express server running in one terminal and the Vite dev server in another.

ⓧ I'd like to double check the full code

Scroll up and confirm each code block is in your files, in order:

  • server.js: setup and imports, POST /shorten, GET /stats, GET /:code, server listener.
  • client/src/App.jsx: imports and state/useEffect, handleShorten function, hitRate and form JSX, stats dashboard and export.

Make sure both files are saved and both servers are running.

Stats panel showing 0 hits and 0 misses even after redirecting?

The stats panel polls http://localhost:3000/stats. Open your browser's developer console and check for CORS or network errors. If you see a CORS error, make sure app.use(cors()) is in your server.js before the route definitions.

help me debug my stats panel not updating

Your URL shortener works end to end. Every redirect hits PostgreSQL, and you have proof in the logs and headers. Next up, you will add Redis caching and watch those database hits drop to zero.

Add Redis Caching

Right now, every single redirect hits PostgreSQL directly. One user clicking a short link is fine, but imagine a hundred users clicking the same link every second.

The cache-aside pattern fixes this. The first lookup for a short code hits the database, but every subsequent lookup is served from Redis in-memory. You will see the difference immediately in your response headers and stats dashboard.

In this step, get ready to:
  • Add cache-aside logic to the redirect route with a 60-second TTL.
  • Test cache hits vs misses using response headers.
  • Run a load test to prove caching absorbs repeated traffic.
Add cache-aside logic to the redirect route

The cache-aside pattern works like a two-level lookup. Check Redis first. If it has the answer, return it immediately. If it does not, query PostgreSQL, store the result in Redis for next time, and then return it.

  • In server.js, find the GET /:code route handler. Update it to include the Redis cache-aside logic:
app.get('/:code', async (req, res) => {
  const { code } = req.params;

  // Check Redis cache first
  const cached = await redis.get(`shorturl:${code}`);
  if (cached) {
    await redis.incr('stats:hits');
    console.log(`[${INSTANCE_ID}] Cache HIT for ${code}`);
    res.set('X-Cache', 'HIT');
    res.set('X-Instance', INSTANCE_ID);
    return res.redirect(302, cached);
  }

  // Cache miss - query PostgreSQL
  const result = await pool.query('SELECT original_url FROM urls WHERE code = $1', [code]);
  if (result.rows.length === 0) {
    return res.status(404).json({ error: 'Not found' });
  }

  // Store in Redis with 60s TTL for next time
  const originalUrl = result.rows[0].original_url;
  await redis.setex(`shorturl:${code}`, CACHE_TTL, originalUrl);
  await redis.incr('stats:misses');

  console.log(`[${INSTANCE_ID}] DB lookup for ${code}`);
  res.set('X-Cache', 'MISS');
  res.set('X-Instance', INSTANCE_ID);
  res.redirect(302, originalUrl);
});

What does this code do?

  • redis.get(`shorturl:${code}`) checks if Redis already has the URL cached under a key like shorturl:abc1234.
  • If the key exists (cache hit), it increments the stats:hits counter and returns immediately with an X-Cache: HIT header.
  • On a miss, it queries PostgreSQL, then stores the result using redis.setex with a 60-second TTL. After 60 seconds, the key expires and the next lookup hits the database again.
  • The X-Instance header tags every response with the server's hostname. This becomes useful in the next step when you run multiple instances.
  • Confirm that your server.js also defines the CACHE_TTL constant and the /stats endpoint near the top of the file:
const INSTANCE_ID = os.hostname();
const CACHE_TTL = 60;

console.log(`Starting instance: ${INSTANCE_ID}`);
app.get('/stats', async (req, res) => {
  // Read hit/miss counters from Redis
  const hits = await redis.get('stats:hits') || '0';
  const misses = await redis.get('stats:misses') || '0';
  res.json({ hits: parseInt(hits), misses: parseInt(misses), instance: INSTANCE_ID });
});

What does the /stats endpoint do?

It reads two Redis keys (stats:hits and stats:misses) and returns them as JSON along with the instance ID. Your React frontend polls this endpoint every second to display live cache performance.

  • Save server.js.
  • Restart your backend server so the changes take effect.

Server won't start?

  • Check that CACHE_TTL is defined before the route handlers use it. It should appear near the top of the file, after the Redis connection.
  • Confirm your Redis container is still running with docker compose ps. If it shows unhealthy, restart it with docker compose restart redis.
  • Why won't my Express server connect to Redis?
Test the cache manually

Before you trust the cache at scale, verify it works for a single request. You will use curl to see the X-Cache header change between the first and second redirect.

  • First, shorten a URL so you have a code to test with. Run this in a new terminal:
curl -X POST http://localhost:3000/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/my-long-url"}'

You will see a JSON response containing a code field (something like abc1234). Copy that code for the next commands.

  • Before you run the next command, predict: will the first redirect be a cache HIT or a cache MISS?
  • Test the first redirect by running this command (replace the code with yours):
curl -s -D - -o /dev/null http://localhost:3000/[[SHORTCODE="your-short-code-here"]]

You will see X-Cache: MISS in the response headers. This is the first time anyone has requested this code, so Redis had nothing cached.

  • Now run the exact same command again:
curl -s -D - -o /dev/null http://localhost:3000/[[SHORTCODE="your-short-code-here"]]

This time you will see X-Cache: HIT. Redis served the URL from memory without touching PostgreSQL.

  • Check your backend terminal. You should see one line saying DB lookup (the first request) and one saying Cache HIT (the second request).

Why does this matter?

The database query involves network hops, disk reads, and query parsing. The Redis lookup is a single in-memory key fetch. Under heavy load, this difference compounds dramatically.

  • Switch to your React app at http://localhost:5173. Confirm that the Cache Stats panel now shows 1 hit and 1 miss.

Stats still showing 0 and 0?

  • Make sure your React app's API_URL in client/src/App.jsx points to http://localhost:3000 (or http://localhost:80 if running through Nginx later). The CORS middleware on Express must be active for the fetch to succeed.
  • Open your browser's DevTools console. If you see CORS errors, restart your backend server to ensure the cors() middleware is loaded.
  • Why aren't my cache stats updating in the React dashboard?
Run a load test with Artillery

A manual test proves caching works for one request. A load test proves it holds up under pressure. You will blast your server with hundreds of requests and watch Redis absorb almost all of them.

  • Install Artillery globally by running:
npm install -g artillery

What is Artillery?

Artillery is a load testing tool that creates virtual users to hammer your endpoints. The quick command lets you test a single HTTP endpoint without writing a full test script.

  • Before you run the load test, predict: after the first request warms the cache, what percentage of the remaining requests should be cache hits?
  • Send 10 virtual users, each making 50 GET requests to your short URL:
artillery quick --count 10 --num 50 http://localhost:3000/[[SHORTCODE="your-short-code-here"]]

What do the flags mean?

  • --count 10 creates 10 virtual users (10 simulated clients hitting the URL).
  • --num 50 tells each virtual user to send 50 GET requests to the target.

When Artillery finishes, it prints a latency table and a requests-per-second summary. The key insight: only the very first request was a cache miss. Every request after that was served from Redis.

  • Switch back to your React app at http://localhost:5173. The Cache Stats panel should now show hundreds of hits and only 1 miss. The hit rate should be at or near 99%.
  • Check your backend terminal logs. You should see one DB lookup line and hundreds of Cache HIT lines.

Artillery command not found?

  • Make sure you used the -g flag to install globally. Without it, the binary is not added to your PATH.
  • On some systems, you may need to open a new terminal window after a global install for the PATH to update.
  • Why can't my terminal find the artillery command?

✔️ Awesome, I've got everything!

Great. Double check you have saved server.js and that your React stats panel is showing live data.

ⓧ I'd like to double check the full code

Here is the complete server.js with all the caching logic. Scroll through and confirm each section matches yours:

  • Imports and middleware setup (lines 1-8).
  • Database and Redis connections with INSTANCE_ID and CACHE_TTL constants (lines 10-16).
  • POST /shorten route (lines 18-29).
  • GET /stats route (lines 31-35).
  • GET /:code route with cache-aside logic (lines 37-59).
  • Server startup on the configured port (lines 61-63).

Scroll up through the code blocks you built and confirm each section is present in order. If anything is missing, add it now and restart your server.

Your URL shortener now serves repeated requests from Redis in microseconds instead of querying PostgreSQL every time. Next up, you will scale to multiple backend instances and prove the shared cache works across all of them.

Scale Horizontally with Docker and Nginx

Your URL shortener works perfectly with a single Express process. In production, a single server is both a bottleneck and a single point of failure.

In this step, you'll containerize the Express app, spin up 3 identical instances behind an Nginx reverse proxy, and blast them with traffic to prove Redis keeps the cache shared across all replicas.

In this step, get ready to:
  • Baseline your single-server performance with Artillery.
  • Create a Dockerfile for the Express API and an Nginx config for load balancing.
  • Update Docker Compose to run 3 API replicas behind Nginx and compare the load test results.
Baseline your single-server performance

Before you scale anything, you need a number to beat. Your Express server is currently running as a single process on port 3000. You'll load test it now and record the results as your baseline.

  • Install Artillery globally by running:
npm install -g artillery
  • Make sure your Express server is running. If it's not, start it from the url-shortener folder with:
node server.js
  • In a new terminal tab, shorten a URL so there's a code in the database by running:
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url": "https://example.com/my-very-long-url"}'

You'll get back a JSON response with a code field like "code": "abc1234". Copy that code for the next step.

  • Create a file called load-test.yml in your url-shortener folder with the following Artillery test script:
config:
  target: http://localhost:3000
  phases:
    - duration: 10
      arrivalRate: 50
      name: Baseline load test
scenarios:
  - flow:
      - get:
          url: "/[[SHORT_CODE=\"the 7-character code from the previous response\"]]"

What does this test script do?

  • config.target points at your single Express server on port 3000.
  • phases creates 50 new virtual users per second for 10 seconds. Each virtual user opens its own connection.
  • scenarios.flow tells each virtual user to make a GET request to your short code URL, triggering the redirect lookup.
  • Run the baseline load test by running:
artillery run load-test.yml

Artillery prints a report every 10 seconds, then a final summary. Note the http.request_rate (requests/sec) and http.response_time.p99 (99th percentile latency). These are your single-server baseline numbers.

Save your baseline numbers

Write down your baseline numbers somewhere. You'll compare them to the scaled results at the end of this step. A typical single-server baseline on a laptop is roughly 300-800 requests/sec with a p99 between 50-200ms, depending on your machine.

Create the Dockerfile

To run your Express app inside Docker containers, you need a Dockerfile that tells Docker how to package the app into an image.

  • First, stop your local Express server by pressing Ctrl+C in the terminal where it's running.
  • Create a file called Dockerfile in the root of your url-shortener folder with the following content:
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]

What does this Dockerfile do?

Each line builds a layer of the container image:

  • FROM node:20-alpine starts from a lightweight Node.js 20 base image.
  • COPY package.json package-lock.json copies dependency manifests first so Docker can cache the install step.
  • RUN npm ci --omit=dev installs only production dependencies, skipping devDependencies.
  • COPY server.js adds your application code last so code changes don't bust the dependency cache.
  • Save Dockerfile.
  • Confirm the file exists by running:
ls Dockerfile

You should see Dockerfile listed in the output.

Don't see Dockerfile?

  • Make sure you created the file in the project root (the same directory as package.json), not inside a subfolder.
  • The filename must be exactly Dockerfile with a capital D and no file extension.

Why isn't my Dockerfile being recognized?

Create the Nginx configuration

Nginx acts as a reverse proxy that distributes incoming HTTP requests across your 3 Express containers using round-robin load balancing.

  • Create a file called nginx.conf in the root of your url-shortener folder with the following content:
upstream api {
    server api:3000;
}

server {
    listen 80;

    location / {
        proxy_pass http://api;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

What does this config do?

  • upstream api defines a group of backend servers. Docker's built-in DNS resolves the service name api to all 3 container IPs automatically, so Nginx round-robins requests across them.
  • listen 80 means Nginx accepts traffic on port 80.
  • proxy_pass http://api forwards every request to one of the backend containers.
  • Save nginx.conf.
  • Confirm both new files exist by running:
ls Dockerfile nginx.conf

You should see both filenames listed.

Missing nginx.conf?

  • Confirm you saved the file in the project root alongside Dockerfile and package.json.
  • The filename must be nginx.conf (all lowercase, with the .conf extension).

Help me find where my nginx.conf ended up.

Update Docker Compose for horizontal scaling

Your existing docker-compose.yml only defines PostgreSQL and Redis. You need to add the Express API service (built from your Dockerfile) and the Nginx service that load-balances across API replicas.

  • Stop the existing Docker containers by running:
docker compose down
  • Open docker-compose.yml and find the closing lines of the redis service's healthcheck:
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
  • Add the following two services directly below the redis service (before the volumes: section at the bottom):
  api:
    build: .
    environment:
      DATABASE_URL: postgres://appuser:apppass@postgres:5432/shortener
      REDIS_URL: redis://redis:6379
      PORT: 3000
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    deploy:
      replicas: 3

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - api

What do these services do?

  • build: . tells Compose to build the API image from your Dockerfile in the current directory.
  • deploy: replicas: 3 spins up 3 identical containers of the API service. Each one gets a unique hostname, which your server.js already logs via os.hostname().
  • depends_on with condition: service_healthy ensures the API containers only start after PostgreSQL and Redis pass their health checks.
  • The Nginx service mounts your nginx.conf file into the container and exposes port 80 to your machine.
  • Save docker-compose.yml.

✔️ Awesome, I've got everything!

Great. Double check you've saved docker-compose.yml and it contains the postgres, redis, api, and nginx services, plus the volumes: section at the bottom.

ⓧ I'd like to double check the full code

Your docker-compose.yml should contain these sections in order:

  • services: at the top.
  • postgres service with the postgres:16-alpine image, environment variables, ports, volumes, and healthcheck.
  • redis service with the redis:7-alpine image, ports, and healthcheck.
  • api service with build: ., environment variables (DATABASE_URL, REDIS_URL, PORT), depends_on with health conditions, and deploy: replicas: 3.
  • nginx service with the nginx:alpine image, port 80:80, the nginx.conf volume mount, and depends_on: api.
  • volumes: section declaring pgdata.
Bring up the full stack and load test it

Everything is wired up. Time to launch the full stack and prove that all 3 Express instances share the same Redis cache.

  • Build and start all services in detached mode by running:
docker compose up -d --build

What does this command do?

  • --build tells Compose to build the API image from your Dockerfile before starting containers.
  • -d runs everything in detached mode (background), so your terminal stays free.
  • Compose reads deploy: replicas: 3 and spins up 3 API containers, all accessible under the service name api.
  • Wait about 10 seconds for health checks to pass, then verify all containers are running:
docker compose ps

You should see 6 containers: 1 postgres, 1 redis, 3 api replicas, and 1 nginx. All should show a "running" status.

Containers not all running?

  • If API containers show "restarting", check the logs with docker compose logs api to see connection errors.
  • If postgres or redis show "starting", wait a few more seconds for health checks to pass.
  • If you see a port conflict on port 80, make sure nothing else is using that port (stop any local web server).

Why are my API containers restarting?

Update the frontend to point at Nginx

Your React app still talks to http://localhost:3000, but no service publishes port 3000 to your machine anymore. The API replicas listen on 3000 inside the Docker network, and Nginx exposes them on port 80.

  • In client/src/App.jsx, find this line near the top:
const API_URL = 'http://localhost:3000';
  • Change it to point at Nginx on port 80:
const API_URL = 'http://localhost';
  • Save client/src/App.jsx.
  • If the Vite dev server is still running, the page refreshes automatically. If you stopped it earlier, restart it with npm run dev from the client folder.

Why does port 80 work without specifying it?

Port 80 is the default HTTP port. When you visit http://localhost without a port number, the browser sends the request to port 80 automatically. Nginx is listening there and forwards it to one of the 3 API containers.

  • Check the API logs to see all 3 instances starting with unique hostnames:
docker compose logs api

You should see three startup messages, each with a different hostname like Starting instance: a1b2c3d4e5f6. These are the Docker container IDs that your server.js logs via os.hostname().

Now it's time to re-run the same load test against the scaled stack. Do you think the numbers will be better or worse than your single-server baseline?

  • Run the same Artillery test, overriding the target to point at Nginx on port 80:
artillery run --target http://localhost:80 load-test.yml

Compare the final summary to your baseline. With 3 instances sharing the load behind Nginx, you should see higher http.request_rate and lower http.response_time.p99. The improvement comes from Nginx distributing traffic across 3 processes instead of one bottleneck.

  • Confirm requests were distributed across all 3 instances by checking the logs:
docker compose logs api | grep "Cache HIT"

You should see "Cache HIT" messages from 3 different hostnames. Each hostname corresponds to one of your 3 API containers. This proves Nginx is distributing traffic and all instances share the same Redis cache.

Why does this prove the app is stateless?

Your Express server stores nothing in memory. The URL data lives in PostgreSQL. The cache lives in Redis. When Nginx sends a request to any of the 3 instances, that instance can serve the cached response because they all talk to the same Redis.

If the app stored the cache in a local JavaScript variable, each instance would have its own copy, and 2 out of 3 requests would miss. The shared Redis is what makes horizontal scaling work.

🙋‍♀️ Only seeing one hostname in the logs?

  • Run docker compose ps and confirm you see 3 api containers listed.
  • If you only have 1 replica, check that docker-compose.yml has deploy: replicas: 3 under the api service. Then run docker compose up -d --build again.
  • Make sure your api service does NOT have a container_name field. Docker Compose cannot scale a service that has a fixed container name.

Why is my load test only hitting one container?

Your URL shortener is now a horizontally-scaled system. Three Express instances share a Redis cache behind an Nginx load balancer, handling thousands of requests per second on your local machine. Next up, you'll have the chance to deploy this entire stack to a real server with a public IP.

Secret mission

Deploy to AWS EC2 Free Tier

Your URL shortener works locally. But showing someone means sharing your screen. In this mission, you deploy the entire Docker Compose stack to an AWS EC2 instance with a public IP. Then anyone can use your shortener from their own browser.

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. The local Docker containers are free. The EC2 instance (Secret Mission only) is free-tier eligible for 12 months but will incur charges after that period expires.

Cost warning

If you completed the Secret Mission, your EC2 instance is running on AWS free tier. After 12 months or 750 hours/month of usage, you will start incurring charges.

Stop or terminate the instance if you are not actively using it.

Resources you used:

  • Local Docker containers (PostgreSQL, Redis, Nginx, 3x Express API).
  • Docker volume (pgdata) storing PostgreSQL data.
  • Local project files (url-shortener directory on Desktop).
  • Globally installed artillery npm package.
  • EC2 instance on AWS (Secret Mission only).

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the project.

  • Your local Docker containers use minimal system resources. Stop them anytime with docker compose down and restart with docker compose up -d.
  • The artillery package stays globally installed for future load testing.
  • If you completed the Secret Mission, your EC2 instance continues running. Monitor free-tier usage in the AWS Billing console.

Pause - I'll come back to this later

Shut down running processes to free up memory and avoid cloud charges while keeping your files intact.

  • Stop your local Docker containers by running:
cd ~/Desktop/url-shortener
docker compose down

This stops all containers but preserves the pgdata volume so your shortened URLs survive.

  • If you completed the Secret Mission, stop your EC2 instance to avoid compute charges:
  • Open the EC2 console at https://console.aws.amazon.com/ec2/.
  • In the navigation pane, choose Instances.
  • Select your instance, then choose Instance state, then Stop instance.
  • A stopped instance incurs no compute charges. A small EBS storage charge applies.

Delete - I don't want to use this again

Remove all project resources completely and start fresh.

  • Stop and remove all local Docker containers and volumes by running:
cd ~/Desktop/url-shortener
docker compose down -v

The -v flag removes the pgdata volume, deleting all stored URLs.

  • Remove the project folder:

macOS

rm -rf ~/Desktop/url-shortener

Windows

rmdir /s /q $env:USERPROFILE\Desktop\url-shortener
  • Uninstall the globally installed artillery package by running:
npm uninstall -g artillery
  • Terminate your EC2 instance (Secret Mission only):
  • Open the EC2 console at https://console.aws.amazon.com/ec2/.
  • In the navigation pane, choose Instances.
  • Select your instance, then choose Instance state, then Terminate (delete) instance.
  • Choose Terminate (delete) when prompted for confirmation.
  • The instance will show a terminated state briefly, then disappear from your console.

That's a Wrap!

That's a Wrap!

Nice work. You just built a production-grade URL shortener with a live caching layer, horizontally scaled it behind a load balancer, and proved the whole thing holds under pressure.

You've learned how to:

  • Implement the cache-aside pattern with Redis and prove it works using X-Cache headers and a live stats dashboard showing hit rates and response times.
  • Horizontally scale a stateless Express API to 3 replicas behind an Nginx reverse proxy, with all instances sharing a single Redis cache via Docker Compose.
  • Load-test the system with Artillery and observe Redis absorbing hundreds of requests per second across all backend instances.
  • Secret Mission: Deployed the entire Docker Compose stack to an AWS EC2 free-tier instance and accessed the URL shortener via public IP.

Ready to quiz yourself?