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
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
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.
- 1Introduce contract tests between the NestJS API and the Flutter client earlier — a few breakages were caught only at runtime.
- 2Move long-lived sockets to a dedicated process so API deploys don't drop client connections.
- 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
| Variable | Default | Description |
|---|---|---|
NODE_ENV | development | development or production |
PORT | 3000 | NestJS HTTP port (internal to container) |
API_PREFIX | api/v1 | URL prefix for all routes |
APP_URL | http://localhost:3000 | Full API URL (used in OAuth callbacks) |
FRONTEND_URL | http://localhost:3001 | Frontend URL (used in CORS + redirect) |
ALLOWED_ORIGINS | http://localhost:3000,http://localhost:3001 | Comma-separated CORS origins |
##Database (PostgreSQL)
| Variable | Default | Description |
|---|---|---|
DB_HOST | postgres | Docker service name — do not change |
DB_PORT | 5432 | PostgreSQL port |
DB_USERNAME | payday_user | Must match postgres container env |
DB_PASSWORD | — | Required |
DB_NAME | payday_db | Must match postgres container env |
DB_SYNC | false | Never set to true in production |
DB_LOGGING | true | Set false in production |
DB_POOL_MIN | 2 | Min DB connection pool size |
DB_POOL_MAX | 20 | Max DB connection pool size |
##Redis
| Variable | Default | Description |
|---|---|---|
REDIS_HOST | redis | Docker service name — do not change |
REDIS_PORT | 6379 | Redis port |
REDIS_PASSWORD | — | Required — set in both API and redis container |
REDIS_DB | 0 | Default DB for app cache |
CACHE_TTL | 300 | Default cache TTL in seconds |
##JWT
| Variable | Default | Description |
|---|---|---|
JWT_ACCESS_SECRET | — | Required, min 32 chars |
JWT_ACCESS_EXPIRES_IN | 15m | Access token lifetime |
JWT_REFRESH_SECRET | — | Required, min 32 chars, different from access |
JWT_REFRESH_EXPIRES_IN | 30d | Refresh token lifetime |
##Google OAuth
| Variable | Description |
|---|---|
GOOGLE_CLIENT_ID | From Google Cloud Console → OAuth 2.0 credentials |
GOOGLE_CLIENT_SECRET | From 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
| Variable | Description |
|---|---|
APPLE_CLIENT_ID | Your Service ID, e.g., com.yourcompany.payday |
APPLE_TEAM_ID | 10-character Team ID from Apple Developer account |
APPLE_KEY_ID | Key ID from the Sign In with Apple private key |
APPLE_PRIVATE_KEY | Full 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
| Variable | Default | Description |
|---|---|---|
STORAGE_PROVIDER | local | local (dev) or s3 (production) |
UPLOAD_PATH | ./uploads | Local storage path (inside container) |
MAX_FILE_SIZE_MB | 50 | Max upload size |
AWS_ACCESS_KEY_ID | — | S3 only |
AWS_SECRET_ACCESS_KEY | — | S3 only |
AWS_REGION | us-east-1 | S3 only |
AWS_S3_BUCKET | — | S3 only |
AWS_CLOUDFRONT_URL | — | Optional CDN prefix |
| Variable | Default (Dev) | Description |
|---|---|---|
MAIL_HOST | mailhog | Docker service name — do not change in dev |
MAIL_PORT | 1025 | Mailhog SMTP port |
MAIL_USER | (empty) | Not needed for Mailhog |
MAIL_PASSWORD | (empty) | Not needed for Mailhog |
MAIL_FROM | noreply@paydayapp.com | From address |
MAIL_FROM_NAME | Payday | From name |
Production SMTP:
MAIL_HOST=smtp.resend.com
MAIL_PORT=465
MAIL_USER=resend
MAIL_PASSWORD=re_your_api_key
##Stripe
| Variable | Description |
|---|---|
STRIPE_SECRET_KEY | sk_test_... in dev, sk_live_... in production |
STRIPE_WEBHOOK_SECRET | whsec_... — from Stripe Dashboard → Webhooks |
STRIPE_STARTER_PRICE_ID | Stripe Price ID for Starter plan |
STRIPE_PRO_PRICE_ID | Stripe Price ID for Pro plan |
STRIPE_ELITE_PRICE_ID | Stripe Price ID for Elite plan |
##Rate Limiting
| Variable | Default | Description |
|---|---|---|
THROTTLE_TTL | 60 | Window in seconds |
THROTTLE_LIMIT | 100 | Requests per window (global default) |
THROTTLE_STARTER | 100 | Requests/min for Starter tier |
THROTTLE_PRO | 500 | Requests/min for Pro tier |
THROTTLE_ELITE | 2000 | Requests/min for Elite tier |
##Security
| Variable | Default | Description |
|---|---|---|
BCRYPT_SALT_ROUNDS | 12 | Higher = slower hash. 12 is production-safe |
COOKIE_SECRET | — | Required, signs cookies |
##Swagger / API Docs
| Variable | Default | Description |
|---|---|---|
SWAGGER_ENABLED | true | Set false in production |
SWAGGER_PATH | docs | URL path: /docs |
##Bull / Background Jobs
| Variable | Default | Description |
|---|---|---|
BULL_REDIS_HOST | redis | Same as REDIS_HOST |
BULL_REDIS_PORT | 6379 | Same as REDIS_PORT |
BULL_REDIS_PASSWORD | — | Same as REDIS_PASSWORD |
##AI (Optional)
| Variable | Description |
|---|---|
OPENAI_API_KEY | sk-... — 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
| Variable | Dev Value | Prod Value |
|---|---|---|
NODE_ENV | development | production |
DB_LOGGING | true | false |
DB_SYNC | false | false |
SWAGGER_ENABLED | true | false |
MAIL_HOST | mailhog | smtp.resend.com |
STORAGE_PROVIDER | local | s3 |
STRIPE_SECRET_KEY | sk_test_... | sk_live_... |
APP_URL | http://localhost:3000 | https://api.paydayapp.com |