1. Core Components (Single VPS)

┌──────────────────────────────────────────────────┐ │ Mobile Apps │ │ (Player App & Owner App) │ └──────────────────────┬───────────────────────────┘ │ HTTPS ┌──────────────────────▼───────────────────────────┐ │ Nginx (TLS/HTTPS) │ │ Reverse proxy, rate limiting │ └──────────────────────┬───────────────────────────┘ │ ┌──────────────────────▼───────────────────────────┐ │ Node.js/Express API Service │ │ Auth, Bookings, Payments, Venues, Users │ └────────┬─────────────────────────┬───────────────┘ │ │ ┌────────▼────────┐ ┌───────────▼──────────┐ │ PostgreSQL │ │ Redis │ │ (Source of │ │ (Slot holds, │ │ truth) │ │ caching, │ │ │ │ sessions) │ └─────────────────┘ └──────────────────────┘ │ ┌────────▼────────────────────────────────────────┐ │ Worker Process │ │ Async jobs: notifications, emails, │ │ refunds, reconciliation │ └─────────────────────────────────────────────────┘ Admin Panel: Static React build served via Nginx (admin.domain.com)

All containerized with Docker Compose so you can migrate to multiple servers later without rewriting.

2. Booking Engine — Model A (Pre-generated Slots)

Recommended Best for "book in 30 seconds" target. Owners define availability rules, system generates discrete time slots.

Table Schema

TableKey ColumnsPurpose
venue id, name, location, owner_id Physical venue/facility
court id, venue_id, name, type Individual court within venue
slot court_id, start_at, end_at, price, status Bookable time block
Status: available | held | booked | blocked
booking id, slot_id(s), user_id, status Player's reservation
Status: pending | confirmed | cancelled
payment booking_id, stripe_intent_id, status Stripe PaymentIntent record

Why Pre-generated Slots?

Model B — Rule-based Availability (Not Recommended for MVP)

Computes availability on the fly from rules + exceptions. More flexible but more complex and easier to get wrong under concurrency. For the MVP speed target, Model A is the right call.

3. Booking Flow

Player selects slot
API holds slot (5-8 min TTL)
Create Stripe PaymentIntent
Player completes payment
Webhook confirms booking

Detailed Flow

  1. Select slot — User taps "Book" on an available slot
  2. Hold slot — API atomically updates slot: available → held, sets held_until and held_by_user. Only succeeds if slot is still available.
  3. Create payment — API creates a Stripe PaymentIntent and returns client secret to the app
  4. Complete payment — Player confirms payment in-app (Apple Pay / Google Pay / Card)
  5. Webhook confirmation — Stripe sends payment_intent.succeeded webhook. API marks booking as confirmed and slot as booked.
  6. Timeout — If payment not completed within TTL, hold expires, slot returns to available

4. Concurrency: Double-Booking Prevention

Non-negotiable Two layers of protection — belt and suspenders.

Layer 1: Database-Level Guarantee (Hard Safety)

-- Single slot per booking: booking.slot_id → UNIQUE constraint for confirmed bookings -- Multiple slots per booking: booking_slots(booking_id, slot_id) → UNIQUE(slot_id) WHERE booking is active This prevents two confirmed bookings from ever sharing the same slot, even under race conditions.

Layer 2: Short Hold Lock (Good UX)

-- Atomic update: only succeeds if slot is available UPDATE slots SET status = 'held', held_until = NOW() + INTERVAL '5 minutes', held_by_user = $user_id WHERE id = $slot_id AND status = 'available' AND (held_until IS NULL OR held_until < NOW()); -- Returns 1 row if success, 0 if already taken

Redis can optionally mirror the hold for speed, but the database remains the source of truth.

5. Payments: Stripe-Ready Pattern

Uses Stripe PaymentIntents + webhooks — the safest pattern for marketplace payments.

Client creates PaymentIntent via API
Player pays (Apple Pay / Card)
Stripe webhook fires
API confirms booking

Key Rules

Stripe Connect (Marketplace Model)

AspectDetails
ModelPlatform collects payment, splits to venue owners
CommissionConfigurable per venue (global default with override)
PayoutsWeekly to court owners (configurable schedule)
KYCHandled by Stripe Connect onboarding

6. Deployment Footprint

Docker Compose Services

services: nginx: image: nginx:alpine ports: ["80:80", "443:443"] volumes: [./nginx/config, ./certs] node-api: build: ./apps/backend ports: ["3000:3000"] environment: [DATABASE_URL, REDIS_URL, STRIPE_KEY] depends_on: [postgres, redis] postgres: image: postgres:16 volumes: [./data/postgres:/var/lib/postgresql/data] environment: [POSTGRES_PASSWORD] redis: image: redis:7-alpine command: redis-server --appendonly yes

On the VPS

7. External Services (Still Required)

Even on a naked VPS, these external services cannot be avoided:

ServicePurposeCost Model
StripePayments, Apple Pay, Google PayPer-transaction fees
Apple DeveloperiOS App Store distribution$99/year
Google Play ConsoleAndroid app publishing$25 one-time
Firebase (FCM)Push notifications (Android + iOS)Free tier available
Google Maps APIVenue discovery, maps, distance$200/month free credit
CloudflareDNS, CDN, DDoS protectionFree tier available
Email (SendGrid/SES)Transactional emailsFree tier available
SMS (Twilio/Vonage)Optional SMS notificationsPer-message

8. Migration-Ready Design Principles

Design for naked VPS now, but ensure easy migration to cloud later:

9. Critical Reminder

Your biggest technical challenge is NOT hosting

It's booking concurrency and double-booking prevention. If you don't design transactional locking properly, two users will book the same slot. The architecture above solves this with database UNIQUE constraints + atomic hold updates — implement this exactly as specified.