# PawPrint SaaS Architecture ## Multi-Tenant Strategy ### Phase 1: Database-per-Tenant (MVP) Each rescue gets their own database on shared infrastructure. The app code is identical across tenants — only the database connection and subdomain differ. ``` ┌─────────────────────────────────────────────┐ │ Caddy Reverse Proxy │ │ *.pawprint.app → route by subdomain │ ├──────────┬──────────┬──────────┬────────────┤ │ almosthome│ happypaws│ furever │ ... │ │ :3001 │ :3002 │ :3003 │ │ ├──────────┼──────────┼──────────┼────────────┤ │ DB: ahcr │ DB: hpaws│ DB: furev│ │ └──────────┴──────────┴──────────┴────────────┘ │ MariaDB Server │ └─────────────────────────────────────────────┘ ``` **Why this approach:** - Zero code changes to the rescue app itself - Complete data isolation between tenants (no org_id bugs) - Each tenant can be backed up, migrated, or deleted independently - Can scale horizontally by adding more VPS nodes - Simple to reason about — each rescue is just "another install" **How it works:** 1. Tenant signs up at `pawprint.app` 2. Provisioning script creates: database, .env file, systemd service, Caddy route 3. App boots on a unique port, connects to its own DB 4. Caddy routes `{slug}.pawprint.app` → `localhost:{port}` ### Phase 2: Shared Database (Scale) When we hit 500+ tenants and per-DB overhead matters: - Add `orgId` column to every table - Single app instance, single DB - Row-level security via middleware - **Only do this when Phase 1 becomes painful** ## Infrastructure ### Single Server (0-100 tenants) One beefy VPS handles everything: - **Hetzner CX31** ($15/mo): 4 vCPU, 8GB RAM, 80GB SSD - MariaDB: all tenant databases - Node.js: one process per tenant (low memory with Node adapter) - Caddy: wildcard SSL via Let's Encrypt - Each tenant uses ~50MB RAM idle, ~200MB under load 100 tenants × 50MB = 5GB RAM — fits on an 8GB server with headroom. ### Multi Server (100+ tenants) - Separate DB server (managed MariaDB or dedicated VPS) - Multiple app servers behind a load balancer - Shared file storage (Cloudflare R2 or mounted volume) - Caddy on each app server or a dedicated proxy ### Image Storage | Scale | Strategy | Cost | |-------|----------|------| | 0-50 tenants | Local disk | $0 (included in VPS) | | 50-200 tenants | Cloudflare R2 | ~$1-5/mo (zero egress) | | 200+ tenants | R2 + CDN | ~$5-20/mo | Average rescue: ~300MB of images. 100 rescues = 30GB = $0.45/mo on R2. ## Tenant Management ### Admin Dashboard (`admin.pawprint.app`) The PawPrint operator (you) gets a meta-dashboard: - **Tenants list** — name, subdomain, plan, status, created date, last active - **Provisioning** — create new tenant (runs automation) - **Billing** — Stripe subscription status per tenant - **Health** — systemd service status, DB size, disk usage - **Maintenance** — run migrations across all tenants, restart services ### Database Schema (Meta) The admin dashboard has its own database (`pawprint_admin`): ```sql CREATE TABLE tenants ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255) NOT NULL, -- "Almost Home Canine Rescue" slug VARCHAR(100) NOT NULL UNIQUE, -- "almosthome" subdomain VARCHAR(100) NOT NULL UNIQUE, -- "almosthome.pawprint.app" custom_domain VARCHAR(255), -- "almosthomecaninerescue.com" db_name VARCHAR(100) NOT NULL, -- "pp_almosthome" port INT NOT NULL, -- 3001 admin_email VARCHAR(255) NOT NULL, admin_name VARCHAR(255) NOT NULL, plan ENUM('free_trial', 'cloud', 'self_hosted') DEFAULT 'free_trial', stripe_customer_id VARCHAR(255), stripe_subscription_id VARCHAR(255), status ENUM('provisioning', 'active', 'suspended', 'deleted') DEFAULT 'provisioning', trial_ends_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE tenant_events ( id INT PRIMARY KEY AUTO_INCREMENT, tenant_id INT NOT NULL REFERENCES tenants(id), event VARCHAR(50) NOT NULL, -- "provisioned", "migrated", "suspended", etc. details JSON, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); ``` ## Billing ### Stripe Integration - **Product:** PawPrint Cloud ($19/mo) - **Trial:** 14 days free (no card required) - **Webhook events:** - `customer.subscription.created` → activate tenant - `customer.subscription.deleted` → suspend tenant (grace period) - `invoice.payment_failed` → send warning email, suspend after 3 failures - **Dunning:** Stripe handles retry logic ### Lifecycle ``` Signup → Trial (14 days) → Active ($19/mo) → ... → Expired → Suspended (7 days) → Deleted ``` Suspended tenants: - App still running but shows "account suspended" page - Data preserved for 30 days - Reactivate by paying ## Custom Domains Tenants can optionally use their own domain: 1. Tenant sets custom domain in their settings 2. They add a CNAME record: `www.theirrescue.com → almosthome.pawprint.app` 3. Caddy auto-provisions SSL via ACME 4. We add the domain to the Caddy config ``` theirrescue.com { reverse_proxy localhost:3001 } ``` ## Security Considerations - Each tenant's DB user should only have access to their own database - .env files are per-tenant with unique SESSION_SECRET - Admin dashboard requires separate auth (not tenant auth) - Tenant data is never mixed — complete isolation - Backups are per-tenant (can restore one without affecting others) - Rate limiting is per-tenant (one rescue getting hammered doesn't affect others) ## Migration Path ### From WordPress 1. Export pets from WP (CSV or WP REST API) 2. Map fields to PawPrint schema 3. Import via PawPrint's CSV import tool 4. Download and re-upload photos ### From Shelterluv / RescueGroups 1. Export data as CSV 2. Map to PawPrint schema 3. Import tool handles the rest ### From Petfinder 1. If they have API access: use REST API to pull pets 2. If not: manual CSV export or widget scraping 3. Note: Petfinder killed their API in 2025, so most rescues can't use it ## Development Workflow ### Updating the App When we push updates to the PawPrint app: ```bash # On the server for tenant in $(cat /etc/pawprint/tenants.list); do cd /var/www/pawprint/$tenant git pull npm install npm run build systemctl restart pawprint-$tenant done ``` Or better: blue-green deploys per tenant. Build once, symlink to each tenant. ### Running Migrations ```bash for tenant in $(cat /etc/pawprint/tenants.list); do DB_NAME=$(grep DATABASE_URL /var/www/pawprint/$tenant/.env | cut -d/ -f4) mysql $DB_NAME < migrations/0009_new_feature.sql done ```