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>
This commit is contained in:
+211
@@ -0,0 +1,211 @@
|
||||
# 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
|
||||
```
|
||||
Reference in New Issue
Block a user