Skip to content
Return to Projects
Case Study

Brewhaus

An AI-driven specialty coffee marketplace for home enthusiasts.

The Problem

Home coffee enthusiasts struggle to find beans matched to their taste. Brewhaus profiles flavor with AI and recommends roasts that fit each palate — turning discovery into an interactive experience.

My Role

Built solo with AI-assisted development — SSR storefront, vector search, AI integration, and Dockerized deployment.

Highlights

  • Next.js 15 SSR storefront with gamification and subscription billing
  • pgvector semantic search powering AI-driven flavor profiling
  • Claude AI integration for personalized recommendations
  • Containerized with Docker and served via Cloudflare Tunnels

Stack

Next.js 15TypeScriptPostgreSQLpgvectorClaude AIDocker

Constraints

  • Self-hosted on a home server — no cloud bill, but also no auto-scaling.
  • Solo build, so the stack had to favour boring, well-documented pieces over novelty.
  • AI cost must stay bounded — recommendations had to use embeddings, not a per-request LLM call.

System Architecture

Edge
Cloudflare Tunnel
Application
Next.js 15 SSR
Subscriptions
Gamification
Intelligence
Claude AI
Flavor Profiler
Data
PostgreSQL
pgvector (semantic search)
Runtime
Docker

Key Trade-offs

The decisions worth defending — what I chose, what I turned down, and why.

Recommendation engine

Chose

pgvector similarity on cached embeddings

Rejected

LLM call per recommendation

Vector search is millisecond-fast and effectively free once embeddings are computed; LLMs would have added cost and latency per page view.

Public ingress

Chose

Cloudflare Tunnel

Rejected

Public VPS with open ports

No exposed origin IP, free TLS, and DDoS protection inherited from Cloudflare — much smaller attack surface for a self-hosted box.

Rendering model

Chose

Next.js 15 SSR

Rejected

Pure client-rendered SPA

Product pages need to be indexable and shareable; SSR gives fast first paint and clean OG cards without a separate prerender step.

What I'd Do Differently

An honest retrospective — the stuff I'd change with more time, more users, or a second pass.

  1. 1Move embedding generation to a background queue — synchronous calls during admin product writes briefly blocked the UI.
  2. 2Add a staging container that mirrors prod data shape so AI tuning doesn't happen against the live DB.
  3. 3Track recommendation quality with a click-through metric instead of relying on subjective spot-checks.

Technical Deep-Dive

Architecture, specifications, and implementation details.

02 · System Architecture

#Architecture Pattern

Containerised monorepo deployed via Docker Compose, exposed to the internet exclusively through a Cloudflare Tunnel. Zero inbound ports open on the host.


#Layer Overview

┌─────────────────────────────────────────────────────────────────┐
│  LAYER 1 — CLIENT                                               │
│  Next.js 15 App Router · React 19 · Three.js · Framer Motion   │
│  Ice Cave UI · Tailwind CSS v4 · PWA                            │
└───────────────────────────────┬─────────────────────────────────┘
                                │ HTTPS (TLS 1.3)
┌───────────────────────────────▼─────────────────────────────────┐
│  LAYER 2 — CLOUDFLARE EDGE                                      │
│  DDoS Protection · CDN Cache · TLS Termination                  │
│  brewhaus.icelegends.cloud → Cloudflare Network                 │
└───────────────────────────────┬─────────────────────────────────┘
                                │ QUIC Tunnel (outbound only)
┌───────────────────────────────▼─────────────────────────────────┐
│  LAYER 3 — CLOUDFLARE TUNNEL                                    │
│  Container: cloudflared (cloudflare/cloudflared:latest)         │
│  Outbound QUIC connection — NO inbound ports on host            │
└───────────────────────────────┬─────────────────────────────────┘
                                │ HTTP (internal Docker network)
┌───────────────────────────────▼─────────────────────────────────┐
│  LAYER 4 — REVERSE PROXY                                        │
│  Container: nginx (nginx:1.25-alpine)                           │
│  Static asset serving · Gzip · Cache headers · WebSocket proxy  │
└───────────────────────────────┬─────────────────────────────────┘
                                │ HTTP (internal)
