I built this project as a more serious take on the classic URL shortener. I wanted something that still felt approachable as a portfolio piece, but also gave me room to show system design decisions I actually care about: caching, rate limiting, analytics, deployment, and production debugging.
Live site: https://3.219.205.122/
A lot of URL shortener projects stop at “store a URL and redirect it.” I wanted this one to go further.
My goals were:
- build a backend with a clear separation between routing, services, repositories, and infrastructure
- use Redis for a real performance optimization instead of including it just to list it on the stack
- keep PostgreSQL as the source of truth for URLs and click history
- add analytics and rate limiting so the project feels more like a real product
- deploy it myself to AWS and deal with the operational issues that come with that
- App: https://3.219.205.122/
- Health check: https://3.219.205.122/health
- Shortens long URLs through
POST /shorten - Redirects short codes through
GET /:code - Uses Redis with a cache-aside pattern for hot URL lookups
- Tracks click metadata in PostgreSQL
- Exposes analytics through
GET /analytics/:code - Rate limits requests per IP using Redis
- Serves the React frontend from the Express server in production
| Layer | Technology |
|---|---|
| Backend | Node.js, Express |
| Database | PostgreSQL |
| Cache / rate limiting | Redis |
| Frontend | React, Vite, Tailwind CSS |
| Testing | Jest, Supertest |
| Load testing | autocannon |
| Local infrastructure | Docker Compose |
| Deployment | AWS EC2, Nginx, Let's Encrypt, pm2 |
At a high level, the request flow looks like this:
Client
-> Express API
-> Redis rate limiter
-> POST /shorten -> PostgreSQL
-> GET /:code -> Redis cache -> PostgreSQL fallback
-> async analytics write -> clicks table
-> GET /analytics/:code -> PostgreSQL aggregation
For redirects, I check Redis first. If the short code is already cached, the backend can redirect immediately. If it is not cached, I read from PostgreSQL, return the result, and write it back to Redis with a 24 hour TTL.
I picked this because it keeps PostgreSQL as the source of truth while still making the hot redirect path fast.
This data model is pretty relational:
- one canonical
urlstable - one append-only
clickstable - a clear lookup key in
short_code - straightforward analytics queries like total clicks and recent clicks
PostgreSQL felt like the better fit because I wanted constraints, indexes, and simple SQL aggregations.
Each IP gets a Redis key in the form ratelimit:{ip}. I increment the key on each request, set a 60 second expiration on first touch, and return 429 once the count goes over 10.
I like this approach because it is simple, fast, and easy to scale if the app ever moves beyond a single server.
On redirect, I log analytics asynchronously instead of blocking the redirect on a database write. That keeps the user-facing path fast while still capturing useful metadata.
| Column | Type | Notes |
|---|---|---|
| id | uuid | primary key |
| short_code | varchar | unique |
| original_url | text | original destination |
| created_at | timestamp | creation timestamp |
| Column | Type | Notes |
|---|---|---|
| id | uuid | primary key |
| short_code | varchar | references the shortened URL |
| clicked_at | timestamp | click timestamp |
| user_agent | text | browser/client info |
| ip | varchar | client IP |
backend/
src/
config/
db/
middleware/
repositories/
routes/
services/
tests/
utils/
frontend/
docker/
results/
scripts/
docker-compose.yml
.env
README.md
- Start Redis and PostgreSQL:
docker compose up -d- Run the backend:
npm --prefix backend start- Run the frontend in development mode:
npm --prefix frontend run dev- Or build the frontend and serve everything from Express:
npm run build
npm start- Run tests:
npm test- Run the load test:
npm run loadtestThe local .env is set up so the project runs immediately on this machine:
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/url_shortener
REDIS_URL=redis://localhost:6379
PORT=8080
BASE_URL=http://localhost:8080
CORS_ORIGIN=http://localhost:5173Note: PostgreSQL is mapped to 5433 locally because 5432 was already in use on my machine during setup.
I wrote Jest tests for:
- URL validation
- short-code generation
- cache-aside lookup behavior
- rate limiting
- analytics route behavior
Current test result:
5test suites passed9tests passed
I used autocannon against the redirect endpoint with:
100concurrent connections30second duration
Measured result:
| Metric | Result |
|---|---|
| Average requests/sec | 5998.77 |
| p99 latency | 50 ms |
| Error rate | 0.00% |
| 3xx responses | 179931 |
| 4xx responses | 0 |
| 5xx responses | 0 |
Raw output is in results/loadtest.txt.
I deployed this on an Amazon Linux 2023 EC2 instance in us-east-1.
Production setup:
- Nginx terminating HTTPS on
443and redirecting80to443 - Express backend managed with
pm2 - Redis and PostgreSQL running through Docker Compose on the EC2 host
- React frontend built with Vite and served from the backend
- Let's Encrypt short-lived IP certificate with automated renewal
Deployment-related files:
One thing I actually liked about this project is that deployment was not completely smooth, which made it more realistic:
- I had to fix Docker Compose installation on Amazon Linux 2023
- I had to debug a blank page issue caused by CSP upgrading HTTP asset requests to HTTPS on port
8080 - I had to add an HTTP-safe clipboard fallback because
navigator.clipboardis restricted on non-secure origins - I ended up finishing the deployment with Nginx in front of the app, a real HTTPS certificate for the Elastic IP, and automated certificate renewal
That debugging work ended up being one of the most valuable parts of the project.
If I kept iterating on this, I’d probably add:
- a custom domain
- an ALB or CloudFront in front of the instance
- user accounts or ownership for short links
- expiration policies for links
- more detailed analytics
- CI/CD for automated deployment