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
| Table | Key Columns | Purpose |
| 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?
- Availability queries are super fast — "show me open slots" is just indexed reads
- Easy to block/create recurring schedules and bulk actions
- Simple concurrency model — each slot is a row with a status
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
- Select slot — User taps "Book" on an available slot
- Hold slot — API atomically updates slot:
available → held, sets held_until and held_by_user. Only succeeds if slot is still available.
- Create payment — API creates a Stripe PaymentIntent and returns client secret to the app
- Complete payment — Player confirms payment in-app (Apple Pay / Google Pay / Card)
- Webhook confirmation — Stripe sends
payment_intent.succeeded webhook. API marks booking as confirmed and slot as booked.
- 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
- payment_intent.succeeded is what flips booking to CONFIRMED — never trust client-side confirmation alone
- If payment fails or is cancelled, booking is cancelled and slot returns to AVAILABLE
- Reconciliation worker runs every 5 minutes to catch orphaned payments ("paid but not booked" edge case)
- Idempotency keys prevent duplicate charges
Stripe Connect (Marketplace Model)
| Aspect | Details |
| Model | Platform collects payment, splits to venue owners |
| Commission | Configurable per venue (global default with override) |
| Payouts | Weekly to court owners (configurable schedule) |
| KYC | Handled 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
- Docker + Docker Compose installed
- Nginx + Certbot (or Cloudflare SSL)
- PostgreSQL (local, not publicly exposed)
- Redis (local, not publicly exposed)
- Daily automated backups (pg_dump to object storage)
- Monitoring: Uptime Kuma + Sentry (recommended)
7. External Services (Still Required)
Even on a naked VPS, these external services cannot be avoided:
| Service | Purpose | Cost Model |
| Stripe | Payments, Apple Pay, Google Pay | Per-transaction fees |
| Apple Developer | iOS App Store distribution | $99/year |
| Google Play Console | Android app publishing | $25 one-time |
| Firebase (FCM) | Push notifications (Android + iOS) | Free tier available |
| Google Maps API | Venue discovery, maps, distance | $200/month free credit |
| Cloudflare | DNS, CDN, DDoS protection | Free tier available |
| Email (SendGrid/SES) | Transactional emails | Free tier available |
| SMS (Twilio/Vonage) | Optional SMS notifications | Per-message |
8. Migration-Ready Design Principles
Design for naked VPS now, but ensure easy migration to cloud later:
- Containerize everything — Docker Compose makes migration trivial
- Separate database logically — don't couple app logic to local paths
- Use environment variables — all config externalized, no hardcoded values
- Avoid tight coupling — each service communicates via API, not shared memory
- Stateless API — no session state on the server; use Redis/JWT
- Log to stdout — let the orchestrator handle log aggregation
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.