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.

Environment Variables — Complete Reference

Copy .env.example to .env and fill in your values before running docker compose up.


#Required Before First Start

These MUST be set or the API container will fail validation and exit:

DB_USERNAME=payday_user
DB_PASSWORD=                  # Choose a strong password
DB_NAME=payday_db
REDIS_PASSWORD=               # Choose a strong password
JWT_ACCESS_SECRET=            # Min 32 chars — generate with: openssl rand -hex 32
JWT_REFRESH_SECRET=           # Min 32 chars — different from access secret
COOKIE_SECRET=                # Min 16 chars — generate with: openssl rand -hex 16

#Full Reference

##App

VariableDefaultDescription
NODE_ENVdevelopmentdevelopment or production
PORT3000NestJS HTTP port (internal to container)
API_PREFIXapi/v1URL prefix for all routes
APP_URLhttp://localhost:3000Full API URL (used in OAuth callbacks)
FRONTEND_URLhttp://localhost:3001Frontend URL (used in CORS + redirect)
ALLOWED_ORIGINShttp://localhost:3000,http://localhost:3001Comma-separated CORS origins

##Database (PostgreSQL)

VariableDefaultDescription
DB_HOSTpostgresDocker service name — do not change
DB_PORT5432PostgreSQL port
DB_USERNAMEpayday_userMust match postgres container env
DB_PASSWORDRequired
DB_NAMEpayday_dbMust match postgres container env
DB_SYNCfalseNever set to true in production
DB_LOGGINGtrueSet false in production
DB_POOL_MIN2Min DB connection pool size
DB_POOL_MAX20Max DB connection pool size

##Redis

VariableDefaultDescription
REDIS_HOSTredisDocker service name — do not change
REDIS_PORT6379Redis port
REDIS_PASSWORDRequired — set in both API and redis container
REDIS_DB0Default DB for app cache
CACHE_TTL300Default cache TTL in seconds

##JWT

VariableDefaultDescription
JWT_ACCESS_SECRETRequired, min 32 chars
JWT_ACCESS_EXPIRES_IN15mAccess token lifetime
JWT_REFRESH_SECRETRequired, min 32 chars, different from access
JWT_REFRESH_EXPIRES_IN30dRefresh token lifetime

##Google OAuth

VariableDescription
GOOGLE_CLIENT_IDFrom Google Cloud Console → OAuth 2.0 credentials
GOOGLE_CLIENT_SECRETFrom Google Cloud Console

Callback URL to register: {APP_URL}/api/v1/auth/google/callback

Dev: http://localhost/api/v1/auth/google/callback

##Apple Sign-In

VariableDescription
APPLE_CLIENT_IDYour Service ID, e.g., com.yourcompany.payday
APPLE_TEAM_ID10-character Team ID from Apple Developer account
APPLE_KEY_IDKey ID from the Sign In with Apple private key
APPLE_PRIVATE_KEYFull PEM contents of the .p8 private key file

Callback URL to register: {APP_URL}/api/v1/auth/apple/callback

Note: Apple requires HTTPS for production callbacks. For dev, use ngrok.

##File Storage

VariableDefaultDescription
STORAGE_PROVIDERlocallocal (dev) or s3 (production)
UPLOAD_PATH./uploadsLocal storage path (inside container)
MAX_FILE_SIZE_MB50Max upload size
AWS_ACCESS_KEY_IDS3 only
AWS_SECRET_ACCESS_KEYS3 only
AWS_REGIONus-east-1S3 only
AWS_S3_BUCKETS3 only
AWS_CLOUDFRONT_URLOptional CDN prefix

##Email

VariableDefault (Dev)Description
MAIL_HOSTmailhogDocker service name — do not change in dev
MAIL_PORT1025Mailhog SMTP port
MAIL_USER(empty)Not needed for Mailhog
MAIL_PASSWORD(empty)Not needed for Mailhog
MAIL_FROMnoreply@paydayapp.comFrom address
MAIL_FROM_NAMEPaydayFrom name

Production SMTP:

MAIL_HOST=smtp.resend.com
MAIL_PORT=465
MAIL_USER=resend
MAIL_PASSWORD=re_your_api_key

##Stripe

VariableDescription
STRIPE_SECRET_KEYsk_test_... in dev, sk_live_... in production
STRIPE_WEBHOOK_SECRETwhsec_... — from Stripe Dashboard → Webhooks
STRIPE_STARTER_PRICE_IDStripe Price ID for Starter plan
STRIPE_PRO_PRICE_IDStripe Price ID for Pro plan
STRIPE_ELITE_PRICE_IDStripe Price ID for Elite plan

##Rate Limiting

VariableDefaultDescription
THROTTLE_TTL60Window in seconds
THROTTLE_LIMIT100Requests per window (global default)
THROTTLE_STARTER100Requests/min for Starter tier
THROTTLE_PRO500Requests/min for Pro tier
THROTTLE_ELITE2000Requests/min for Elite tier

##Security

VariableDefaultDescription
BCRYPT_SALT_ROUNDS12Higher = slower hash. 12 is production-safe
COOKIE_SECRETRequired, signs cookies

##Swagger / API Docs

VariableDefaultDescription
SWAGGER_ENABLEDtrueSet false in production
SWAGGER_PATHdocsURL path: /docs

##Bull / Background Jobs

VariableDefaultDescription
BULL_REDIS_HOSTredisSame as REDIS_HOST
BULL_REDIS_PORT6379Same as REDIS_PORT
BULL_REDIS_PASSWORDSame as REDIS_PASSWORD

##AI (Optional)

VariableDescription
OPENAI_API_KEYsk-... — leave blank to disable AI features

#Generating Secure Secrets

# Generate JWT secrets (run twice for access + refresh)
openssl rand -hex 32

# Generate cookie secret
openssl rand -hex 16

# Generate Redis password
openssl rand -base64 32 | tr -d '=+/' | cut -c1-32

# Generate DB password
openssl rand -base64 32 | tr -d '=+/' | cut -c1-32

#Environment by Context

VariableDev ValueProd Value
NODE_ENVdevelopmentproduction
DB_LOGGINGtruefalse
DB_SYNCfalsefalse
SWAGGER_ENABLEDtruefalse
MAIL_HOSTmailhogsmtp.resend.com
STORAGE_PROVIDERlocals3
STRIPE_SECRET_KEYsk_test_...sk_live_...
APP_URLhttp://localhost:3000https://api.paydayapp.com
~ End of Document ~