Operator
Operator's manual
This page is for whoever keeps the service running: deploying a version, rolling back when it goes wrong, backing up and restoring the database, rotating keys, changing the model, understanding why the chat does not answer. Every command below is the one that really runs on this VPS.
- What you need to know
- Terminal, Docker and a little SQL. You don't need to know the application code.
- What you need to have
- SSH access to the server and permissions on /etc/caddy.
- Time
- Daily checks: 2 minutes. A full restore: 15 minutes.
For the assistant's content and behaviour (fixing answers, tone, new information) the page is Maintenance. For how it is built: the tutorial.
Orientation
1 · The map, in one minute
Two containers and a proxy. Nothing else: no managed services, no queues, no external caches. The only piece outside is the model provider.
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.| Where | What is there | Who can reach it |
|---|---|---|
/etc/caddy/Caddyfile | proxy, HTTPS (and an optional password) | root on the VPS |
web/docker-compose.yml | the two containers and their limits | root on the VPS |
web/.env.production | the secrets (permissions 600) | root only, never in the repository |
| dbdata volume | the whole database | only the db container |
127.0.0.1:3040 | the application | only the proxy, not the internet |
Routine
2 · The three daily checks
Two minutes, and they cover 90% of the trouble before someone else reports it.
# 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 does not just say «the site is up»: it really queries the database. If it answers 503 db_unavailable, the application is running but has lost the database: this is the situation in which the chat tells everyone «I can't answer».
Routine
3 · Deploying and rolling back
The deploy rebuilds the image, restarts and waits for the site to respond before declaring itself done. If something goes wrong, one command takes you back: the previous image always stays on the 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.Warning. The rollback brings back the code, not the database. If the problem is a migration that has already run, the rollback is not enough: you need to restore the backup (chapter 5). That is why the backup is taken before the deploy, not after.
Routine
4 · Getting new content through
Catalogue and help centre live in two text files and are poured into the database every time the application starts. Whoever looks after the content works on the files (see Maintenance); the operator needs to know how to see and force that step.
# 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"The tools profile is a throwaway container that mounts the project read-only and talks to the database over Docker's internal network: it is the only way to run commands on the database, which is not published to the internet. It does not stay on and does not touch the application container.
Critical
5 · Backup, restore and what never to do
The database is the only thing that cannot be rebuilt: the code recompiles, the content is in the files, but carts, orders and conversations are not. A full dump of this installation weighs a few hundred 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 restartWarning. docker compose down -v deletes the dbdata volume, that is the whole database, without asking for confirmation. You never need it: to restart use restart, to update up -d. If someone suggests it «to clean up», that is the time you lose the data.
Critical
6 · Secrets and key rotation
The secrets live in a single file, with permissions 600 and outside the repository. Rotate them if someone has seen them, if the people with server access change, or as a habit once a year.
# 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'"The widget's public key (WIDGET_PUBLIC_KEY) is a special case: it is in plain text in the compose file and in the tutorial, because it reaches everyone's browser anyway. Changing it stops the scripts that used it; whoever only pasted the <script> does not notice, because the widget page is served by the server.
Critical
7 · Changing the model or the provider
Free OpenRouter models run here: a preferred one, a backup and then the free router. Zero cost, but variable quality and a daily request cap. It is the right choice for a demo and the wrong one for a real shop, where a wrong answer costs more than half a euro a month.
# 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| What you see | What is happening | What to do |
|---|---|---|
| Slow or cut-off answers | overloaded free provider | pin a paid model in AI_MODEL |
| It writes the tool JSON instead of using it | a poor model picked by the router | retry; if it happens again, pin a model |
| 429 from the provider | over the free plan's 1000 a day | wait for the next day or switch to paid |
| 401 error from the provider | key revoked or expired | new key in .env.production, then ./deploy.sh |
Routine
8 · Limits, quotas and 429s
Limits do two things: stop a single person from using up everyone's quota, and stop the owner from paying for a bot's traffic. They are all in memory, with no external services.
// 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'| Code | Meaning | Where to change it |
|---|---|---|
| 429 | too many messages (visitor or whole site) | src/lib/rate-limit.ts, CHAT_DAILY_LIMIT in the compose file |
| 401 | the x-cf-key key is missing or wrong | WIDGET_PUBLIC_KEY in the compose file |
| 403 | foreign origin or non-JSON body (CSRF defence) | src/lib/request.ts |
| 413 | request too large | MAX_BODY in the route, request_body in Caddy |
| 503 | database unreachable | chapter 9 |
Emergency
9 · First aid: symptom, cause, command
| Symptom | Most likely cause | First command |
|---|---|---|
| The site does not open | Caddy stopped or certificate | systemctl status caddy · journalctl -u caddy -n 50 |
| 502 from the proxy | app container not up | docker compose ps · logs --tail 100 app |
| The chat does not answer, the site does | model provider or quota | logs -f app while asking a question |
| «I can't answer» for everyone | database down | curl /api/health · restart db |
| Everyone gets 429 | global cap reached | restart app (resets the counters) and raise the limits |
| The widget disappears from external sites | password extended to the widget paths | chapter 10: restore the exclusions |
| The app does not come back after a deploy | failed migration or broken build | ./rollback.sh, then read the logs |
| Disk full | old Docker images | 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)Critical
10 · Caddy: HTTPS, password, other sites
This server's Caddyfile also hosts other sites: one syntax error takes them all down together. Backup, validate, reload: in this order, always.
# 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.icoWarning. The /widget.js and /embed paths and the APIs must stay outside the password: they are the ones the widget uses inside the sites of whoever installed it. If you protect them, their visitors get a password prompt they cannot answer.
Critical
11 · Stored data and deletion
Few tables, short retention and no plain IP addresses. It is the minimum needed to answer the question «what do you keep about me?» without reading the code.
# 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 a real shop this page must come with a privacy notice and a way to delete a person's data on request. Here the data is test data, and the site says so to whoever comes in.
Routine
12 · The two lists to always follow
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'appThe last line is what makes the difference between a maintained service and one that decays: write down what was done and when. In six months, the only person who will want to know is you.
The other two pages
- Maintenance — content, tone, fixes: the weekly work.
- Tutorial — how the assistant is built, from the database to the widget.