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>
7.2 KiB
7.2 KiB
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:
- Tenant signs up at
pawprint.app - Provisioning script creates: database, .env file, systemd service, Caddy route
- App boots on a unique port, connects to its own DB
- Caddy routes
{slug}.pawprint.app→localhost:{port}
Phase 2: Shared Database (Scale)
When we hit 500+ tenants and per-DB overhead matters:
- Add
orgIdcolumn 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 tenantcustomer.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:
- Tenant sets custom domain in their settings
- They add a CNAME record:
www.theirrescue.com → almosthome.pawprint.app - Caddy auto-provisions SSL via ACME
- 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
- Export pets from WP (CSV or WP REST API)
- Map fields to PawPrint schema
- Import via PawPrint's CSV import tool
- Download and re-upload photos
From Shelterluv / RescueGroups
- Export data as CSV
- Map to PawPrint schema
- Import tool handles the rest
From Petfinder
- If they have API access: use REST API to pull pets
- If not: manual CSV export or widget scraping
- 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