Files
justinandClaude Opus 4.6 7fd2438c36 Initial paw❤️print repo with SaaS architecture docs
README, multi-tenant SaaS architecture (database-per-tenant),
automated onboarding flow (signup → 60 seconds → live site),
and template extraction plan from AHCR codebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:36:38 -05:00

7.2 KiB
Raw Permalink Blame History

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):

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:

# 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

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