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.

Payday — Docker Architecture

#Overview

All Payday services run inside Docker containers orchestrated with Docker Compose. In development: docker compose up -d starts everything. In production: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d


#Container Inventory

ContainerImageRoleDev PortProd Exposed
payday-apinode:20-alpine (custom build)NestJS backend API3000No (behind Nginx)
payday-postgrespgvector/pgvector:pg16Primary database5432No
payday-redisredis:7.2-alpineCache + pub/sub + queues6379No
payday-nginxnginx:1.25-alpineReverse proxy80, 443Yes
payday-mailhogmailhog/mailhogEmail catcher (dev only)1025, 8025No
payday-portainerportainer/portainer-ceDocker management UI9000Optional

#Docker Network

All containers share one bridge network: payday-network

Subnet: 172.20.0.0/24

payday-postgres    → 172.20.0.10
payday-redis       → 172.20.0.11
payday-api         → 172.20.0.20
payday-nginx       → 172.20.0.30
payday-mailhog     → 172.20.0.42
payday-portainer   → 172.20.0.43

Containers talk to each other by service name (Docker DNS):

  • API connects to Postgres: host: postgres
  • API connects to Redis: host: redis
  • API sends email to: host: mailhog, port: 1025

#Docker Volumes

Volume NameMounted ToPurpose
postgres_data/var/lib/postgresql/dataPostgreSQL data persistence
redis_data/dataRedis AOF persistence
uploads_data/app/uploads (api) + /var/www/uploads (nginx)User uploaded files
nginx_logs/var/log/nginxAccess and error logs
portainer_data/dataPortainer state

#Container: payday-api

##Dockerfile (Multi-Stage)

# Stage 1 — base: shared OS dependencies
FROM node:20-alpine AS base
RUN apk add --no-cache python3 make g++ curl vips-dev
WORKDIR /app
COPY package*.json tsconfig*.json nest-cli.json ./

# Stage 2 — deps: install all dependencies
FROM base AS deps
RUN npm ci --include=dev

# Stage 3 — builder: compile TypeScript to /dist
FROM deps AS builder
COPY src ./src
RUN npm run build && npm prune --omit=dev

# Stage 4 — development: hot-reload with source binding
FROM deps AS development
ENV NODE_ENV=development
COPY . .
RUN mkdir -p /app/uploads
EXPOSE 3000 9229
CMD ["node", "--inspect=0.0.0.0:9229", "node_modules/.bin/nest", "start", "--watch"]

# Stage 5 — production: lean final image (~180MB)
FROM base AS production
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
RUN mkdir -p /app/uploads && adduser -S nestjs && chown -R nestjs /app
USER nestjs
HEALTHCHECK --interval=30s --timeout=10s --retries=3 CMD curl -f http://localhost:3000/health
EXPOSE 3000
CMD ["node", "dist/main"]

##Volume Binds (Development Only)

  • Source code .:/app — enables hot reload
  • /app/node_modules — anonymous volume prevents overwriting container's node_modules
  • uploads_data:/app/uploads — shared with Nginx for file serving

##Key Environment Variables

DB_HOST=postgres
REDIS_HOST=redis
MAIL_HOST=mailhog
NODE_ENV=development

#Container: payday-postgres

##Image: pgvector/pgvector:pg16

This image has pgvector pre-installed. No manual extension setup needed.

##Init Scripts (run once on first start)

/docker-entrypoint-initdb.d/
  01-init.sql    → Creates extensions, enums, all 18 tables, indexes, triggers, default channels
  02-seed.sql    → Dev seed data (admin user + 4 sample members + sample posts)

##PostgreSQL Tuning (passed via command args)

max_connections=200
shared_buffers=256MB
effective_cache_size=512MB
work_mem=4MB
maintenance_work_mem=64MB
log_min_duration_statement=500   # log queries slower than 500ms

##Health Check

pg_isready -U payday_user -d payday_db

#Container: payday-redis

##Image: redis:7.2-alpine

##Redis Databases

