A small but complete system: short codes, redirects, click counting, and a clean Docker Compose setup.

URL shorteners look simple until you think about collisions, persistence, and fast redirects. This project gives you a working version you can actually use (or extend).

What you’ll build

  • Create short links (POST /shorten)
  • Redirect (GET /:code)
  • Click counter
  • Redis as the store
  • Docker Compose so everything starts with one command

Project structure

shortener/
├── src/
│   └── index.js
├── Dockerfile
├── docker-compose.yml
├── package.json
└── .env.example

Step 1 – Setup

mkdir shortener && cd shortener
npm init -y
npm install express redis nanoid dotenv cors

Step 2 – The API

src/index.js:

require('dotenv').config();
const express = require('express');
const { createClient } = require('redis');
const { nanoid } = require('nanoid');
const cors = require('cors');

const app = express();
const PORT = process.env.PORT || 3000;
const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;

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

const redis = createClient({
  url: process.env.REDIS_URL || 'redis://localhost:6379'
});

redis.on('error', (err) => console.error('Redis error', err));

async function start() {
  await redis.connect();

  app.post('/shorten', async (req, res) => {
    const { url } = req.body;
    if (!url || !url.startsWith('http')) {
      return res.status(400).json({ error: 'Valid URL required' });
    }

    const code = nanoid(7);
    await redis.hSet(`link:${code}`, {
      url,
      clicks: 0,
      created: Date.now()
    });

    res.json({
      short: `${BASE_URL}/${code}`,
      code
    });
  });

  app.get('/:code', async (req, res) => {
    const { code } = req.params;
    const data = await redis.hGetAll(`link:${code}`);

    if (!data || !data.url) {
      return res.status(404).json({ error: 'Link not found' });
    }

    await redis.hIncrBy(`link:${code}`, 'clicks', 1);
    res.redirect(301, data.url);
  });

  app.get('/stats/:code', async (req, res) => {
    const data = await redis.hGetAll(`link:${req.params.code}`);
    if (!data || !data.url) {
      return res.status(404).json({ error: 'Not found' });
    }
    res.json({
      url: data.url,
      clicks: Number(data.clicks),
      created: new Date(Number(data.created)).toISOString()
    });
  });

  app.get('/health', (req, res) => res.json({ status: 'ok' }));

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

start();

Step 3 – Docker setup

Dockerfile:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
EXPOSE 3000
CMD ["node", "src/index.js"]

docker-compose.yml:

services:
  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data
    restart: unless-stopped

  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - REDIS_URL=redis://redis:6379
      - BASE_URL=http://localhost:3000
      - PORT=3000
    depends_on:
      - redis
    restart: unless-stopped

volumes:
  redis_data:

Step 4 – Run everything

docker compose up --build

Test:

# Create a short link
curl -X POST http://localhost:3000/shorten \
  -H "Content-Type: application/json" \
  -d '{"url":"https://balaramshiwakoti.com.np/tech"}'

# Visit the short link in browser or with curl -L
curl -L http://localhost:3000/<code>

# Check stats
curl http://localhost:3000/stats/<code>

Design decisions worth noticing

DecisionWhy
nanoid(7)Short, URL-safe, collision-resistant enough for personal use
Redis hashFast reads + atomic click increment
301 redirectBrowser/cache friendly for permanent links
Docker ComposeOne command to start both services

Possible next steps

  • Add custom short codes
  • Expiry dates (EXPIRE in Redis)
  • Simple admin UI
  • Rate limiting on /shorten
  • Persist to a real domain with Caddy or Nginx

What you learned

  • How a real (small) system design problem is solved
  • Redis as a primary data store for simple key-value workloads
  • Docker Compose for multi-service apps
  • Clean separation between application and infrastructure

This is small enough to understand completely, yet real enough that you can keep using it.