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>
212 lines
7.2 KiB
Markdown
212 lines
7.2 KiB
Markdown
# 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
|
||
```
|