DB 0 — Application cache (member profiles, feed metadata)
DB 1 — Session store (optional)
DB 2 — Rate limiting (ThrottlerGuard)
DB 3 — BullMQ job queues
DB 4 — Socket.io pub/sub (Redis adapter)
DB 5 — Leaderboard (sorted sets)

##Key Settings

maxmemory 256mb
maxmemory-policy allkeys-lru     # Evict least recently used when full
appendonly yes                   # AOF persistence
appendfsync everysec             # Flush to disk every second (balance of safety/perf)
requirepass <REDIS_PASSWORD>     # Always use password, even in dev

##Health Check

redis-cli -a $REDIS_PASSWORD ping

#Container: payday-nginx

##Routing Rules

# All API routes → NestJS
location /api/ {
  limit_req zone=api_general burst=50;
  proxy_pass http://api:3000;
}

# Auth routes → stricter rate limit
location /api/v1/auth/ {
  limit_req zone=api_auth burst=5;
  proxy_pass http://api:3000;
}

# WebSocket for Socket.io
location /socket.io/ {
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_pass http://api:3000;
  proxy_read_timeout 86400s;
}

# Swagger docs
location /docs { proxy_pass http://api:3000; }

# Uploaded files served directly (no API hop)
location /uploads/ {
  alias /var/www/uploads/;
  expires 30d;
  add_header Cache-Control "public, immutable";
}

##Rate Limit Zones

limit_req_zone $binary_remote_addr zone=api_general:10m rate=100r/m;
limit_req_zone $binary_remote_addr zone=api_auth:10m    rate=10r/m;
limit_req_zone $binary_remote_addr zone=api_upload:10m  rate=20r/m;

#Container: payday-mailhog

Dev only — catches all outgoing email. No emails are actually sent.

  • SMTP server: mailhog:1025 (used by NestJS Nodemailer)
  • Web UI: http://localhost:8025 — view all captured emails

#Container: payday-portainer

Portainer CE provides a web UI to manage all Docker containers.

  • UI: http://localhost:9000
  • Create admin account on first visit
  • Can view logs, start/stop containers, inspect volumes

#docker-compose.yml Key Structure

name: payday

networks:
  payday-network:
    driver: bridge
    ipam:
      config: [{ subnet: 172.20.0.0/24 }]

volumes:
  postgres_data:
  redis_data:
  uploads_data:
  nginx_logs:
  portainer_data:

services:
  postgres:
    image: pgvector/pgvector:pg16
    healthcheck:
      test: pg_isready -U payday_user -d payday_db
      interval: 10s
      retries: 5

  redis:
    image: redis:7.2-alpine
    healthcheck:
      test: redis-cli -a $REDIS_PASSWORD ping

  api:
    build: { context: ., target: development }
    depends_on:
      postgres: { condition: service_healthy }
      redis: { condition: service_healthy }
    # Source code mounted for hot reload in dev
    volumes: [".:/app", "/app/node_modules", "uploads_data:/app/uploads"]

  nginx:
    image: nginx:1.25-alpine
    depends_on: [api]
    ports: ["80:80", "443:443"]

  mailhog:
    image: mailhog/mailhog
    ports: ["1025:1025", "8025:8025"]

  portainer:
    image: portainer/portainer-ce
    volumes: ["/var/run/docker.sock:/var/run/docker.sock", "portainer_data:/data"]
    ports: ["9000:9000"]

#Startup Order & Dependencies

postgres (healthcheck) ──┐
                          ├──→ api (waits for both healthy)
redis (healthcheck) ─────┘
                          └──→ nginx (waits for api)
mailhog (independent)
portainer (independent)

The api container will not start until both postgres and redis pass their health checks. This prevents "cannot connect to database" crashes on first boot.


#Development Commands

# Start everything
docker compose up -d

# View all logs live
docker compose logs -f

# View just API logs
docker compose logs -f api

# Rebuild API after package.json changes
docker compose build api && docker compose up -d api

# Run a NestJS CLI command inside the container
docker compose exec api npx nest g module new-feature

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

# Open Redis CLI
docker compose exec redis redis-cli -a $REDIS_PASSWORD

# Stop everything (keep data)
docker compose down

# Stop everything AND delete all data (full reset)
docker compose down -v
~ End of Document ~