┌───────────────────────────────▼─────────────────────────────────┐
│  LAYER 5 — APPLICATION                                          │
│  Container: brewhaus-app (node:20-alpine)                       │
│  Next.js 15 · tRPC · NextAuth v5 · Prisma ORM · Stripe         │
└──────┬─────────────────┬──────────────────┬────────────────────┘
       │                 │                  │
┌──────▼──────┐  ┌───────▼───────┐  ┌──────▼──────────────┐
│  LAYER 6a   │  │   LAYER 6b    │  │   LAYER 6c          │
│  DATABASE   │  │    CACHE      │  │   WORKER            │
│  postgres   │  │    redis      │  │   worker            │
│  pgvector   │  │    BullMQ     │  │   BullMQ processor  │
│  pg16       │  │    7-alpine   │  │   Email, XP, Subs   │
└─────────────┘  └───────────────┘  └─────────────────────┘

#Docker Container Specifications

ContainerImageInternal PortPurpose
brewhaus-appnode:20-alpine (custom)3000Next.js application
nginxnginx:1.25-alpine80Reverse proxy
cloudflaredcloudflare/cloudflarednoneCF Tunnel daemon
postgrespgvector/pgvector:pg165432PostgreSQL + vector search
redisredis:7-alpine6379Cache + job queues
workernode:20-alpine (custom)noneBackground job processor

Critical: No host port mappings. All containers communicate on the internal brewhaus-net bridge network. The only external connectivity is the outbound QUIC tunnel from the cloudflared container.


#Request Lifecycle

1. User navigates to https://brewhaus.icelegends.cloud
2. DNS resolves to Cloudflare's anycast network
3. Cloudflare checks cache → cache miss → forwards to tunnel
4. cloudflared container receives request over QUIC connection
5. Routes to nginx:80 on internal Docker network
6. NGINX checks if static asset → serves from cache (/_next/static/)
7. Dynamic request proxied to brewhaus-app:3000
8. Next.js App Router handles route → RSC renders on server
9. tRPC call if data needed → Prisma query → PostgreSQL
10. Response streamed back to browser
11. React hydrates on client

#Data Flow — AI Recommendation

User completes quiz
       ↓
Claude Haiku (claude-haiku-4-5)
  → Receives 5 Q&A pairs
  → Returns taste profile JSON
       ↓
OpenAI text-embedding-3-small
  → Profile text → 1536-dim vector
       ↓
PostgreSQL + pgvector
  → INSERT into taste_profiles
  → Cosine similarity search:
    SELECT id FROM products
    ORDER BY embedding <=> $vector
    LIMIT 20
       ↓
Claude Sonnet (claude-sonnet-4-5)
  → Re-ranks 20 candidates
  → Applies: stock, season, tier, history
  → Returns final 4-8 products
       ↓
Response to client
  + Personalised brew guide (streaming)
  + XP awarded (+50 for quiz completion)

#Networking

##Internal Docker Network

All containers join brewhaus-net (bridge driver). Services reference each other by container name as DNS:

brewhaus-app → postgres:5432
brewhaus-app → redis:6379
nginx        → brewhaus-app:3000
cloudflared  → nginx:80
worker       → redis:6379
worker       → postgres:5432

##External — Cloudflare Tunnel

Internet → Cloudflare Edge (anycast)
         → Encrypted QUIC tunnel
         → cloudflared daemon (outbound connection only)
         → nginx:80 (Docker internal)

No firewall rules needed. No port forwarding. No public IP exposure.


#Scalability Considerations

BottleneckCurrent SolutionScale Path
App serverSingle Node.js containerAdd replicas + load balancer
Database connectionsPrisma connection poolingAdd PgBouncer sidecar
Redis memory256MB limit, LRU evictionIncrease or add Upstash
AI API callsClaude rate limitsQueue via BullMQ + retry
Static assetsCloudflare CDN cacheAlready solved
SearchDB full-text searchAdd Typesense container

Next: 03 — Database Schema →

~ End of Document ~