Featured
PackYourBag!
Building a Production-Grade Distributed System
A deliberately over-engineered packing app — and why that was the point.

The Problem
As an avid backpacker and long-distance hiker, I've always relied on spreadsheets to manage gear lists. When you're out in the wilderness, finding out you forgot some essential gear is simply not an option. Before each trip, I'd copy the spreadsheet, prune it, extend it, and manually tick items off. It worked, but it didn't scale.
PackYourBag replaces that workflow with a hierarchical data model: individual Items compose into reusable Lists, Lists combine into Packs, and a Pack gets assigned to a Tripwith an interactive check-off when it's time to leave.
The architecture is deliberately modular: the luggage planner is an MVP built on top of a platform. Adding capabilities to an existing service doesn't touch authentication. Spinning up an entirely new service means plugging into the same auth, observability, and deployment infrastructure that's already proven and running.
Why Over-Engineer a Packing App?
The honest answer: because the packing list is a vehicle for the engineering. The core functionality could ship as a single-service CRUD app. Instead, I used it as an opportunity to build a platform allowing for further extension and scaling, implementing the patterns you'd find in production distributed systems: microservice orchestration, stateless asymmetric authentication, polyglot persistence, and a full CI/CD pipeline deploying to real infrastructure.
Architecture at a Glance
The system follows a Backend-for-Frontend (BFF) pattern: a Next.js frontend acts as the sole entry point, orchestrating three independent NestJS microservices through typed HTTP clients generated from OpenAPI specs.

| Layer | Technology | Role |
|---|---|---|
| Frontend & BFF | Next.js (React Server Components) | UI + API orchestration |
| Auth Service | NestJS | RS256 JWT issuance, account lifecycle |
| Product Service | NestJS + PostgreSQL | Core packing data (items, lists, packs, trips) |
| User Data Service | NestJS + MongoDB | User preferences and settings |
| Infrastructure | Docker, Caddy, GitHub Actions | Containerized deployment on Hetzner VPS |
Services are independently deployable and share nothing at the data layer. PostgreSQL uses logical schema separation with per-service database roles enforcing least-privilege access. MongoDB handles the User Data Service where schema flexibility matters more than relational integrity.
Authentication & Security
Authentication was the first thing I built, not the last. The Auth Service issues RS256 JWT pairs where the private key never leaves the service. Downstream services verify tokens locally using the public key, which means the Auth Service isn't a latency bottleneck on every request.
The refresh token system implements family rotation with reuse detection: every refresh invalidates its predecessor and issues a new token. If a revoked token gets reused (indicating theft), the entire token family is revoked instantly. A grace period handles legitimate race conditions from concurrent requests.
Beyond tokens: a BFF guard (shared-secret header that blocks direct API access), intelligent throttling, and GDPR-compliant audit logging with anonymized IPs. Sentry handles production error tracking, configured to strip PII before it ever leaves the server.
API Contracts: One Source of Truth
I initially planned to maintain shared TypeScript types between services and frontend manually. Digging into the NestJS Swagger integration, I found I could generate an OpenAPI spec from the existing endpoint annotations, and from that spec, automatically generate typed HTTP clients, TypeScript interfaces, and Zod validation schemas.
The result: backend contract changes propagate to frontend types and form validation automatically. No manual type maintenance, no drift between what the API expects and what the UI sends.
Frontend: Separating UI From Logic
The UI layer follows a strict separation of concerns. UI components live in a shared package (@repo/react-common) and are purely presentational: no data fetching, no API calls, no internal state beyond controlled inputs. Data flows in through typed props; interactions propagate upward through callbacks.
Each component has a colocated Storybook story covering happy paths, visual variants, and edge cases. This replaced the traditional dev cycle of spinning up the full stack just to verify a visual change. In the Next.js app, Server Components prefetch data with Suspense boundaries and skeleton fallbacks, keeping the loading waterfall on the server.
The design system supports light and dark mode, and entities use a 10-color palette for visual differentiation, all driven by props, without touching component internals.

Deployment & Operations
The app runs on a Hetzner VPS with all services containerized behind Caddy as a reverse proxy with automatic TLS. The CI/CD pipeline uses path-based change detection: a change to the Auth Service won't trigger a frontend rebuild.
Key operational decisions:
- Database migrations run in an isolated container. Application containers never hold the credentials to modify database schemas.
- GitHub Secrets are the single source of truth for production config, nothing is stored on the VPS outside the deploy process.
- Prometheus + Grafana for metrics, Uptime Kuma and UptimeRobot for health monitoring, scheduled backups syncing to object storage.
What I'd Do Differently
No project is finished, and some decisions would change with hindsight. The shared NestJS infrastructure package (@repo/nestjs-common) was extracted during Phase 2, after building the Auth Service from scratch. That extraction surfaced issues: database migrations had been running under the wrong service role, and untangling the permissions took more effort than building the shared package itself. Starting with the shared package from day one would have avoided that detour entirely.
The asymmetric key pair implementation was designed to keep services fully independent, no service ever needs to call another to verify a request. Implementing the user deletion cascade broke that pattern by introducing synchronous internal API calls between services. The next step is extracting a dedicated event service with a message queue, letting services react to events like audit logging, account deletion, and guest data seeding asynchronously, removing the direct coupling entirely.
The frontend shipped without tests. I should have accounted for it in the project timeline. I didn't, and when the schedule got tight, it was the first thing to go. It gave me firsthand understanding of how test coverage gets deprioritized under deadline pressure, even when you know better.
Stack
TypeScript, Next.js, React (Server Components), NestJS, PostgreSQL, MongoDB, Prisma, Mongoose, Vitest, Storybook, Docker, Turborepo, GitHub Actions, Sentry
For a deeper dive into the architecture and development process, check out the code and README in the project repository. Or just start packing!
Screenshots





















