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
| Decision | Why |
|---|---|
nanoid(7) | Short, URL-safe, collision-resistant enough for personal use |
| Redis hash | Fast reads + atomic click increment |
| 301 redirect | Browser/cache friendly for permanent links |
| Docker Compose | One command to start both services |
Possible next steps
- Add custom short codes
- Expiry dates (
EXPIREin 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.