Skip to content
Return to Projects
Case Study

Payday Chat

A real-time, members-only network for online business founders.

The Problem

Founders needed a private, high-signal place to network, build teams, and scale revenue — not another noisy public feed. Payday Chat is invite-only and built around real-time collaboration.

My Role

Built solo with AI-assisted development — Flutter client, NestJS API, data model, real-time layer, and deployment.

Highlights

  • Real-time messaging with presence and live events over WebSockets
  • One NestJS API powering both a Flutter mobile app and a web client
  • PostgreSQL domain model with Redis for caching and pub/sub fan-out
  • NGINX gateway, Stripe payments, and OAuth authentication

Stack

FlutterDartNestJSPostgreSQLRedisNGINXStripe

Constraints

  • Invite-only product — auth and access control had to be correct from day one.
  • One backend, two clients (Flutter + web) — schema and contracts had to be shared, not duplicated.
  • Realtime is a feature, not a nice-to-have — presence and live events must survive reconnects.

System Architecture

Clients
Flutter Mobile App
Web App
Gateway
NGINX
API
NestJS REST
WebSocket Gateway
Data
PostgreSQL
Redis (cache + pub/sub)
External
Stripe
OAuth

Key Trade-offs

The decisions worth defending — what I chose, what I turned down, and why.

Realtime transport

Chose

WebSockets with Redis pub/sub fan-out

Rejected

Long-polling or third-party realtime SaaS

Predictable latency, no per-message vendor cost, and Redis already in the stack for caching — one fewer moving part.

Mobile client framework

Chose

Flutter (single codebase for iOS + Android)

Rejected

Native Swift + Kotlin clients

Solo build — two native clients would have doubled the surface area and slowed iteration on the API.

API style

Chose

REST + dedicated WebSocket gateway

Rejected

GraphQL subscriptions

Simpler operational story, easier to cache at the NGINX layer, and the realtime channel stays an explicit, observable component.

What I'd Do Differently

An honest retrospective — the stuff I'd change with more time, more users, or a second pass.

  1. 1Introduce contract tests between the NestJS API and the Flutter client earlier — a few breakages were caught only at runtime.
  2. 2Move long-lived sockets to a dedicated process so API deploys don't drop client connections.
  3. 3Add structured event versioning from day one instead of retrofitting it once the schema started moving.

Technical Deep-Dive

Architecture, specifications, and implementation details.

Docker Setup Checklist — Local Development

Work through this top to bottom. Every step is required.


#Prerequisites

  • Docker Desktop installed (Mac/Windows) or Docker Engine (Linux)
  • Docker version 24+ (docker --version)
  • Docker Compose v2 (docker compose version)
  • Node.js 20+ installed locally (for running CLI tools outside container)
  • VS Code with Docker extension installed (optional but useful)
  • Portainer will be available at localhost:9000 for visual container management

#Step 1 — Clone & Setup

  • Clone the repository
  • Navigate to payday-backend/
  • Copy environment file: cp .env.example .env
  • Open .env and fill in the following required fields:
    • DB_PASSWORD — choose any strong password
    • REDIS_PASSWORD — choose any strong password
    • JWT_ACCESS_SECRET — generate: openssl rand -hex 32
    • JWT_REFRESH_SECRET — generate: openssl rand -hex 32 (different from above)
    • COOKIE_SECRET — generate: openssl rand -hex 16

#Step 2 — Start All Containers

docker compose up -d

Expected output: 6 containers starting. Wait ~30 seconds for full health.

  • All 6 containers show running status: docker compose ps
  • Postgres health: docker compose exec postgres pg_isready -U payday_user
  • Redis health: docker compose exec redis redis-cli -a $REDIS_PASSWORD pingPONG

#Step 3 — Verify API

  • API responds: curl http://localhost/health Expected: { "status": "ok", "info": { "database": {...}, "redis": {...} } }
  • Swagger docs load: open http://localhost/docs in browser
  • API root: curl http://localhost/api/v1/ returns platform info

