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.

Production Deployment Guide

This guide is cloud-agnostic — the same steps work on AWS EC2, DigitalOcean Droplets, Hetzner VPS, Vultr, or any Linux server with Docker installed.


#Recommended Server Specs (1K–10K Members)

MembersCPURAMDiskMonthly Cost (approx)
Launch (0–1K)2 vCPU4 GB80 GB SSD$20–40/mo
Growth (1K–5K)4 vCPU8 GB160 GB SSD$50–80/mo
Scale (5K–10K)8 vCPU16 GB320 GB SSD$100–160/mo

Cheapest options per provider for launch:

  • Hetzner CX22 — 2 vCPU, 4 GB RAM, 40 GB disk — €4.35/mo (best value)
  • DigitalOcean Basic — 2 vCPU, 4 GB RAM — $24/mo
  • Vultr Cloud Compute — 2 vCPU, 4 GB RAM — $20/mo
  • AWS t3.medium — 2 vCPU, 4 GB RAM — ~$30/mo

#Step 1 — Server Setup

# SSH into your server
ssh root@YOUR_SERVER_IP

# Update system
apt update && apt upgrade -y

# Install Docker
curl -fsSL https://get.docker.com | sh

# Install Docker Compose v2
apt install docker-compose-plugin -y

# Verify
docker --version
docker compose version

# Create a non-root deploy user
adduser deploy
usermod -aG docker deploy
usermod -aG sudo deploy

# Switch to deploy user for everything else
su - deploy

#Step 2 — Domain & DNS

Point your domain to the server before setting up SSL.

A record:    api.yourdomain.com  →  YOUR_SERVER_IP
A record:    yourdomain.com      →  YOUR_SERVER_IP  (if frontend on same server)

Wait for DNS to propagate (~5 minutes to 1 hour) before step 4.


#Step 3 — Deploy the Code

# On the server as deploy user
mkdir -p ~/payday
cd ~/payday

# Option A: Clone from Git
git clone https://github.com/yourorg/payday-backend.git .

# Option B: Copy files via SCP from local machine
scp -r ./payday-backend/* deploy@YOUR_SERVER_IP:~/payday/

# Create production .env
cp .env.example .env
nano .env
# Fill in ALL required variables with production values
# Key differences from dev:
#   NODE_ENV=production
#   APP_URL=https://api.yourdomain.com
#   SWAGGER_ENABLED=false
#   DB_LOGGING=false
#   MAIL_HOST=smtp.resend.com (or your SMTP)
#   STORAGE_PROVIDER=s3 (recommended for production)
#   STRIPE_SECRET_KEY=sk_live_...

#Step 4 — SSL Certificate (Free via Certbot)

# Install Certbot
sudo apt install certbot -y

# Get certificate (stop any service using port 80 first)
sudo certbot certonly --standalone -d api.yourdomain.com

# Certificates saved to:
# /etc/letsencrypt/live/api.yourdomain.com/fullchain.pem
# /etc/letsencrypt/live/api.yourdomain.com/privkey.pem

# Copy to nginx/ssl/ in your project
sudo cp /etc/letsencrypt/live/api.yourdomain.com/fullchain.pem ~/payday/nginx/ssl/
sudo cp /etc/letsencrypt/live/api.yourdomain.com/privkey.pem ~/payday/nginx/ssl/

# Auto-renewal cron (runs twice daily)
echo "0 12 * * * root certbot renew --quiet && docker compose -f ~/payday/docker-compose.yml restart nginx" | sudo tee -a /etc/crontab

#Step 5 — Production Nginx Config

Replace nginx/nginx.dev.conf content with production config that includes SSL:

# nginx/nginx.prod.conf
server {
  listen 80;
  server_name api.yourdomain.com;
  return 301 https://$host$request_uri;
}

server {
  listen 443 ssl http2;
  server_name api.yourdomain.com;

  ssl_certificate     /etc/nginx/ssl/fullchain.pem;
  ssl_certificate_key /etc/nginx/ssl/privkey.pem;
  ssl_protocols TLSv1.2 TLSv1.3;
  ssl_ciphers HIGH:!aNULL:!MD5;
  ssl_session_cache shared:SSL:10m;

  # Security headers
  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
  add_header X-Frame-Options "SAMEORIGIN" always;
  add_header X-Content-Type-Options "nosniff" always;

  client_max_body_size 55M;

  location /health {
    proxy_pass http://api:3000;
    access_log off;
  }

  location /api/ {
    limit_req zone=api_general burst=50 nodelay;
    proxy_pass http://api:3000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto https;
  }

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

  location /uploads/ {
    alias /var/www/uploads/;
    expires 30d;
    add_header Cache-Control "public, immutable";
  }
}

#Step 6 — Launch Production Stack

cd ~/payday

# Build the production API image
docker compose -f docker-compose.yml -f docker-compose.prod.yml build

# Start all production services
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

# Verify all containers running
docker compose ps

# Check API health
curl https://api.yourdomain.com/health

# Watch logs for errors
docker compose logs -f api

#Step 7 — Firewall Rules

# Allow only required ports
sudo ufw allow 22/tcp    # SSH
sudo ufw allow 80/tcp    # HTTP (redirects to HTTPS)
sudo ufw allow 443/tcp   # HTTPS
sudo ufw allow 1935/tcp  # RTMP (only if using live streaming)

# Block direct access to internal ports
# 5432 (Postgres), 6379 (Redis), 3000 (API), 9000 (Portainer)
# are NOT opened — they only talk inside the Docker network

sudo ufw enable
sudo ufw status

#Step 8 — Database Backups

# Manual backup
docker compose exec postgres pg_dump \
  -U payday_user payday_db \
  | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz

# Automated daily backup cron (as deploy user)
crontab -e
# Add:
0 3 * * * docker compose -f ~/payday/docker-compose.yml exec -T postgres \
  pg_dump -U payday_user payday_db \
  | gzip > ~/backups/payday_$(date +\%Y\%m\%d).sql.gz

# Keep last 30 days
0 4 * * * find ~/backups -name "*.sql.gz" -mtime +30 -delete

For production, also sync backups to S3:

aws s3 cp ~/backups/ s3://your-backup-bucket/payday-db/ --recursive

#Updating / Redeploying

cd ~/payday

# Pull latest code
git pull origin main

# Rebuild only the API image (zero-downtime replacement)
docker compose -f docker-compose.yml -f docker-compose.prod.yml build api

# Restart just the API (Nginx + DB keep running)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-deps api

# Verify new version is running
docker compose logs --tail=20 api
curl https://api.yourdomain.com/health

#Scaling Beyond Single Server

When you outgrow one server (typically at 5K+ active members):

  1. Separate the database — Move PostgreSQL to a managed service (AWS RDS, DigitalOcean Managed PostgreSQL, Supabase) — eliminates DB as single point of failure

  2. Separate Redis — Move to managed Redis (AWS ElastiCache, Upstash, DigitalOcean Managed Redis)

  3. Multiple API instances — Run 2–3 API containers behind a load balancer Socket.io Redis adapter already handles multi-instance pub/sub

  4. CDN for uploads — Move STORAGE_PROVIDER=s3 + CloudFront for global file delivery

  5. Horizontal auto-scaling — Move to AWS ECS or Kubernetes when needed

~ End of Document ~