TenantHawk — Guia completa
Del primer bootstrap al go-live, sin friccion.
Guia operativa completa: entiende la arquitectura, ejecuta los pasos en el orden correcto y llega a produccion con control total de seguridad, rendimiento y escalabilidad. Sin relleno, solo lo que realmente sirve.
Template open source MIT creado por Alessio Quagliara
Indice rapido+-
00
El stack de un vistazo
Cada pieza tiene un rol preciso. Nada esta para impresionar: todo esta para funcionar.
| Tecnologia | Rol |
|---|---|
| FastAPI (async) | Backend + Admin SSR |
| SQLAlchemy 2 async + asyncpg | ORM + pool de conexiones |
| PostgreSQL 16 | Persistencia de datos |
| Redis 7 | Session store con ventana deslizante |
| Next.js | Sitio marketing + SEO |
| Jinja2 + HTMX | UI Admin sin sobrecarga SPA |
| Traefik v3 | Reverse proxy + TLS automatico |
| Stripe | Checkout + webhook de facturacion |
| n8n | Automatizacion de workflows + agente AI |
| LiteLLM | Proxy LLM multi-proveedor |
| Alembic | Migraciones de esquema DB |
| k6 | Load testing |
01
Donde TenantHawk rinde mejor
Template pensado para multi-tenancy B2B real: cada cliente es una cuenta aislada con usuarios, roles y suscripcion.
Escenarios ideales
| Escenario | Por que encaja |
|---|---|
| SaaS de gestion B2B | Cada cliente es un tenant con roles, plan y billing listos. |
| Automatizaciones AI para agencias | n8n + LiteLLM con workflows aislados por cliente. |
| CMS multi-cliente | Equipos editoriales por tenant con RBAC granular. |
| MVP marketplace B2B | Roles PROVEEDOR y CLIENTE ya modelados en el dominio. |
| LMS multi-organizacion | Escuelas y empresas como tenants separados con suscripciones. |
Escenarios menos adecuados
| Escenario | Por que no |
|---|---|
| App consumer B2C pura | La multi-tenencia se vuelve sobrecarga innecesaria. |
| Editor realtime tipo Figma | Se necesita arquitectura collaboration-first, no solo REST. |
| Producto realtime masivo | REST no alcanza, hacen falta WebSockets dedicados. |
| App solo mobile | La landing Next.js pasa a segundo plano. |
02
Desarrollo local: de cero a operativo
Ruta minima para empezar rapido. Cada comando tiene un objetivo en el orden correcto.
1. Archivo .env para desarrollo
# PostgreSQL locale
POSTGRES_USER=dev_user
POSTGRES_PASSWORD=dev_password
POSTGRES_DB=dev_db
APP_DATABASE_URL=postgresql+asyncpg://dev_user:dev_password@db:5432/dev_db
# Redis
APP_REDIS_URL=redis://redis:6379
# Dev keys (non sicure, solo per sviluppo)
APP_SECRET_KEY=dev_secret_key_qualsiasi
APP_N8N_ENCRYPTION_KEY=dev_n8n_key_qualsiasi
APP_LITELLM_MASTER_KEY=dev_litellm_key
# Stripe TEST
APP_STRIPE_SECRET_KEY=sk_test_xxxxxxxxxx
APP_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxx
APP_STRIPE_WEBHOOK_SECRET=whsec_test_xxxxxxxxxx
APP_STRIPE_PRICE_BASE=price_test_xxx
APP_STRIPE_PRICE_PRO=price_test_xxx
APP_STRIPE_PRICE_COMPANY=price_test_xxx
# Backend dev
APP_HOST=0.0.0.0
APP_PORT=8000
APP_RELOAD=true # ← hot reload attivo
APP_WORKERS=12. Bootstrap completo
git clone https://github.com/AlessioQuagliara/SaaS_Template.git
cd SaaS_Template
cp .env.example .env
# Edita .env con i valori dev sopra
# Avvia lo stack completo
docker compose up --build
# Applica migrazioni DB (solo al primo avvio)
docker compose exec backend alembic revision --autogenerate -m "Inizializza"
docker compose exec backend alembic upgrade head
# Crea primo tenant + admin
docker compose exec backend python -m app.cli seed tenant-admin \
--slug dev \
--nome-tenant "Tenant Dev" \
--admin-email dev@dev.it \
--admin-password "Dev123!"Endpoints locales
| Servicio | URL |
|---|---|
| Admin backend | http://admin.localhost |
| Landing frontend | http://www.localhost |
| Traefik dashboard | http://localhost:8080 |
| LiteLLM UI | http://litellm.localhost |
| n8n | http://n8n.localhost |
| API Docs (Swagger) | http://admin.localhost/docs |
Flujo diario
# Le modifiche al codice backend si ricaricano automaticamente (APP_RELOAD=true)
# Nuova migration dopo aver modificato un model
docker compose exec backend alembic revision --autogenerate -m "Aggiunge campo X"
docker compose exec backend alembic upgrade head
# Log in tempo reale
docker compose logs -f backend
# Shell nel container backend
docker compose exec backend bash
# Connessione diretta al DB
docker compose exec db psql -U dev_user -d dev_db
# Riavvio singolo servizio
docker compose restart backend.localhost, agrega estas lineas en /etc/hosts:# /etc/hosts — aggiungi se il browser non risolve .localhost
127.0.0.1 admin.localhost www.localhost litellm.localhost n8n.localhost03
Produccion: Docker, TLS, hardening
La diferencia entre demo y producto real esta aqui. Sin atajos: cada punto tiene impacto de seguridad.
1. Archivo .env de produccion
# Genera prima con: openssl rand -hex 32
APP_SECRET_KEY=<openssl rand -hex 32>
APP_N8N_ENCRYPTION_KEY=<openssl rand -hex 32>
# PostgreSQL
POSTGRES_USER=saas_user
POSTGRES_PASSWORD=<password_forte>
POSTGRES_DB=saas_db
APP_DATABASE_URL=postgresql+asyncpg://saas_user:<password>@db:5432/saas_db
# Redis
APP_REDIS_URL=redis://redis:6379
# Email (Resend)
APP_RESEND_API_KEY=re_live_xxxxxxxxxx
APP_RESET_EMAIL_FROM=TuoSaaS <no-reply@tuodominio.com>
APP_BASE_URL=https://admin.tuodominio.com
APP_FRONTEND_BASE_URL=https://www.tuodominio.com
# Stripe LIVE
APP_STRIPE_SECRET_KEY=sk_live_xxxxxxxxxx
APP_STRIPE_PUBLISHABLE_KEY=pk_live_xxxxxxxxxx
APP_STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxx
APP_STRIPE_PRICE_BASE=price_xxxxxxxxxx
APP_STRIPE_PRICE_PRO=price_xxxxxxxxxx
APP_STRIPE_PRICE_COMPANY=price_xxxxxxxxxx
# LiteLLM
APP_DEEPSEEK_API_KEY=sk-xxxxxxxxxx
APP_LITELLM_MASTER_KEY=<stringa_random_sicura>
# n8n
APP_N8N_ENCRYPTION_KEY=<openssl rand -hex 32>
# Frontend
NEXT_PUBLIC_API_BASE_URL=https://admin.tuodominio.com
# Backend produzione
APP_HOST=0.0.0.0
APP_PORT=8000
APP_RELOAD=false
APP_WORKERS=22. Traefik con HTTPS y Let's Encrypt
El bloque TLS ya esta presente en compose.yaml pero comentado. Para produccion, descomentarlo y completa estos pasos:
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.le.acme.httpchallenge=true"
- "--certificatesresolvers.le.acme.httpchallenge.entrypoints=web"
- "--certificatesresolvers.le.acme.email=tua@email.com"
- "--certificatesresolvers.le.acme.storage=/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/acme.json:/acme.jsonmkdir -p traefik
touch traefik/acme.json
chmod 600 traefik/acme.json # Traefik rifiuta il file se i permessi sono troppo aperti
# Basic auth per la dashboard (non esporre mai in chiaro)
echo $(htpasswd -nb admin <tua_password>) | sed -e s/\$/\$\$/g
# Output: admin:$$apr1$$xxxxx ← da inserire nei labels Traefik3. Labels para TLS y auth del dashboard
# Labels backend in compose.yaml — TLS + auth dashboard
- "traefik.http.routers.backend-admin.entrypoints=websecure"
- "traefik.http.routers.backend-admin.tls.certresolver=le"
- "traefik.http.routers.dashboard.rule=Host(`traefik.tuodominio.com`)"
- "traefik.http.routers.dashboard.middlewares=auth"
- "traefik.http.middlewares.auth.basicauth.users=admin:$$apr1$$xxxxx":8080 por comodidad, pero en produccion debe estar protegida o deshabilitada.04
CLI: modulos admin sin boilerplate
La CLI genera toda la estructura de un modulo tenant-aware. Tu te enfocas en la logica de dominio, no en el andamiaje.
Que genera un solo comando
backend/app/
├── routes/admin/<nome>.py ← Route FastAPI + Jinja2
├── templates/admin/<nome>/
│ └── index.html ← Template Tailwind base
├── models/<nome>.py ← (opzionale) SQLAlchemy model
├── schemas/<nome>.py ← (opzionale) Pydantic schema
└── routes/admin/__init__.py ← Aggiornato automaticamenteComandos reales
# Modulo base (tutti i ruoli autenticati del tenant)
docker compose exec backend python -m app.cli admin create-module clienti
# Modulo con accesso solo SUPERUTENTE + model + schema
docker compose exec backend python -m app.cli admin create-module ordini-vendite \
--label "Ordini e Vendite" \
--superuser-only \
--with-model \
--with-schema
# Lista moduli esistenti
docker compose exec backend python -m app.cli admin list-modulesRuta generada (ejemplo con --superuser-only)
# routes/admin/ordini_vendite.py — generato dalla CLI
@router.get("/ordini_vendite", response_class=HTMLResponse)
async def ordini_vendite_page(
request: Request,
tenant_obj: Tenant = Depends(prendi_tenant_con_accesso),
utente_corrente: Utente = Depends(prendi_utente_corrente),
ruolo_corrente: str = Depends(prendi_ruolo_corrente),
_: None = Depends(richiede_ruolo([UtenteRuolo.SUPERUTENTE])), # guard RBAC
):
return templates.TemplateResponse(
request,
"admin/ordini_vendite/index.html",
{"tenant": tenant_obj, "utente": utente_corrente, "ruolo_corrente": ruolo_corrente},
)Como enriquecer con logica DB real
Despues de la generacion, el patron para agregar queries reales es siempre el mismo: importa el modelo y ejecuta la select filtrada por tenant_id, y pasa los datos al template.
# Dopo la generazione, aggiungi la logica DB reale
from sqlalchemy import select
from app.core.database import get_db
from app.models.ordini_vendite import OrdiniVendite
@router.get("/ordini_vendite", response_class=HTMLResponse)
async def ordini_vendite_page(
request: Request,
tenant_obj: Tenant = Depends(prendi_tenant_con_accesso),
utente_corrente: Utente = Depends(prendi_utente_corrente),
ruolo_corrente: str = Depends(prendi_ruolo_corrente),
_: None = Depends(richiede_ruolo([UtenteRuolo.SUPERUTENTE])),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(OrdiniVendite).where(OrdiniVendite.tenant_id == tenant_obj.id)
)
ordini = result.scalars().all()
return templates.TemplateResponse(
request,
"admin/ordini_vendite/index.html",
{"tenant": tenant_obj, "utente": utente_corrente,
"ruolo_corrente": ruolo_corrente, "ordini": ordini},
)tenant_id == tenant_obj.id es el limite de aislamiento entre tenants. Nunca lo omitas al consultar tablas tenant-aware.05
Rendimiento: mide antes de optimizar
El test de login incluido simula 700 usuarios concurrentes en el flujo completo con CSRF. Usalo como base.
Instalacion y primer test
# macOS
brew install k6
# Linux (Debian/Ubuntu)
sudo apt-get install k6
# Docker (nessuna installazione)
docker run --rm -i grafana/k6 run - <test/test_login.js# Test standard: 700 VU, 30 secondi (modifica credenziali prima)
k6 run test/test_login.js
# Test leggero per sviluppo
k6 run --vus 10 --duration 10s test/test_login.js
# Output JSON per analisi
k6 run --out json=result.json test/test_login.jstest/test_login.js con un usuario real de tu tenant dev. El test tambien simula la extraccion del token CSRF desde el HTML del login.Template para testear cualquier ruta
// test/test_custom.js — template per testare qualsiasi route
import http from 'k6/http'
import { check, sleep } from 'k6'
const BASE_URL = 'http://admin.localhost:8000'
const SESSION = 'id_sessione_utente=<cookie_reale>'
export const options = {
stages: [
{ duration: '10s', target: 50 }, // ramp up
{ duration: '30s', target: 200 }, // carico sostenuto
{ duration: '10s', target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% richieste sotto 500ms
http_req_failed: ['rate<0.01'], // meno dell'1% di errori
},
}
export default function () {
const res = http.get(`${BASE_URL}/demo/dashboard`, {
headers: { Cookie: SESSION },
})
check(res, {
'status 200': r => r.status === 200,
'sotto 200ms': r => r.timings.duration < 200,
'html presente': r => r.headers['Content-Type'].includes('text/html'),
})
sleep(0.5)
}Como leer resultados
| Metrica | Umbral recomendado | Que mide |
|---|---|---|
| p(95) latency | < 300ms | 95% richieste servite velocemente |
| p(99) latency | < 1000ms | Coda lenta sotto controllo |
| http_req_failed | < 1% | Quasi zero errori HTTP |
| checks | > 99% | Logica applicativa corretta |
| http_reqs/s | dipende dal caso | Throughput complessivo |
06
LiteLLM: politicas, costes y GDPR
LiteLLM es el punto unico de control para todos los proveedores LLM. Cambia proveedor sin tocar n8n ni backend.
Accede a la UI en http://litellm.localhost usando la APP_LITELLM_MASTER_KEY de tu .env.
1. Configuracion de proveedor (litellm_config.yaml)
# litellm_config.yaml
model_list:
# Provider principale: DeepSeek (economico, performante)
- model_name: deepseek-chat
litellm_params:
model: deepseek/deepseek-chat
api_key: os.environ/APP_DEEPSEEK_API_KEY
- model_name: deepseek-reasoner
litellm_params:
model: deepseek/deepseek-reasoner
api_key: os.environ/APP_DEEPSEEK_API_KEY
# Provider EU per GDPR (Mistral AI — data center in Francia)
- model_name: mistral-large
litellm_params:
model: mistral/mistral-large-latest
api_key: os.environ/APP_MISTRAL_API_KEY
# Fallback OpenAI opzionale
- model_name: gpt-4o
litellm_params:
model: openai/gpt-4o
api_key: os.environ/APP_OPENAI_API_KEY
general_settings:
master_key: os.environ/APP_LITELLM_MASTER_KEY2. Virtual keys por tenant con limites de presupuesto y GDPR
Para tenants que requieren residencia de datos en la UE (ej. GDPR), crea una virtual key que permita solo modelos alojados en Europa:
# Crea virtual key EU-only per tenant con data residency requirement
curl -X POST http://litellm.localhost/key/generate \
-H "Authorization: Bearer $APP_LITELLM_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{
"models": ["mistral-large"],
"metadata": {"tenant_id": "azienda-eu"},
"max_budget": 10.0,
"budget_duration": "30d"
}'3. Monitoreo de gasto
# Vedi utilizzo e spesa per chiave
curl http://litellm.localhost/spend/keys \
-H "Authorization: Bearer $APP_LITELLM_MASTER_KEY"
# Riavvia LiteLLM dopo modifiche al yaml (senza toccare lo stack)
docker compose restart litellmlitellm_config.yaml, ejecuta docker compose restart litellm. No hace falta reiniciar todo el stack.07
n8n: configuracion e integracion
n8n orquesta workflows automatizados. TenantHawk sigue siendo el sistema de referencia para tenants, usuarios y billing.
1. Primer acceso
Ve a http://n8n.localhost. En el primer arranque, n8n te guia para crear la cuenta owner y activar la licencia community gratuita en community.n8n.io.
APP_N8N_ENCRYPTION_KEY en .env debe definirse antes del primer arranque. Si la cambias despues, n8n no puede descifrar credenciales guardadas y debes reiniciar eliminando el volumen.2. Conectar n8n a LiteLLM
# In n8n: Credentials → New → OpenAI API
API Key: <APP_LITELLM_MASTER_KEY>
Base URL: http://litellm:4000
# Usa il nome servizio Docker, NON litellm.localhost
# (n8n è dentro la rete Docker e comunica via nome servizio)3. Workflow tipico: onboarding de tenant automatico
Webhook trigger (es. nuovo tenant registrato)
→ HTTP Request → backend: GET /{slug}/utenti
→ OpenAI node (via LiteLLM) → genera email benvenuto
→ HTTP Request → backend: POST /api/email/send
→ Wait 3 giorni
→ HTTP Request → backend: GET /{slug}/sottoscrizione
→ If: trial scade in 2 giorni?
→ Sì: OpenAI → reminder upgrade → Resend → invia email4. Backup de workflows
# Esporta tutti i workflow
docker compose exec n8n n8n export:workflow \
--all \
--output=/home/node/.n8n/backup.json
# Copia fuori dal container
docker cp $(docker compose ps -q n8n):/home/node/.n8n/backup.json ./backup/Reset completo (solo si es necesario)
# Reset completo n8n (ATTENZIONE: cancella tutti i workflow)
docker compose down
docker volume rm saas_template_n8n_data
docker compose up -d08
Billing: periodo de gracia y cascade delete
El ciclo de vida del tenant esta automatizado. Ningun tenant se elimina sin una ventana de recuperacion.
La funcion applica_policy_disattivazione_tenant() se llama en cada carga del tenant y gestiona automaticamente la transicion de estados:
# La policy di disattivazione tenant funziona così:
# 1. ATTIVO / PROVA → nessuna azione
# 2. SCADUTO / CANCELLATO → entra in SOSPESO per 14 giorni (grace period)
# 3. SOSPESO → dopo 14 giorni verifica live su Stripe, poi cascade delete
#
# Cascade delete rimuove: ruoli, token reset, utenti senza altri tenant, sottoscrizione, tenant
# Se l'utente è condiviso su altri tenant, il suo account viene semplicemente spostato| Estado | Accion automatica |
|---|---|
| ACTIVO / PRUEBA | Sin accion, acceso garantizado |
| EXPIRADO / CANCELADO | Entra en SUSPENDIDO con 14 dias de gracia |
| SUSPENDIDO (dentro de la gracia) | Acceso bloqueado, aun sin eliminacion |
| SUSPENDIDO (gracia vencida) | Verificacion live Stripe -> cascade delete si se confirma |
verifica_live_ok = False), la politica entra en modo fail-safe y no elimina nada.09
Web3: cuando y como integrarlo
TenantHawk es intencionalmente Web2 para fiabilidad B2B. Web3 entra solo cuando aporta ventaja competitiva real.
Hoy identidad, sesiones y billing estan centralizados: cookies httpOnly, session store Redis y Stripe como autoridad de pago. Esta eleccion reduce complejidad y acelera el time-to-market. Web3 tiene sentido en cuatro casos concretos: acceso tokenizado, billing on-chain, login wallet-first y trazabilidad inmutable en cadena.
1. Dependencias
# requirements.txt — aggiungi
siwe>=2.1.0
web3>=6.0.0
# frontend
npm install wagmi viem @rainbow-me/rainbowkit2. Agregar campo wallet al modelo Usuario
# backend/app/models/utente.py — aggiungi il campo wallet
wallet_address: Mapped[str | None] = mapped_column(
String(42), # lunghezza fissa indirizzo Ethereum
unique=True,
index=True,
nullable=True,
)3. Rutas SIWE backend
# backend/app/routes/auth/web3.py
from siwe import SiweMessage
@router.post("/auth/web3/nonce")
async def genera_nonce(wallet_address: str):
nonce = secrets.token_hex(16)
await gestore_sessioni.redis.setex(
f"web3_nonce:{wallet_address.lower()}",
timedelta(minutes=5),
nonce,
)
return {"nonce": nonce}
@router.post("/auth/web3/verify")
async def verifica_firma_web3(payload: dict, response: Response, db=Depends(get_db)):
message = SiweMessage(message=payload["message"])
message.verify(payload["signature"])
wallet = message.address.lower()
nonce_salvato = await gestore_sessioni.redis.get(f"web3_nonce:{wallet}")
if nonce_salvato != message.nonce:
raise HTTPException(status_code=401, detail="Nonce non valido o scaduto")
utente = await trova_o_crea_utente_wallet(db, wallet_address=wallet)
id_sessione = await gestore_sessioni.crea_sessione(
id_utente=utente.id,
id_tenant=utente.tenant_id,
auth_method="web3",
wallet=wallet,
)
# Sessione identica a quella classica: RBAC e tenancy funzionano senza refactor
response.set_cookie(
key="id_sessione_utente", value=id_sessione,
httponly=True, samesite="lax",
)
return {"ok": True}4. Componente frontend con wagmi
// frontend — Next.js + wagmi
import { useSignMessage } from "wagmi"
export function Web3Login({ address }: { address: string }) {
const { signMessageAsync } = useSignMessage()
const handleLogin = async () => {
const { nonce } = await fetch(
'/api/auth/web3/nonce?wallet=' + address
).then(r => r.json())
const message = buildSiweMessage({ address, nonce, domain: "admin.localhost" })
const signature = await signMessageAsync({ message })
await fetch("/api/auth/web3/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, signature }),
})
}
return <button onClick={handleLogin}>Accedi con Wallet</button>
}prendi_utente_corrente, RBAC y tenancy funciona sin refactors porque usa solo id_utente desde la sesion de Redis.GO LIVE
Checklist final de lanzamiento
Completa cada punto antes de ir a produccion. Ningun paso es opcional.
- □APP_RELOAD=false y APP_WORKERS >= 2 en produccion
- □APP_SECRET_KEY generada con openssl rand -hex 32 (nunca usar valores por defecto)
- □APP_N8N_ENCRYPTION_KEY definida antes del primer arranque de n8n (inmutable)
- □HTTPS activo en compose.yaml y traefik/acme.json con chmod 600
- □Dashboard de Traefik protegida con basic auth (no exponer en claro)
- □Claves Stripe live y webhook de produccion verificados
- □Virtual keys LiteLLM con limites de presupuesto por tenant y modelos EU
- □Backup de pg_data planificado y restore probado
- □No ejecutar docker compose down -v en produccion (borra datos)
- □Agregar /etc/hosts si .localhost no resuelve de forma nativa
Ahora tienes una base operativa completa: arquitectura, desarrollo, produccion, automatizaciones, testing y go-live. El siguiente paso es convertir esta solidez tecnica en posicionamiento y hoja de ruta comercial.