#Step 4 — Verify Database

  • Tables created: docker compose exec postgres psql -U payday_user -d payday_db -c "\dt" You should see ~18 tables listed
  • pgvector loaded: docker compose exec postgres psql -U payday_user -d payday_db -c "SELECT * FROM pg_extension WHERE extname='vector';"
  • Seed data present: docker compose exec postgres psql -U payday_user -d payday_db -c "SELECT email FROM users;" Should show 5 users (admin + 4 test members)

#Step 5 — Verify Redis

  • Connect: docker compose exec redis redis-cli -a $REDIS_PASSWORD
  • Ping: PINGPONG
  • Check databases: INFO keyspace

#Step 6 — Verify Email (Mailhog)

  • Mailhog UI accessible: open http://localhost:8025
  • Test send: curl -X POST http://localhost/api/v1/auth/register -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"TestPass123!","displayName":"Test User"}'
  • Check Mailhog at http://localhost:8025 — welcome email should appear

#Step 7 — Verify Portainer

  • Open http://localhost:9000
  • Create admin account on first visit
  • All 6 containers visible and green

#Step 8 — Test Auth Flow

# Register
curl -X POST http://localhost/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"dev@test.com","password":"DevPass123!","displayName":"Dev User"}'

# Login (copy accessToken from response)
curl -X POST http://localhost/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"dev@test.com","password":"DevPass123!"}'

# Get profile (replace TOKEN with actual accessToken)
curl http://localhost/api/v1/auth/me \
  -H "Authorization: Bearer TOKEN"
  • Register returns accessToken + refreshToken
  • Login returns tokens
  • /auth/me returns user + profile data

#Step 9 — Configure OAuth (If Using)

##Google

  • Go to Google Cloud Console
  • Create project → Enable Google+ API
  • Create OAuth 2.0 Client ID (Web application)
  • Add authorized redirect: http://localhost/api/v1/auth/google/callback
  • Copy GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to .env
  • Restart API: docker compose restart api
  • Test: open http://localhost/api/v1/auth/google in browser

##Apple

  • Apple requires HTTPS — use ngrok for local testing: ngrok http 80 → copy the HTTPS URL
  • Register redirect: https://YOUR-NGROK-URL.ngrok.io/api/v1/auth/apple/callback
  • Set APP_URL=https://YOUR-NGROK-URL.ngrok.io in .env
  • Fill in APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY

#Step 10 — WebSocket Test

Open browser console on any page and run:

const socket = io('http://localhost', {
  auth: { token: 'YOUR_ACCESS_TOKEN' }
});
socket.on('connect', () => console.log('✅ Socket connected:', socket.id));
socket.on('connect_error', (err) => console.log('❌ Error:', err.message));
  • Socket connects successfully (not disconnected)
  • No connect_error events

#Useful Development Commands

# Watch API logs (hot reload active)
docker compose logs -f api

# Full reset (delete ALL data and restart fresh)
docker compose down -v && docker compose up -d

# Rebuild API after package.json changes
docker compose build api && docker compose restart api

# Run a database migration (when you add it)
docker compose exec api npm run migration:run

# Open a psql shell
docker compose exec postgres psql -U payday_user -d payday_db

# Open Redis CLI
docker compose exec redis redis-cli -a YOUR_REDIS_PASSWORD

# Execute any nest command inside container
docker compose exec api npx nest g service my-new-service

#Troubleshooting

ProblemFix
api container crashes immediatelyCheck docker compose logs api — likely missing required env var
postgres container won't startPort 5432 already in use — stop local Postgres or change port in compose
redis AUTH errorREDIS_PASSWORD in .env doesn't match what's in the redis command args
Hot reload not workingMake sure .:/app volume mount is present — only in development target
Tables not createdCheck docker compose logs postgres for SQL errors in init.sql
pgvector not foundMake sure you're using pgvector/pgvector:pg16 image, not plain postgres:16
~ End of Document ~