Vai al contenuto

Negozio dimostrativo: prodotti, prezzi e ordini sono inventati e nessun pagamento è reale. Come è fatto

Crema e Ferro0

Operatore

Manuale dell’operatore

Questa pagina è per chi tiene in piedi il servizio: pubblicare una versione, tornare indietro quando va storta, salvare e ripristinare il database, ruotare le chiavi, cambiare il modello, capire perché la chat non risponde. Ogni comando qui sotto è quello che gira davvero su questo VPS.

Serve sapere
Terminale, Docker e un po’ di SQL. Non serve conoscere il codice dell’applicazione.
Serve avere
Accesso SSH al server e i permessi su /etc/caddy.
Tempo
Controlli quotidiani: 2 minuti. Un ripristino completo: 15 minuti.

Per i contenuti e il comportamento dell’assistente (correggere risposte, tono, nuove informazioni) la pagina è Manutenzione. Per com’è costruito: il tutorial.

Orientarsi

1 · La mappa, in un minuto

Due container e un proxy. Nient’altro: niente servizi gestiti, niente code, niente cache esterne. Il pezzo che sta fuori è solo il fornitore del modello.

terminale
Internet
   │  HTTPS (certificati automatici)
   ▼
Caddy (sul VPS, porta 443)
   │  password sul sito, NON su /widget.js /embed /api/*
   │  niente compressione su /api/chat (è uno stream)
   ▼
container "app"  →  127.0.0.1:3040   (Next.js, sola lettura, senza privilegi)
   │                                  qui gira anche il calcolo degli embedding
   ▼
container "db"   →  Postgres 18 + pgvector, NON pubblicato su internet
                     volume "dbdata": catalogo, centro assistenza, carrelli, ordini, chat

Fuori dal VPS: solo il fornitore del modello (OpenRouter). Gli embedding sono in casa.
DoveCosa c’èChi lo può raggiungere
/etc/caddy/Caddyfileproxy, HTTPS, password del sitoroot sul VPS
web/docker-compose.ymli due container e i loro limitiroot sul VPS
web/.env.productioni segreti (permessi 600)solo root, mai nel repository
volume dbdatatutto il databasesolo il container db
127.0.0.1:3040l’applicazionesolo il proxy, non internet

Routine

2 · I tre controlli quotidiani

Due minuti, e coprono il 90% dei guai prima che li segnali qualcun altro.

terminale
# 1. il sito risponde e il database è raggiungibile
curl -fsS https://chat-bot.giuseppebosi.com/api/health     # {"status":"ok"}

# 2. i container sono su e non si riavviano in continuazione
docker compose --env-file .env.production ps

# 3. errori nelle ultime ore
docker compose --env-file .env.production logs --since 24h app | grep -iE "error|fail|429|503"

# spazio su disco (le immagini Docker crescono a ogni deploy)
df -h / && docker system df

/api/health non dice solo «il sito è su»: interroga davvero il database. Se risponde 503 db_unavailable, l’applicazione sta girando ma non ha più il database — è la situazione in cui la chat risponde «non riesco a rispondere» a tutti.

Routine

3 · Pubblicare e tornare indietro

Il deploy ricostruisce l’immagine, riavvia e aspetta che il sito risponda prima di dichiararsi finito. Se qualcosa va storto, si torna indietro con un comando: l’immagine precedente resta sempre sul server.

terminale
# pubblicare (ricostruisce, riavvia, aspetta che /api/health risponda)
./deploy.sh

# tornare all'immagine precedente, se il deploy ha rotto qualcosa (pochi secondi)
./rollback.sh

# cosa fa davvero: prima di ricostruire, deploy.sh marca l'immagine in uso come :prev
docker images chat-bot-web        # latest = in produzione, prev = quella prima, broken = l'ultima annullata

# ATTENZIONE: il rollback riporta indietro il CODICE, mai i dati. Una migrazione già applicata
# resta applicata: se il problema è una migrazione, serve il ripristino del backup.

Attenzione. Il rollback riporta indietro il codice, non il database. Se il problema è una migrazione già applicata, il rollback non basta: serve il ripristino del backup (capitolo 5). Per questo il backup si fa prima del deploy, non dopo.

Routine

4 · Far arrivare i contenuti nuovi

Catalogo e centro assistenza vivono in due file di testo e vengono riversati nel database a ogni avvio dell’applicazione. Chi cura i contenuti lavora sui file (vedi Manutenzione); all’operatore serve sapere come vedere e forzare quel passaggio.

terminale
# I comandi di manutenzione girano dentro la rete di Docker, perché il database non è
# raggiungibile da fuori. Il profilo "tools" esiste solo per questo e non tiene su niente.

# anteprima: cosa cambierebbe nel database rispetto ai file src/data/*
docker compose --env-file .env.production --profile tools run --rm tools \
  scripts/content.ts --dry-run

# applicarlo subito senza ricostruire l'immagine (di solito basta ./deploy.sh)
docker compose --env-file .env.production --profile tools run --rm tools scripts/content.ts

# cosa hanno chiesto i clienti negli ultimi 7 giorni (sola lettura, niente IP in chiaro)
docker compose --env-file .env.production --profile tools run --rm tools scripts/chats.ts

# la stessa sincronizzazione gira da sola a ogni avvio del container:
docker compose --env-file .env.production logs app | grep "contenuti aggiornati"

Il profilo tools è un container usa e getta che monta il progetto in sola lettura e parla con il database attraverso la rete interna di Docker: è l’unico modo per lanciare comandi sul database, che non è pubblicato su internet. Non resta acceso e non tocca il container dell’applicazione.

Critico

5 · Backup, ripristino e cosa non fare mai

Il database è l’unica cosa non ricostruibile: il codice si ricompila, i contenuti stanno nei file, ma carrelli, ordini e conversazioni no. Un dump completo di questa installazione pesa poche centinaia di KB.

terminale
# BACKUP (da mettere in cron: ci mette due secondi e pesa qualche centinaio di KB)
cd /root/Developer/Chat-Bot/web
docker compose --env-file .env.production exec -T db \
  pg_dump -U chatbot -d chatbot --no-owner | gzip > ~/backup/chatbot-$(date +%F).sql.gz

# esempio di riga di cron: ogni notte alle 3:20, tenendo 14 giorni
# 20 3 * * * cd /root/Developer/Chat-Bot/web && docker compose --env-file .env.production exec -T db pg_dump -U chatbot -d chatbot --no-owner | gzip > ~/backup/chatbot-$(date +\%F).sql.gz && find ~/backup -name 'chatbot-*.sql.gz' -mtime +14 -delete

# RIPRISTINO (cancella e riscrive i dati: fermare prima l'app)
docker compose --env-file .env.production stop app
gunzip -c ~/backup/chatbot-2026-09-19.sql.gz | \
  docker compose --env-file .env.production exec -T db psql -U chatbot -d chatbot
docker compose --env-file .env.production start app

# MAI: docker compose down -v   ← la -v cancella il volume "dbdata", cioè tutto il database.
# Per riavviare i container basta: docker compose --env-file .env.production restart

Attenzione. docker compose down -v cancella il volume dbdata, cioè tutto il database, senza chiedere conferma. Non serve mai: per riavviare basta restart, per aggiornare up -d. Se qualcuno te lo suggerisce «per pulire», è la volta che perdi i dati.

Critico

6 · Segreti e rotazione delle chiavi

I segreti stanno in un unico file, con permessi 600 e fuori dal repository. Vanno ruotati se qualcuno li ha visti, se cambia chi ha accesso al server, o per abitudine una volta l’anno.

terminale
# I segreti stanno in web/.env.production (permessi 600, mai nel repository).
# Nomi, non valori:
#   OPENROUTER_API_KEY     chiave del fornitore del modello — l'unica che costa soldi se esce
#   TOOL_APPROVAL_SECRET   firma le approvazioni ("Invia richiesta"): 64 caratteri casuali
#   IP_HASH_SALT           sale dell'impronta dell'IP: senza, gli hash sarebbero indovinabili
#   POSTGRES_PASSWORD      password del database (usata anche dal compose)
#   PUBLIC_SITE_URL        indirizzo pubblico, usato nei link
# In docker-compose.yml, in chiaro perché non sono segreti:
#   CHAT_DAILY_LIMIT       tetto giornaliero di messaggi per tutto il sito
#   WIDGET_PUBLIC_KEY      chiave pubblica delle API, scritta anche nel tutorial

# generare un valore casuale nuovo
openssl rand -hex 32

# dopo ogni modifica del file:
./deploy.sh        # oppure: docker compose --env-file .env.production up -d

# Cosa succede ruotando ciascuna:
#   OPENROUTER_API_KEY    → nessun effetto sui clienti (revoca subito la vecchia sul sito del fornitore)
#   TOOL_APPROVAL_SECRET  → le approvazioni aperte in quel momento vanno rifatte dal cliente
#   IP_HASH_SALT          → i contatori per visitatore ripartono da zero
#   POSTGRES_PASSWORD     → va cambiata ANCHE nel database, se no l'app non entra più:
#       docker compose --env-file .env.production exec db \
#         psql -U chatbot -d chatbot -c "alter user chatbot with password 'nuova'"

La chiave pubblica del widget (WIDGET_PUBLIC_KEY) è un caso a parte: sta in chiaro nel compose e nel tutorial, perché arriva comunque nel browser di chiunque. Cambiarla ferma gli script che la usavano; chi ha incollato solo il <script> non se ne accorge, perché la pagina del widget la serve il server.

Critico

7 · Cambiare il modello o il fornitore

Qui gira il router gratuito di OpenRouter: costo zero, ma qualità variabile e un tetto di richieste giornaliere. È la scelta giusta per una dimostrazione e quella sbagliata per un negozio vero, dove una risposta sbagliata costa più di mezzo euro al mese.

terminale
# Il modello si sceglie con due variabili (in .env.production):
#   AI_PROVIDER=openrouter        openrouter | openai
#   AI_MODEL=openrouter/free      gratuito: pesca un modello libero capace di usare gli strumenti

# Alternative a pagamento, in ordine di costo (prezzi di settembre 2026, ~1.000 messaggi):
#   mistralai/mistral-small-3.2-24b-instruct   ~0,34 $   risposte costanti
#   z-ai/glm-5.3-flash                          ~0,35 $   molto affidabile con gli strumenti
#   openai/gpt-oss-120b                         ~0,62 $   il migliore sui testi lunghi

# chi ha risposto davvero, conversazione per conversazione:
docker compose --env-file .env.production exec -T db psql -U chatbot -d chatbot \
  -c "select model, count(*) from chats where updated_at > now() - interval '2 days' group by 1 order by 2 desc;"

# Sintomi tipici del router gratuito, e cosa fare:
#   "Nessun modello gratuito disponibile" o risposte troncate  → siamo oltre i 1000 al giorno: aspetta o passa a pagamento
#   scrive la chiamata allo strumento come testo JSON          → modello scadente pescato a caso: riprova, o fissa un modello
#   risposte lentissime (>30 s)                                 → fornitore sovraccarico: fissa un modello a pagamento
Cosa vediCosa sta succedendoCosa fare
Risposte lente o mozzatefornitore gratuito sovraccaricofissare un modello a pagamento in AI_MODEL
Scrive il JSON dello strumento invece di usarlomodello scadente pescato dal routerriprovare; se ricapita, modello fisso
429 dal fornitoresuperati i 1000 al giorno del piano gratuitoaspettare il giorno dopo o passare a pagamento
Errore 401 dal fornitorechiave revocata o scadutanuova chiave in .env.production, poi ./deploy.sh

Routine

8 · Limiti, quote e i 429

I limiti servono a due cose: non far esaurire a una persona sola la quota di tutti, e non far pagare al proprietario il traffico di un robot. Sono tutti in memoria, senza servizi esterni.

terminale
// src/lib/rate-limit.ts — le manopole dei limiti (richiedono un deploy)
const perVisitorBurst = new RateLimiterMemory({ points: 8, duration: 60 });     // 8 messaggi/minuto
const perVisitorDaily = new RateLimiterMemory({ points: 40, duration: 86400 }); // 40 messaggi/giorno
const globalBurst = new RateLimiterMemory({ points: 40, duration: 600 });       // 40 ogni 10 minuti, tutto il sito
const globalDaily = ... process.env.CHAT_DAILY_LIMIT                            // tetto del giorno (compose)

# I contatori stanno in memoria: si azzerano al riavvio del container.
docker compose --env-file .env.production restart app

# Quanti rifiuti ci sono stati (429) nelle ultime 24 ore, visti da Caddy:
journalctl -u caddy --since "24 hours ago" | grep -c '"status":429'
CodiceSignificatoDove si cambia
429troppi messaggi (visitatore o sito intero)src/lib/rate-limit.ts, CHAT_DAILY_LIMIT nel compose
401manca o è sbagliata la chiave x-cf-keyWIDGET_PUBLIC_KEY nel compose
403origine estranea o corpo non JSON (difesa CSRF)src/lib/request.ts
413richiesta troppo grandeMAX_BODY nella route, request_body in Caddy
503database non raggiungibilecapitolo 9

Emergenza

9 · Pronto soccorso: sintomo, causa, comando

SintomoCausa più probabilePrimo comando
Il sito non si apreCaddy fermo o certificatosystemctl status caddy · journalctl -u caddy -n 50
502 dal proxycontainer app non in piedidocker compose ps · logs --tail 100 app
La chat non risponde, il sito sìfornitore del modello o quotalogs -f app mentre si fa una domanda
«Non riesco a rispondere» a tuttidatabase giùcurl /api/health · restart db
Tutti ricevono 429tetto globale raggiuntorestart app (azzera i contatori) e alzare i limiti
Il widget sparisce dai siti esternipassword estesa ai percorsi del widgetcapitolo 10: rimettere le esclusioni
L’app non riparte dopo un deploymigrazione fallita o build rotta./rollback.sh, poi leggere i log
Disco pienoimmagini Docker vecchiedocker system df · docker image prune -f
terminale
# log in diretta (Ctrl-C per uscire)
docker compose --env-file .env.production logs -f app

# ultime 200 righe, solo errori
docker compose --env-file .env.production logs --tail 200 app | grep -iE "error|unhandled|fatal"

# riavviare solo l'applicazione (riallinea anche i contenuti e azzera i limiti)
docker compose --env-file .env.production restart app

# entrare nel database
docker compose --env-file .env.production exec db psql -U chatbot -d chatbot

# conteggi utili una volta dentro:
#   select count(*) from products;    -- 27 se il catalogo è a posto
#   select count(*) from kb_chunks;   -- le sezioni del centro assistenza
#   select count(*) from chats where updated_at > now() - interval '1 day';

# spazio: le immagini vecchie si accumulano a ogni deploy
docker image prune -f            # toglie le immagini senza nome (NON tocca latest/prev)

Critico

10 · Caddy: HTTPS, password, altri siti

Il Caddyfile di questo server ospita anche altri siti: un errore di sintassi li butta giù tutti insieme. Backup, validate, reload — in quest’ordine, sempre.

terminale
# Il Caddyfile è condiviso con gli altri siti del VPS: backup, verifica, ricarica. Sempre in
# quest'ordine, e mai un riavvio secco (butterebbe giù anche gli altri siti).
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak.$(date +%Y%m%d-%H%M%S)
sudo nano /etc/caddy/Caddyfile
sudo caddy validate --config /etc/caddy/Caddyfile      # se non è valido, NON ricaricare
sudo systemctl reload caddy                            # ricarica senza interrompere le connessioni
systemctl status caddy --no-pager

# cambiare la password del sito (basic_auth): si scrive l'impronta, mai la password
caddy hash-password --plaintext 'la-nuova-password'
# → incollare l'hash nella riga dell'utente, poi validate + reload

# i percorsi esclusi dalla password devono restare esclusi, o il widget smette di funzionare
# nei siti di chi l'ha installato:
#   @protetto not path /widget.js /embed /embed/* /api/chat /api/cart /api/health /_next/* /favicon.ico

Attenzione. I percorsi /widget.js, /embed e le API devono restare fuori dalla password: sono quelli che il widget usa dentro i siti di chi l’ha installato. Se li proteggi, i loro visitatori si vedono comparire una richiesta di password che non possono soddisfare.

Critico

11 · Dati conservati e cancellazione

Poche tabelle, una retention corta e nessun indirizzo IP in chiaro. È il minimo per poter rispondere alla domanda «cosa tenete di me?» senza andare a leggere il codice.

terminale
# Cosa viene conservato
#   chats        i messaggi delle conversazioni + un'IMPRONTA dell'IP (sha256 con sale) + il modello
#   carts        i carrelli dei visitatori
#   orders       ordini dimostrativi (isDemo=1) e quelli creati durante le prove
# Cancellazione automatica: carrelli e conversazioni più vecchi di 30 giorni, una volta al giorno.

# Cancellare subito tutte le conversazioni (per esempio dopo una dimostrazione pubblica):
docker compose --env-file .env.production exec -T db psql -U chatbot -d chatbot \
  -c "delete from chats;"

# Non viene registrato: indirizzi IP in chiaro, nomi, email dei visitatori (se non le scrivono loro
# nella chat). Il sito avvisa che è una demo e di non inserire dati veri.

In un negozio vero questa pagina va accompagnata da un’informativa e da un modo per cancellare i dati di una persona su richiesta. Qui i dati sono di prova, e il sito lo dice a chi entra.

Routine

12 · Le due liste da seguire sempre

terminale
PRIMA di toccare qualcosa
  [ ] backup del database (due secondi, vedi capitolo 5)
  [ ] so com'è adesso: docker compose ps, curl /api/health
  [ ] se tocco il Caddyfile: copia di sicurezza + caddy validate

DOPO ogni intervento
  [ ] curl -fsS https://chat-bot.giuseppebosi.com/api/health   → {"status":"ok"}
  [ ] una domanda vera all'assistente sul sito
  [ ] il widget su una pagina esterna si apre ancora
  [ ] docker compose logs --since 10m app | grep -i error   → vuoto
  [ ] annotato cosa ho fatto e quando (in docs/JOURNAL.md)

SE È GIÙ E NON SO PERCHÉ
  1. ./rollback.sh          (torna all'immagine di prima)
  2. logs dell'app e di Caddy
  3. se il database non risponde: docker compose --env-file .env.production restart db
  4. ripristino del backup solo come ultima spiaggia, e dopo aver fermato l'app

L’ultima riga è quella che fa la differenza fra un servizio mantenuto e uno che si degrada: scrivere cosa è stato fatto e quando. Tra sei mesi, l’unica persona che vorrà saperlo sarai tu.

Le altre due pagine

  • Manutenzione — contenuti, tono, correzioni: il lavoro settimanale.
  • Tutorial — come è costruito l’assistente, dal database al widget.