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

Pronto in 30-45 min9 sezioni operativeZero fluff

00

Lo stack in un colpo d'occhio

Ogni pezzo ha un ruolo preciso. Niente e li per impressionare: tutto e li per funzionare.

TecnologiaRuolo
FastAPI (async)Backend + Admin SSR
SQLAlchemy 2 async + asyncpgORM + pool connessioni
PostgreSQL 16Persistenza dati
Redis 7Session store sliding-window
Next.jsLanding page + SEO
Jinja2 + HTMXUI Admin senza SPA overhead
Traefik v3Reverse proxy + TLS automatico
StripeCheckout + webhook billing
n8nWorkflow automation + AI agent
LiteLLMProxy LLM multi-provider
AlembicMigrazioni schema DB
k6Load testing
Il backend FastAPI serve sia le API JSON che l'admin SSR con Jinja2. Non c'e un frontend SPA separato per l'admin: questa scelta riduce la superficie di attacco, elimina CORS e semplifica la gestione delle sessioni.

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

ScenarioPerche funziona
SaaS Gestionale B2BOgni cliente e un tenant con ruoli, piano e billing gia pronti.
Automazioni AI per agenzien8n + LiteLLM con workflow separati per ogni cliente.
CMS multi-clienteTeam redazionali per tenant con RBAC granulare.
Marketplace B2B MVPRuoli FORNITORE e CLIENTE gia modellati nel dominio.
LMS multi-organizzazioneScuole e aziende come tenant separati con abbonamenti.

Scenari meno adatti

ScenarioPerche no
App consumer B2C puraIl multi-tenant diventa overhead inutile.
Editor realtime tipo FigmaServe architettura collaboration-first, non REST.
Prodotto realtime massivoREST non basta, servono WebSocket dedicati.
App solo mobileLa 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

bash
# 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=1

2. Bootstrap completo

bash
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

ServizioURL
Admin backendhttp://admin.localhost
Landing frontendhttp://www.localhost
Traefik dashboardhttp://localhost:8080
LiteLLM UIhttp://litellm.localhost
n8nhttp://n8n.localhost
API Docs (Swagger)http://admin.localhost/docs

Workflow quotidiano

bash
# 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
Se il browser non risolve .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.localhost

03

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

bash
# 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=2

2. 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:

yaml
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.json
bash
mkdir -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 Traefik

3. Labels per TLS e auth dashboard

yaml
# 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"
Non esporre mai la dashboard Traefik senza autenticazione. In sviluppo e aperta su :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

text
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 automaticamente

Comandi reali

bash
# 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-modules

Route generata (esempio con --superuser-only)

python
# 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.

python
# 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},
    )
Il filtro 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

bash
# 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
bash
# 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.js
Prima di eseguire il test, modifica le credenziali in test/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

javascript
// 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

MetricaSoglia buonaCosa misura
p(95) latency< 300ms95% richieste servite velocemente
p(99) latency< 1000msCoda lenta sotto controllo
http_req_failed< 1%Quasi zero errori HTTP
checks> 99%Logica applicativa corretta
http_reqs/sdipende dal casoThroughput 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)

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_KEY

2. 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:

bash
# 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

bash
# 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 litellm
Dopo ogni modifica a litellm_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.

Critico: la 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

text
# 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

text
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 email

4. Backup workflow

bash
# 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)

bash
# Reset completo n8n (ATTENZIONE: cancella tutti i workflow)
docker compose down
docker volume rm saas_template_n8n_data
docker compose up -d

08

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:

python
# 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
StatoAzione automatica
ATTIVO / PROVANessuna azione, accesso garantito
SCADUTO / CANCELLATOEntra 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
Prima di ogni azione distruttiva, il sistema esegue sempre una verifica live su Stripe. Se la connessione a Stripe fallisce (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

bash
# requirements.txt — aggiungi
siwe>=2.1.0
web3>=6.0.0

# frontend
npm install wagmi viem @rainbow-me/rainbowkit

2. Aggiungere il campo wallet al model Utente

python
# 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

python
# 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

typescript
// 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>
}
La parte chiave: la sessione generata dal flusso Web3 e identica strutturalmente a quella del login classico. Tutto il middleware di 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.