TenantHawk — Guida completa
Dal primo bootstrap al go-live, senza attrito.
Guida operativa completa: capisci l'architettura, esegui gli step nell'ordine giusto, arrivi in produzione con controllo pieno su sicurezza, performance e scalabilita. Nessun fluff, solo cio che serve davvero.
Template open source MIT creato da Alessio Quagliara
Indice rapido+-
00
Lo stack in un colpo d'occhio
Ogni pezzo ha un ruolo preciso. Niente e li per impressionare: tutto e li per funzionare.
| Tecnologia | Ruolo |
|---|---|
| FastAPI (async) | Backend + Admin SSR |
| SQLAlchemy 2 async + asyncpg | ORM + pool connessioni |
| PostgreSQL 16 | Persistenza dati |
| Redis 7 | Session store sliding-window |
| Next.js | Landing page + SEO |
| Jinja2 + HTMX | UI Admin senza SPA overhead |
| Traefik v3 | Reverse proxy + TLS automatico |
| Stripe | Checkout + webhook billing |
| n8n | Workflow automation + AI agent |
| LiteLLM | Proxy LLM multi-provider |
| Alembic | Migrazioni schema DB |
| k6 | Load testing |
01
Dove TenantHawk rende al massimo
Template pensato per B2B multi-tenant reale: ogni cliente e un account isolato con utenti, ruoli e abbonamento propri.
Scenari ideali
| Scenario | Perche funziona |
|---|---|
| SaaS Gestionale B2B | Ogni cliente e un tenant con ruoli, piano e billing gia pronti. |
| Automazioni AI per agenzie | n8n + LiteLLM con workflow separati per ogni cliente. |
| CMS multi-cliente | Team redazionali per tenant con RBAC granulare. |
| Marketplace B2B MVP | Ruoli FORNITORE e CLIENTE gia modellati nel dominio. |
| LMS multi-organizzazione | Scuole e aziende come tenant separati con abbonamenti. |
Scenari meno adatti
| Scenario | Perche no |
|---|---|
| App consumer B2C pura | Il multi-tenant diventa overhead inutile. |
| Editor realtime tipo Figma | Serve architettura collaboration-first, non REST. |
| Prodotto realtime massivo | REST non basta, servono WebSocket dedicati. |
| App solo mobile | La landing Next.js diventa secondaria. |
02
Sviluppo locale: zero a operativo
Percorso minimo per partire subito. Ogni comando ha uno scopo, nell'ordine giusto.
1. File .env per sviluppo
# 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!"Indirizzi locali
| Servizio | 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 |
Workflow quotidiano
# 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, aggiungi queste righe a /etc/hosts:# /etc/hosts — aggiungi se il browser non risolve .localhost
127.0.0.1 admin.localhost www.localhost litellm.localhost n8n.localhost03
Produzione: Docker, TLS, hardening
La differenza tra demo e prodotto serio e qui. Nessuna scorciatoia: ogni punto ha una conseguenza di sicurezza.
1. File .env di produzione
# 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 e Let's Encrypt
Il blocco TLS e gia presente nel compose.yaml ma commentato. Per la produzione decommentalo e completa questi passi:
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 per TLS e auth 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 per comodita, ma in produzione deve essere protetta o disabilitata.04
CLI: moduli admin senza boilerplate
La CLI genera l'intera struttura di un modulo tenant-aware. Tu ti concentri sul dominio, non sull'impalcatura.
Cosa genera un singolo 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 automaticamenteComandi reali
# 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-modulesRoute generata (esempio 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},
)Come arricchire con logica DB reale
Dopo la generazione, il pattern per aggiungere query reali e sempre lo stesso: importa il model, esegui la select filtrata per tenant_id, passa i dati 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 e il confine di isolamento tra tenant. Non dimenticarlo mai quando scrivi query su tabelle tenant-aware.05
Performance: misura prima di ottimizzare
Il test di login incluso nel progetto simula 700 utenti concorrenti sul flusso completo con CSRF. Usalo come base.
Installazione e primo 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 utente reale del tuo tenant dev. Il test simula anche l'estrazione del token CSRF dall'HTML della pagina di login.Template per testare qualsiasi route
// 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)
}Come leggere i risultati
| Metrica | Soglia buona | Cosa misura |
|---|---|---|
| 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: policy, costi e GDPR
LiteLLM e il punto unico di controllo per tutti i provider LLM. Cambia provider senza toccare n8n o il backend.
Accedi alla UI su http://litellm.localhost usando la APP_LITELLM_MASTER_KEY dal tuo .env.
1. Configurazione provider (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 key per tenant con limiti budget e GDPR
Per tenant che richiedono data residency EU (es. GDPR), crea una virtual key che permette solo modelli con server europei:
# 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. Monitoraggio spesa
# 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, esegui docker compose restart litellm. Non e necessario riavviare l'intero stack.07
n8n: setup e integrazione
n8n orchestra i workflow automatizzati. TenantHawk resta il sistema of record per tenant, utenti e billing.
1. Primo accesso
Vai su http://n8n.localhost. Al primo avvio, n8n guida attraverso la creazione dell'account owner e l'attivazione della licenza community gratuita su community.n8n.io.
APP_N8N_ENCRYPTION_KEY nel .env deve essere impostata prima del primo avvio. Se la modifichi dopo, n8n non riesce piu a decifrare le credenziali salvate e bisogna ripartire da zero eliminando il volume.2. Connettere 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 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 workflow
# 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 se necessario)
# Reset completo n8n (ATTENZIONE: cancella tutti i workflow)
docker compose down
docker volume rm saas_template_n8n_data
docker compose up -d08
Billing: grace period e cascade delete
Il ciclo di vita del tenant e automatizzato. Nessun tenant viene eliminato senza una finestra di recupero.
La funzione applica_policy_disattivazione_tenant() viene chiamata ad ogni caricamento del tenant e gestisce automaticamente la transizione tra stati:
# 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| Stato | Azione automatica |
|---|---|
| ATTIVO / PROVA | Nessuna azione, accesso garantito |
| SCADUTO / CANCELLATO | Entra in SOSPESO con 14 giorni di grace period |
| SOSPESO (entro tregua) | Accesso bloccato, nessuna eliminazione ancora |
| SOSPESO (tregua scaduta) | Verifica live Stripe → cascade delete se confermato |
verifica_live_ok = False), la policy si blocca in fail-safe e non elimina nulla.09
Web3: quando e come integrarlo
TenantHawk e volutamente Web2 per affidabilita B2B. Web3 entra solo dove crea un vantaggio competitivo reale.
Oggi identita, sessioni e billing sono centralizzati: cookie httpOnly, session store Redis, Stripe come authority di pagamento. Questa scelta riduce complessita e accelera il time-to-market. Web3 ha senso in quattro casi concreti: tokenized access, billing on-chain, login wallet-first, audit trail immutabile su chain.
1. Dipendenze
# requirements.txt — aggiungi
siwe>=2.1.0
web3>=6.0.0
# frontend
npm install wagmi viem @rainbow-me/rainbowkit2. Aggiungere il campo wallet al model Utente
# 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. Route 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 e tenancy funziona senza refactor, perche usa solo id_utente dalla sessione Redis.GO LIVE
Checklist finale di lancio
Completa ogni punto prima di andare live. Nessun passaggio e opzionale.
- □APP_RELOAD=false e APP_WORKERS >= 2 in produzione
- □APP_SECRET_KEY generata con openssl rand -hex 32 (mai usare il default)
- □APP_N8N_ENCRYPTION_KEY fissata prima del primo avvio di n8n (immutabile dopo)
- □HTTPS attivo in compose.yaml e traefik/acme.json con chmod 600
- □Dashboard Traefik protetta con basic auth (non esposta in chiaro)
- □Stripe: chiavi sk_live_* e webhook con URL di produzione verificato
- □LiteLLM: virtual key con limiti budget per tenant, modelli EU per GDPR
- □Backup pg_data pianificato e procedura di restore testata
- □Mai eseguire docker compose down -v in produzione (cancella tutti i dati)
- □Aggiungere /etc/hosts se .localhost non si risolve nativamente
Hai una base operativa completa: architettura, sviluppo, produzione, automazioni, test e go-live. Il passo successivo e tradurre questa solidita tecnica in posizionamento e roadmap commerciale.