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.
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.| Dove | Cosa c’è | Chi lo può raggiungere |
|---|---|---|
/etc/caddy/Caddyfile | proxy, HTTPS, password del sito | root sul VPS |
web/docker-compose.yml | i due container e i loro limiti | root sul VPS |
web/.env.production | i segreti (permessi 600) | solo root, mai nel repository |
| volume dbdata | tutto il database | solo il container db |
127.0.0.1:3040 | l’applicazione | solo il proxy, non internet |
Routine
2 · I tre controlli quotidiani
Due minuti, e coprono il 90% dei guai prima che li segnali qualcun altro.
# 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.
# 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.
# 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.
# 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 restartAttenzione. 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.
# 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.
# 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 vedi | Cosa sta succedendo | Cosa fare |
|---|---|---|
| Risposte lente o mozzate | fornitore gratuito sovraccarico | fissare un modello a pagamento in AI_MODEL |
| Scrive il JSON dello strumento invece di usarlo | modello scadente pescato dal router | riprovare; se ricapita, modello fisso |
| 429 dal fornitore | superati i 1000 al giorno del piano gratuito | aspettare il giorno dopo o passare a pagamento |
| Errore 401 dal fornitore | chiave revocata o scaduta | nuova 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.
// 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'| Codice | Significato | Dove si cambia |
|---|---|---|
| 429 | troppi messaggi (visitatore o sito intero) | src/lib/rate-limit.ts, CHAT_DAILY_LIMIT nel compose |
| 401 | manca o è sbagliata la chiave x-cf-key | WIDGET_PUBLIC_KEY nel compose |
| 403 | origine estranea o corpo non JSON (difesa CSRF) | src/lib/request.ts |
| 413 | richiesta troppo grande | MAX_BODY nella route, request_body in Caddy |
| 503 | database non raggiungibile | capitolo 9 |
Emergenza
9 · Pronto soccorso: sintomo, causa, comando
| Sintomo | Causa più probabile | Primo comando |
|---|---|---|
| Il sito non si apre | Caddy fermo o certificato | systemctl status caddy · journalctl -u caddy -n 50 |
| 502 dal proxy | container app non in piedi | docker compose ps · logs --tail 100 app |
| La chat non risponde, il sito sì | fornitore del modello o quota | logs -f app mentre si fa una domanda |
| «Non riesco a rispondere» a tutti | database giù | curl /api/health · restart db |
| Tutti ricevono 429 | tetto globale raggiunto | restart app (azzera i contatori) e alzare i limiti |
| Il widget sparisce dai siti esterni | password estesa ai percorsi del widget | capitolo 10: rimettere le esclusioni |
| L’app non riparte dopo un deploy | migrazione fallita o build rotta | ./rollback.sh, poi leggere i log |
| Disco pieno | immagini Docker vecchie | docker system df · docker image prune -f |
# 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.
# 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.icoAttenzione. 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.
# 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
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'appL’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.