A production-ready URL shortening service built with NestJS, PostgreSQL, and Redis — fully containerized with Docker Compose. Features custom aliases, click tracking, GeoIP analytics, TTL-based expiry, and per-user rate limiting.
┌─────────────────────────────────────────────────────┐
│ Client / Browser │
└────────────────────────┬────────────────────────────┘
│ HTTP
▼
┌─────────────────────────────────────────────────────┐
│ NestJS API (port 3000) │
│ │
│ ┌──────────────┐ ┌─────────────┐ ┌────────────┐ │
│ │ Auth Module │ │ URL Module │ │ Stats Module│ │
│ │ JWT + Guard │ │ Shorten / │ │ Analytics │ │
│ │ │ │ Redirect │ │ Endpoint │ │
│ └──────────────┘ └──────┬──────┘ └────────────┘ │
│ │ │
│ ┌────────────┴──────────┐ │
│ │ Redis Cache │ │
│ │ (TTL + Rate Limit) │ │
│ └────────────┬──────────┘ │
│ │ cache miss │
│ ┌────────────▼──────────┐ │
│ │ PostgreSQL │ │
│ │ urls · clicks · users│ │
│ └───────────────────────┘ │
└─────────────────────────────────────────────────────┘
- URL Shortening — Generate a short code automatically or provide a custom alias
- Instant Redirect — Sub-5ms redirects powered by Redis cache
- Click Analytics — Track every click: timestamp, IP, country, city, browser, OS, referrer
- GeoIP Lookup — Resolve country and city from visitor IP using
geoip-lite - TTL / Link Expiry — Set an expiration date per link; expired links return 410 Gone
- User Accounts — Register, login, manage your own links with JWT auth
- Rate Limiting — Throttle shortening requests per user via Redis sliding window
- Swagger Docs — Full OpenAPI 3.0 documentation at
/api/docs - Health Check —
/healthendpoint reports DB and Redis status - Docker Compose — One command spins up the entire stack
| Layer | Technology | Why |
|---|---|---|
| Framework | NestJS 10 + TypeScript | Modular, decorator-driven architecture |
| Database | PostgreSQL 15 + TypeORM | Relational data, migrations support |
| Cache | Redis 7 | Sub-millisecond reads for redirects |
| Auth | JWT + Passport.js | Stateless, scalable authentication |
| GeoIP | geoip-lite | Offline IP-to-location resolution |
| Validation | class-validator + class-transformer | DTO-level request validation |
| Docs | @nestjs/swagger | Auto-generated from decorators |
| Container | Docker + Docker Compose | Reproducible dev & prod environments |
| Testing | Jest + Supertest | Unit + E2E test coverage |
snapurl/
├── src/
│ ├── main.ts # Bootstrap, Swagger setup
│ ├── app.module.ts # Root module
│ │
│ ├── auth/
│ │ ├── auth.module.ts
│ │ ├── auth.controller.ts # POST /auth/register, /auth/login
│ │ ├── auth.service.ts
│ │ ├── jwt.strategy.ts
│ │ ├── jwt-auth.guard.ts
│ │ └── dto/
│ │ ├── register.dto.ts
│ │ └── login.dto.ts
│ │
│ ├── urls/
│ │ ├── urls.module.ts
│ │ ├── urls.controller.ts # POST /urls, GET /:code, DELETE /urls/:id
│ │ ├── urls.service.ts # Core shorten + redirect logic
│ │ ├── urls.repository.ts # TypeORM custom queries
│ │ ├── entities/
│ │ │ └── url.entity.ts
│ │ └── dto/
│ │ ├── create-url.dto.ts
│ │ └── url-response.dto.ts
│ │
│ ├── clicks/
│ │ ├── clicks.module.ts
│ │ ├── clicks.service.ts # Record click + GeoIP resolve
│ │ └── entities/
│ │ └── click.entity.ts
│ │
│ ├── stats/
│ │ ├── stats.module.ts
│ │ ├── stats.controller.ts # GET /stats/:code
│ │ └── stats.service.ts # Aggregate analytics queries
│ │
│ ├── cache/
│ │ ├── cache.module.ts
│ │ └── cache.service.ts # Redis wrapper (get/set/del/ttl)
│ │
│ └── common/
│ ├── guards/
│ │ └── throttle.guard.ts # Redis-backed rate limiter
│ ├── interceptors/
│ │ └── logging.interceptor.ts
│ └── filters/
│ └── http-exception.filter.ts
│
├── test/
│ ├── app.e2e-spec.ts
│ └── urls.service.spec.ts
│
├── docker/
│ ├── Dockerfile
│ └── Dockerfile.dev
│
├── docker-compose.yml
├── docker-compose.prod.yml
├── .env.example
├── .env.test
└── README.md
- Docker & Docker Compose v2+
- Node.js 18+ (only needed if running outside Docker)
git clone https://github.com/yourusername/snapurl.git
cd snapurlcp .env.example .env
# Edit .env with your values (see Environment Variables section below)docker-compose up --buildThat's it. The API is live at http://localhost:3000.
| Service | URL |
|---|---|
| API | http://localhost:3000 |
| Swagger docs | http://localhost:3000/api/docs |
| Health check | http://localhost:3000/health |
# ── Stage 1: build ──────────────────────────────────────
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ── Stage 2: production image ───────────────────────────
FROM node:18-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/main"]FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "start:dev"]version: "3.9"
services:
app:
build:
context: .
dockerfile: docker/Dockerfile.dev
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
environment:
- NODE_ENV=development
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
postgres:
image: postgres:15-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
volumes:
postgres_data:
redis_data:version: "3.9"
services:
app:
build:
context: .
dockerfile: docker/Dockerfile
target: production
ports:
- "3000:3000"
environment:
- NODE_ENV=production
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: always
postgres:
image: postgres:15-alpine
expose:
- "5432" # not exposed to host in prod
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
restart: always
redis:
image: redis:7-alpine
expose:
- "6379" # not exposed to host in prod
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 3s
retries: 5
restart: always
volumes:
postgres_data:
redis_data:# Build and start with prod config
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
# View logs
docker-compose logs -f app
# Rebuild app only after a code change
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build appCopy .env.example to .env:
# ── App ──────────────────────────────────────────────
PORT=3000
BASE_URL=http://localhost:3000
NODE_ENV=development
# ── JWT ──────────────────────────────────────────────
JWT_SECRET=change_me_to_something_long_and_random
JWT_EXPIRES_IN=7d
# ── PostgreSQL ───────────────────────────────────────
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=snapurl
POSTGRES_USER=snapurl_user
POSTGRES_PASSWORD=supersecretpassword
# ── Redis ────────────────────────────────────────────
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_TTL=3600
# ── Rate Limiting ────────────────────────────────────
THROTTLE_TTL=60
THROTTLE_LIMIT=20POST /auth/register
Content-Type: application/json
{
"email": "user@example.com",
"password": "StrongPass123!"
}Response 201:
{
"id": "uuid",
"email": "user@example.com",
"createdAt": "2025-01-15T10:00:00Z"
}POST /auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "StrongPass123!"
}Response 200:
{ "accessToken": "eyJhbGciOiJIUzI1NiIs..." }POST /urls
Authorization: Bearer <token>
Content-Type: application/json
{
"originalUrl": "https://example.com/very/long/path?with=params",
"alias": "my-link",
"expiresAt": "2025-12-31"
}Response 201:
{
"id": "uuid",
"shortCode": "my-link",
"shortUrl": "http://localhost:3000/my-link",
"originalUrl": "https://example.com/very/long/path?with=params",
"expiresAt": "2025-12-31T00:00:00Z",
"createdAt": "2025-01-15T10:30:00Z"
}GET /:codeReturns 301 redirect. Returns 404 if not found, 410 if expired.
GET /urls
Authorization: Bearer <token>Response 200:
[
{
"id": "uuid",
"shortCode": "my-link",
"shortUrl": "http://localhost:3000/my-link",
"originalUrl": "https://...",
"totalClicks": 142,
"expiresAt": null,
"createdAt": "2025-01-15T10:30:00Z"
}
]DELETE /urls/:id
Authorization: Bearer <token>Response 204 No Content
GET /stats/:code
Authorization: Bearer <token>Response 200:
{
"shortCode": "my-link",
"originalUrl": "https://...",
"totalClicks": 142,
"uniqueIps": 98,
"clicksByDay": [
{ "date": "2025-01-14", "count": 23 },
{ "date": "2025-01-15", "count": 41 }
],
"topCountries": [
{ "country": "Egypt", "count": 55 },
{ "country": "Saudi Arabia", "count": 32 }
],
"topReferrers": [
{ "referrer": "twitter.com", "count": 60 },
{ "referrer": "direct", "count": 45 }
],
"browsers": {
"Chrome": 88,
"Safari": 30,
"Firefox": 14
}
}| Column | Type | Notes |
|---|---|---|
| id | UUID | PK |
| VARCHAR | unique | |
| passwordHash | VARCHAR | bcrypt |
| createdAt | TIMESTAMP |
| Column | Type | Notes |
|---|---|---|
| id | UUID | PK |
| shortCode | VARCHAR(20) | unique, indexed |
| originalUrl | TEXT | |
| userId | UUID | FK → users |
| expiresAt | TIMESTAMP | nullable |
| createdAt | TIMESTAMP |
| Column | Type | Notes |
|---|---|---|
| id | UUID | PK |
| urlId | UUID | FK → urls, indexed |
| ip | VARCHAR | |
| country | VARCHAR | GeoIP resolved |
| city | VARCHAR | GeoIP resolved |
| browser | VARCHAR | parsed user-agent |
| os | VARCHAR | parsed user-agent |
| referrer | VARCHAR | nullable |
| clickedAt | TIMESTAMP | indexed |
private generateCode(length = 7): string {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let code = '';
for (let i = 0; i < length; i++) {
code += chars[Math.floor(Math.random() * chars.length)];
}
return code;
}GET /:code
│
├─▶ Redis GET snapurl:<code>
│ │
│ ├── HIT ──────────────────────▶ 301 redirect (< 5ms)
│ │
│ └── MISS ──▶ PostgreSQL SELECT
│ │
│ ├── found ──▶ Redis SET (TTL 1h) ──▶ 301 redirect
│ └── not found ──▶ 404
// throttle.guard.ts — simplified logic
const key = `throttle:${userId}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, THROTTLE_TTL);
if (count > THROTTLE_LIMIT) throw new TooManyRequestsException();# Unit tests
npm run test
# E2E tests (requires running Docker services)
npm run test:e2e
# Coverage report
npm run test:covRun tests inside Docker:
docker-compose exec app npm run test
docker-compose exec app npm run test:e2e# Start dev with hot reload
docker-compose up
# Start production
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
# Stop everything
docker-compose down
# Full reset (deletes volumes/data)
docker-compose down -v
# View live logs
docker-compose logs -f app
# Open psql shell
docker-compose exec postgres psql -U snapurl_user -d snapurl
# Open Redis CLI
docker-compose exec redis redis-cli
# Run DB migrations
docker-compose exec app npm run migration:run
# Generate new migration
docker-compose exec app npm run migration:generate -- src/migrations/AddIndexToClicks- QR code generation per short link
- Link preview / Open Graph metadata
- Password-protected links
- Bulk URL import via CSV
- Webhook on click event
- Admin dashboard (React + Next.js)
- Prometheus + Grafana metrics
MIT License — see LICENSE for details.
Nest framework TypeScript starter repository.
$ npm install# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:covWhen you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the deployment documentation for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out Mau, our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
$ npm install -g @nestjs/mau
$ mau deployWith Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
Check out a few resources that may come in handy when working with NestJS:
- Visit the NestJS Documentation to learn more about the framework.
- For questions and support, please visit our Discord channel.
- To dive deeper and get more hands-on experience, check out our official video courses.
- Deploy your application to AWS with the help of NestJS Mau in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using NestJS Devtools.
- Need help with your project (part-time to full-time)? Check out our official enterprise support.
- To stay in the loop and get updates, follow us on X and LinkedIn.
- Looking for a job, or have a job to offer? Check out our official Jobs board.
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please read more here.
- Author - Kamil Myśliwiec
- Website - https://nestjs.com
- Twitter - @nestframework
Nest is MIT licensed.