A deliberately small 3-tier demo app, built to be a testbed for real infra concepts — not just a CRUD project. The UI is intentionally polished (vibe-coded, no shame in that); the backend is the part meant to be read, modified, and rebuilt on by hand as you go through the roadmap.
linkforge/
├── frontend/ # Tier 1 — React (Vite), deploy build output on its own droplet behind Nginx
├── backend/ # Tier 2 — Spring Boot REST API, deploy on its own droplet(s)
└── (DB not included) # Tier 3 — Postgres, its own droplet — see Day 10+ below
The backend defaults to an in-memory H2 database, so it runs with zero setup — good for sanity-checking the code works before you touch a server.
cd backend
mvn spring-boot:runFrontend (React + Vite):
cd frontend
npm install
npm run devOpens on http://localhost:5173 by default and talks to http://localhost:8080.
To point it at a real backend later, set the env var before building/running:
VITE_API_BASE=http://YOUR_BACKEND_IP:8080 npm run buildThe build output goes to frontend/dist/ — that's what you copy onto the frontend
Droplet and serve with Nginx (just static files, no Node needed to run it in prod,
only to build it).
| Method | Path | Auth required? | Purpose |
|---|---|---|---|
| POST | /api/auth/register |
No | Create an account |
| POST | /api/auth/login |
No | Step 1 of login (password check) |
| POST | /api/auth/2fa/verify |
No* | Step 2 of login (TOTP code) *needs pendingToken |
| POST | /api/auth/2fa/enable |
Yes | Start 2FA enrollment, get secret + QR URL |
| POST | /api/auth/2fa/confirm |
Yes | Finish enrollment by proving a code works |
| POST | /api/links |
Yes | Create a short link — { "longUrl": "..." } |
| GET | /r/{shortCode} |
No | Redirect (the hot path — cache & LB this) |
| GET | /api/links/{code}/stats |
No | Fetch click count for a code |
Login without 2FA enabled:
POST /api/auth/login {username, password}
→ { requires2fa: false, token: "<JWT>" }
Use that token as Authorization: Bearer <JWT> on protected endpoints.
Login with 2FA enabled:
POST /api/auth/login {username, password}
→ { requires2fa: true, pendingToken: "<short-lived JWT>" }
POST /api/auth/2fa/verify {pendingToken, code}
→ { requires2fa: false, token: "<JWT>" }
The pendingToken is deliberately useless for anything except /2fa/verify — it has
a purpose: pending_2fa claim, and JwtAuthFilter only accepts tokens with
purpose: access. This is the actual security property: a leaked password alone
never produces a working session token.
Enrolling in 2FA (once logged in):
POST /api/auth/2fa/enable (needs Authorization header)
→ { secret: "BASE32SECRET", otpauthUrl: "otpauth://totp/..." }
Feed otpauthUrl into any QR generator (e.g. qrencode or an online tool) and scan
it with Google Authenticator / Authy, or type secret in manually. Then:
POST /api/auth/2fa/confirm {code} (needs Authorization header)
Only after this call succeeds does totpEnabled flip to true — enrolling doesn't
silently turn on 2FA, you have to prove the code actually works first.
Testing the whole flow with curl, no frontend needed:
curl -X POST localhost:8080/api/auth/register -H 'Content-Type: application/json' \
-d '{"username":"you","email":"[email protected]","password":"password123"}'
curl -X POST localhost:8080/api/auth/login -H 'Content-Type: application/json' \
-d '{"username":"you","password":"password123"}'
# copy the token, then:
curl -X POST localhost:8080/api/links -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <token>' -d '{"longUrl":"https://example.com"}'Enforcement toggle: app.security.enabled in application.properties controls
whether /api/links actually requires login. It defaults to false right now,
so your frontend and the Day 1-9 deployment/proxy/LB stages work exactly as before —
no login screen needed yet. The full auth + 2FA code is still there and testable via
curl regardless of the flag (see below). Flip it to true once you're ready to wire
a login UI into the frontend, or just want to practice with real enforcement on.
- Day 1-3, manual deploy:
backend→ one Droplet,frontend→ a second Droplet (or same box, different port, behind Nginx path routing — your call). - Day 4-5, reverse/forward proxy + rate limiting: put Nginx in front of the backend.
Rate-limit
POST /api/links(creation) — do not rate-limitGET /r/{code}(redirects should stay fast and open). - Day 6-9, load balancer + algorithms + load testing: run 2+ backend instances,
point
wrk/ab/heyatGET /r/{code}since that's the real hot path. - Day 10-14, DB, pooling, caching: switch
application.propertiesfrom H2 to Postgres (the commented block is ready to go), add PgBouncer/HikariCP tuning, then put Redis in front of theshortCode → longUrllookup inShortenerService.resolve(). - Day 15-17, Kafka: replace the synchronous
clickEventRepository.save(...)call inShortenerService.resolve()with a Kafka producer call, and move that save into a consumer. That single line is the whole "decouple the write path" lesson.
- Kafka producer/consumer wiring
- Redis cache-aside logic in
resolve() - Rate limiting config (Nginx-level, not app-level, per the roadmap)
- Postgres schema migration (JPA
ddl-auto=updateis fine for now, but a real project would use Flyway/Liquibase — worth trying once the basics are solid) - Sharding logic if you get to it —
shortCodeprefix is a reasonable shard key to try