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.

Security Checklist

Work through every section before accepting real members or payment.


#Secrets & Credentials

  • JWT_ACCESS_SECRET is at least 64 characters, randomly generated
  • JWT_REFRESH_SECRET is at least 64 characters, different from access secret
  • COOKIE_SECRET is at least 32 characters, randomly generated
  • DB_PASSWORD is at least 32 characters with mixed characters
  • REDIS_PASSWORD is at least 32 characters
  • No secrets hardcoded in any source file or committed to Git
  • .env is in .gitignore
  • .env.example has no real values — only placeholder comments

#Authentication

  • Passwords hashed with bcrypt at 12 salt rounds minimum
  • Account lockout after 5 failed login attempts (15 min lock)
  • Refresh tokens are stored as bcrypt hashes (not plaintext)
  • Refresh token rotation on every use (old token invalidated)
  • Max 5 refresh tokens per user (multi-device, prevents accumulation)
  • JWT access token expires in 15 minutes
  • Email verification required before accessing protected features
  • Password reset tokens expire (check password_reset_expires column)
  • OAuth callbacks validate state parameter (CSRF prevention)
  • Apple Sign-In id_token verified with Apple's public keys

#API Security

  • All endpoints require JWT unless explicitly @Public()
  • Admin routes use @Roles('admin') guard — tested with non-admin token
  • Pro/Elite tier routes use @RequireTier() guard — tested with Starter token
  • ValidationPipe with whitelist: true and forbidNonWhitelisted: true (strips extra fields)
  • All UUID params use ParseUUIDPipe (prevents injection via non-UUID IDs)
  • Stripe webhook endpoint validates stripe-signature header
  • File upload endpoint validates MIME type (not just extension)
  • File upload maximum size enforced (50MB)
  • Dangerous file types rejected: .php, .exe, .sh, .py, etc.

#Database Security

  • TypeORM uses parameterized queries for all DB operations (no string concatenation)
  • PostgreSQL not accessible on any public port (no 5432:5432 in prod compose)
  • Database credentials not in version control
  • DB_SYNC=false in production (never auto-drop/recreate tables)
  • Soft deletes used (not hard DELETE) so audit trail is preserved
  • password_hash, refresh_tokens, email_verify_token never returned in API responses
  • User enumeration prevented — password reset returns same message for valid/invalid email

#Redis Security

  • Redis requires password (requirepass set)
  • Redis not accessible on public port (no 6379:6379 in prod compose)
  • Rate limiting keys namespaced per user (not just per IP)

#Network Security

  • Nginx sits in front of the API — direct port 3000 not public-facing
  • UFW firewall only allows ports 22, 80, 443 (and 1935 if streaming)
  • HTTPS enforced — HTTP redirects to HTTPS
  • SSL certificate is valid and auto-renewing
  • Strict-Transport-Security header set (HSTS)
  • X-Frame-Options: SAMEORIGIN set
  • X-Content-Type-Options: nosniff set
  • X-XSS-Protection set
  • CORS origin is set to specific domains (not *)

#Nginx Rate Limiting

  • Global API: 100 req/min per IP
  • Auth endpoints (/auth/login, /auth/register): 10 req/min per IP
  • Upload endpoints: 20 req/min per IP
  • Rate limit responses return 429 with JSON error body

#Content Security

  • User-generated HTML content is sanitized (DOMPurify or similar) before storage
  • File uploads are served from /uploads/ path, not inline executed
  • Upload directory not accessible as PHP/Python/script execution (Nginx config blocks it)
  • Post content is sanitized on read before sending to client

#Stripe / Payments

  • Live Stripe keys (sk_live_) only used in production environment
  • Webhook endpoint verifies stripe-signature using STRIPE_WEBHOOK_SECRET
  • Idempotency keys used on all Stripe API calls that create resources
  • Stripe Customer Portal used for subscription management (never store card data)
  • Payment failure triggers email notification (handled by invoice.payment_failed webhook)

#Docker Security

  • API container runs as non-root user (USER nestjs)
  • No unnecessary ports exposed (only 80/443 via Nginx)
  • Docker socket (/var/run/docker.sock) only mounted in Portainer, not in API container
  • Container images use specific version tags, not latest (for postgres, redis, nginx)
  • Portainer secured with strong password (or removed from production)
  • Log rotation configured on all containers (max-size, max-file)

#Monitoring & Incident Response

  • Health check endpoint monitored externally (UptimeRobot or similar)
  • Error logs accessible: docker compose logs api
  • Database slow query log active (queries > 500ms logged)
  • Alert set up for container crashes
  • Backup and restore procedure tested at least once
  • Know how to roll back a deployment: git revert + redeploy
~ End of Document ~