docs: runbook deploy completo su VPS Virtualmin

Guida step-by-step per deployare KetoPath sul server `lamiadieta.luzaonline.net`: preparazione VPS, PostgreSQL 15+ con backup, env, build, PM2, reverse proxy Apache + Let's Encrypt sui due sottodomini (web + api), webhook Stripe pubblico, operazioni quotidiane, troubleshooting. Riusabile come runbook per futuri deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-05-07 11:51:57 +02:00
1 parent f455cbe499
commit 11d73eae69
1 file changed
+717
+717
View File
@@ -0,0 +1,717 @@
# Deploy KetoPath su VPS Virtualmin
> Runbook per il deploy di KetoPath su un VPS Linux con Virtualmin (CentOS/Debian/Ubuntu). Coperto: preparazione server, PostgreSQL, deploy app (frontend + backend), reverse proxy + SSL, Stripe webhook, backup, troubleshooting.
>
> Target: dominio `lamiadieta.luzaonline.net` (frontend) e sottodominio `api.lamiadieta.luzaonline.net` (backend).
>
> Tempo stimato: 30-45 minuti se tutto fila, 1-2 ore con i primi inciampi.
---
## 0. Convenzioni
Tutti i blocchi `bash` si lanciano via SSH come utente con `sudo` (di norma `root`, oppure il tuo utente di Virtualmin). Per l'utente di runtime dell'applicazione useremo l'utente già esistente del dominio, **`lamiadieta`**, con home `/home/lamiadieta`.
I comandi marcati con `# come root` vanno lanciati con privilegi di root (via `sudo` o `su -`). Quelli marcati con `# come lamiadieta` da:
```bash
sudo -u lamiadieta -i
```
Il **`<PASSWORD_DB>`** è la password del DB Postgres che genererai al passaggio §2 — non è la password MariaDB esistente. La password di MariaDB esposta in chat **deve essere ruotata** dal pannello Virtualmin **prima di proseguire**.
---
## 1. Preparazione server
### 1.1 Verifica OS e installa pacchetti base
```bash
# come root
cat /etc/os-release # verifica distribuzione (Ubuntu/Debian/CentOS/AlmaLinux)
uname -a # kernel
df -h # spazio disco — KetoPath chiede ~2 GB inclusi node_modules
free -h # RAM — minimo 2 GB consigliato
```
Pacchetti che ci servono (Debian/Ubuntu):
```bash
# come root
apt update
apt install -y curl git build-essential ca-certificates gnupg ufw
```
CentOS/AlmaLinux:
```bash
# come root
dnf install -y curl git gcc gcc-c++ make ca-certificates
```
### 1.2 Installa Node.js 20
```bash
# come root
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs
# Verifica
node --version # v20.x.x
npm --version
```
Su CentOS:
```bash
curl -fsSL https://rpm.nodesource.com/setup_20.x | bash -
dnf install -y nodejs
```
### 1.3 Installa pnpm
```bash
# come root
npm install -g pnpm@9
pnpm --version # 9.x
```
### 1.4 Installa PostgreSQL 15+
Debian/Ubuntu:
```bash
# come root
sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg
apt update
apt install -y postgresql-15
systemctl enable --now postgresql
systemctl status postgresql # active (running)
```
CentOS/AlmaLinux:
```bash
# come root
dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm
dnf -qy module disable postgresql
dnf install -y postgresql15-server postgresql15-contrib
/usr/pgsql-15/bin/postgresql-15-setup initdb
systemctl enable --now postgresql-15
```
### 1.5 Installa PM2 (process manager)
```bash
# come root
npm install -g pm2
# Configura PM2 per partire al boot come utente lamiadieta
pm2 startup systemd -u lamiadieta --hp /home/lamiadieta
# (esegui il comando suggerito dall'output, ti dirà esattamente quale)
```
### 1.6 Firewall
```bash
# come root (Debian/Ubuntu)
ufw allow 22/tcp # SSH
ufw allow 80/tcp # HTTP (Let's Encrypt challenge)
ufw allow 443/tcp # HTTPS
ufw --force enable
ufw status
```
CentOS:
```bash
firewall-cmd --permanent --add-service=ssh
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --reload
```
**Critico**: Postgres NON deve essere esposto. Resta in bind `127.0.0.1`. Ne riparliamo al §2.3.
---
## 2. Setup PostgreSQL
### 2.1 Genera una password robusta
```bash
# come root
openssl rand -base64 32
# Copia l'output: questa sarà <PASSWORD_DB>
```
Salva la password in un password manager. **Non incollarla in chat.**
### 2.2 Crea utente e database
```bash
# come root
sudo -u postgres psql <<EOF
CREATE USER ketopath WITH PASSWORD '<PASSWORD_DB>';
CREATE DATABASE ketopath OWNER ketopath;
GRANT ALL PRIVILEGES ON DATABASE ketopath TO ketopath;
EOF
```
### 2.3 Verifica bind locale (no esposizione esterna)
```bash
# come root
grep listen_addresses /etc/postgresql/15/main/postgresql.conf
# Deve essere: listen_addresses = 'localhost' (o non valorizzato)
```
Se è `'*'` o un indirizzo pubblico, correggilo:
```bash
sed -i "s/^#*listen_addresses.*/listen_addresses = 'localhost'/" /etc/postgresql/15/main/postgresql.conf
systemctl restart postgresql
```
Test connessione:
```bash
psql "postgresql://ketopath:<PASSWORD_DB>@127.0.0.1:5432/ketopath" -c '\l'
# Deve listare i database, niente errori.
```
### 2.4 Backup automatico (cron giornaliero)
```bash
# come root
mkdir -p /var/backups/postgres
chown postgres:postgres /var/backups/postgres
cat > /etc/cron.daily/postgres-ketopath-backup <<'EOF'
#!/bin/bash
DATE=$(date +%Y%m%d)
sudo -u postgres pg_dump ketopath | gzip > /var/backups/postgres/ketopath-$DATE.sql.gz
find /var/backups/postgres -name "ketopath-*.sql.gz" -mtime +14 -delete
EOF
chmod +x /etc/cron.daily/postgres-ketopath-backup
```
(Tiene 14 giorni di backup giornalieri. Per backup off-site valuta dopo: rclone su Cloudflare R2, S3, ecc.)
---
## 3. Setup utente applicativo
```bash
# come root
sudo -u lamiadieta -i
# Da qui in poi siamo "come lamiadieta"
mkdir -p ~/apps
cd ~/apps
```
### 3.1 SSH key per GitHub
Se il repo è privato:
```bash
# come lamiadieta
ssh-keygen -t ed25519 -C "lamiadieta@luzaonline" -f ~/.ssh/id_ed25519 -N ""
cat ~/.ssh/id_ed25519.pub
# Copia l'output → GitHub → Settings → SSH and GPG keys → New SSH key
```
### 3.2 Clone del repo
```bash
# come lamiadieta
cd ~/apps
git clone git@github.com:<your-user>/DietApp.git ketopath
# oppure HTTPS se il repo è pubblico:
# git clone https://github.com/<your-user>/DietApp.git ketopath
cd ketopath
git checkout main
```
### 3.3 Install dipendenze
```bash
# come lamiadieta
cd ~/apps/ketopath
pnpm install --frozen-lockfile
# (può richiedere 2-3 minuti la prima volta)
```
---
## 4. Configurazione env
### 4.1 Genera segreti
```bash
# come lamiadieta
# Better Auth secret (32+ char):
openssl rand -base64 32
# Prisma field encryption key (vedi ADR 0002):
node -e "console.log('k1.aesgcm256.'+require('crypto').generateKeySync('aes',{length:256}).export().toString('base64url'))"
# VAPID per Web Push:
cd ~/apps/ketopath/apps/api
node -e "console.log(require('web-push').generateVAPIDKeys())"
```
Annota i tre output: `<BETTER_AUTH_SECRET>`, `<PRISMA_KEY>`, `<VAPID_PUBLIC>`, `<VAPID_PRIVATE>`.
### 4.2 Crea `apps/api/.env`
```bash
# come lamiadieta
cd ~/apps/ketopath/apps/api
cp .env.example .env
nano .env # o vim
```
Compila i valori reali:
```ini
NODE_ENV=production
PORT=4000
HOST=127.0.0.1
LOG_LEVEL=info
CORS_ORIGINS=https://lamiadieta.luzaonline.net
DATABASE_URL=postgresql://ketopath:<PASSWORD_DB>@127.0.0.1:5432/ketopath
PRISMA_FIELD_ENCRYPTION_KEY=<PRISMA_KEY>
BETTER_AUTH_SECRET=<BETTER_AUTH_SECRET>
BETTER_AUTH_URL=https://lamiadieta.luzaonline.net
# Sentry (opzionale)
# SENTRY_DSN=
VAPID_PUBLIC_KEY=<VAPID_PUBLIC>
VAPID_PRIVATE_KEY=<VAPID_PRIVATE>
VAPID_SUBJECT=mailto:tuo@email.it
# Stripe (test mode all'inizio — vedi §7)
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_ID_MONTHLY=
STRIPE_PRICE_ID_YEARLY=
BILLING_RETURN_URL=https://lamiadieta.luzaonline.net
```
### 4.3 Crea `apps/web/.env`
```bash
# come lamiadieta
cd ~/apps/ketopath/apps/web
cat > .env <<EOF
NODE_ENV=production
DATABASE_URL=postgresql://ketopath:<PASSWORD_DB>@127.0.0.1:5432/ketopath
PRISMA_FIELD_ENCRYPTION_KEY=<PRISMA_KEY>
BETTER_AUTH_SECRET=<BETTER_AUTH_SECRET>
BETTER_AUTH_URL=https://lamiadieta.luzaonline.net
API_URL=http://127.0.0.1:4000
NEXT_PUBLIC_VAPID_PUBLIC_KEY=<VAPID_PUBLIC>
EOF
```
### 4.4 Crea `packages/db/.env`
Prisma legge da qui durante le migration.
```bash
# come lamiadieta
cd ~/apps/ketopath/packages/db
cat > .env <<EOF
DATABASE_URL=postgresql://ketopath:<PASSWORD_DB>@127.0.0.1:5432/ketopath
PRISMA_FIELD_ENCRYPTION_KEY=<PRISMA_KEY>
EOF
```
### 4.5 Permessi restrittivi sui file env
```bash
# come lamiadieta
cd ~/apps/ketopath
chmod 600 apps/api/.env apps/web/.env packages/db/.env
```
---
## 5. Migration + build + start
### 5.1 Applica le migration al DB
```bash
# come lamiadieta
cd ~/apps/ketopath
pnpm --filter @ketopath/db exec prisma migrate deploy
# Output atteso: "16 migrations found ... applied"
```
### 5.2 Seed dei dati iniziali (ricette, ingredienti)
```bash
# come lamiadieta
cd ~/apps/ketopath
pnpm db:seed
# Output: "[seed] inserted 96 recipes, 220 ingredients, ..."
```
### 5.3 Build
```bash
# come lamiadieta
cd ~/apps/ketopath
pnpm -r build
# Compila packages, apps/api, apps/web. Richiede 1-3 min.
```
### 5.4 Smoke test manuale (prima di PM2)
```bash
# come lamiadieta
cd ~/apps/ketopath/apps/api
node --env-file=.env dist/server.js &
sleep 2
curl -s http://127.0.0.1:4000/health # {"status":"ok"}
kill %1
```
```bash
cd ~/apps/ketopath/apps/web
pnpm start &
sleep 5
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000 # 200 o 307 (redirect locale)
kill %1
```
Se entrambi rispondono ok → tutto pronto per PM2.
---
## 6. Process manager (PM2)
Crea il file di configurazione PM2:
```bash
# come lamiadieta
cd ~/apps/ketopath
cat > ecosystem.config.cjs <<'EOF'
module.exports = {
apps: [
{
name: 'ketopath-api',
cwd: '/home/lamiadieta/apps/ketopath/apps/api',
script: 'dist/server.js',
node_args: '--env-file=.env',
env: { NODE_ENV: 'production' },
max_memory_restart: '512M',
autorestart: true,
watch: false,
time: true,
},
{
name: 'ketopath-web',
cwd: '/home/lamiadieta/apps/ketopath/apps/web',
script: 'node_modules/next/dist/bin/next',
args: 'start --port 3000',
env: { NODE_ENV: 'production' },
max_memory_restart: '768M',
autorestart: true,
watch: false,
time: true,
},
],
};
EOF
pm2 start ecosystem.config.cjs
pm2 save # rendi persistente al reboot
pm2 status # entrambi "online"
pm2 logs # log live, Ctrl-C per uscire
```
Verifica:
```bash
curl -s http://127.0.0.1:4000/health
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000
```
---
## 7. Reverse proxy + SSL (via Virtualmin)
Virtualmin gestisce Apache (più raramente nginx). I passaggi sono concettualmente identici per entrambi.
### 7.1 Crea il sottodominio per l'API
In Virtualmin:
1. **Servers** → seleziona `lamiadieta.luzaonline.net`
2. **Create Sub-Server** (o "Create Server") → `api.lamiadieta.luzaonline.net`
3. Tipo: "Sub-server" sotto `lamiadieta.luzaonline.net`
4. Crea senza opzioni speciali (niente PHP, niente database, niente FTP)
### 7.2 Configura Apache come reverse proxy
Per il sottodominio API. Da Virtualmin:
1. Nuovo sottodominio `api.lamiadieta.luzaonline.net` → **Services** → **Configure Website** (o "Apache Webserver")
2. Aggiungi le direttive proxy nel virtual host. Da SSH puoi anche modificare direttamente:
```bash
# come root
nano /etc/apache2/sites-enabled/api.lamiadieta.luzaonline.net.conf
```
Aggiungi nel `<VirtualHost *:80>` e nel `<VirtualHost *:443>` (se già creato):
```apache
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:4000/
ProxyPassReverse / http://127.0.0.1:4000/
# Webhook Stripe ha bisogno del raw body — disattiva eventuali filtri
<Location /webhooks/stripe>
SetEnv proxy-sendchunked 1
</Location>
```
Stessa operazione per il dominio principale `lamiadieta.luzaonline.net`, che fa da proxy verso `:3000`:
```apache
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:3000/
ProxyPassReverse / http://127.0.0.1:3000/
# WebSocket support per Next.js dev/HMR (anche in prod per fast refresh sui server actions)
RewriteEngine On
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://127.0.0.1:3000/$1" [P,L]
```
Abilita i moduli proxy se non lo sono già:
```bash
# come root
a2enmod proxy proxy_http proxy_wstunnel rewrite headers
systemctl reload apache2
```
### 7.3 SSL Let's Encrypt
In Virtualmin:
1. **Server `lamiadieta.luzaonline.net`** → **Server Configuration** → **SSL Certificate** → **Let's Encrypt** → "Request Certificate"
2. Stesso passo per `api.lamiadieta.luzaonline.net`
Dopo il rilascio, Virtualmin aggiorna automaticamente i virtual host con le direttive `SSLCertificateFile` ecc.
### 7.4 Forza HTTPS
Nel virtual host HTTP (porta 80), aggiungi:
```apache
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^/?(.*)$ https://%{HTTP_HOST}/$1 [R=301,L]
```
### 7.5 Verifica finale
```bash
curl -s -I https://lamiadieta.luzaonline.net
# 200 OK, server: Apache, x-powered-by: Next.js
curl -s https://api.lamiadieta.luzaonline.net/health
# {"status":"ok"}
```
---
## 8. Configurazione Stripe webhook
### 8.1 Account Stripe (vedi §1-§4 della guida Stripe in chat)
Dashboard → Test mode → Developers → API keys → copia `sk_test_...`
### 8.2 Crea i prezzi
Dashboard → Products → Add product "KetoPath Pro" con due Prices:
- €9,90 / mese (recurring)
- €89 / anno (recurring)
Copia i due `price_...`.
### 8.3 Webhook su URL pubblico
Dashboard → Developers → Webhooks → Add endpoint:
- **Endpoint URL**: `https://api.lamiadieta.luzaonline.net/webhooks/stripe`
- **Events to send**:
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `checkout.session.completed`
- `invoice.payment_failed`
Dopo la creazione, sul dettaglio del webhook → **Signing secret** → "Reveal" → copia `whsec_...`.
### 8.4 Aggiorna `apps/api/.env` e riavvia
```bash
# come lamiadieta
nano ~/apps/ketopath/apps/api/.env
# Compila:
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_WEBHOOK_SECRET=whsec_...
# STRIPE_PRICE_ID_MONTHLY=price_...
# STRIPE_PRICE_ID_YEARLY=price_...
pm2 restart ketopath-api
pm2 logs ketopath-api --lines 30
```
### 8.5 Test del webhook
Dal dashboard Stripe → il tuo webhook → "Send test webhook" → seleziona `customer.subscription.created` → Send.
In `pm2 logs ketopath-api` dovresti vedere `200 OK` per la richiesta. Se vedi `400 invalid_signature`: il secret nell'env è sbagliato o il body è stato manipolato dal proxy (controlla §7.2).
---
## 9. Operazioni quotidiane
### Aggiornare il codice
```bash
# come lamiadieta
cd ~/apps/ketopath
git pull origin main
pnpm install --frozen-lockfile
pnpm --filter @ketopath/db exec prisma migrate deploy
pnpm -r build
pm2 restart all
```
### Vedere i log
```bash
pm2 logs # tutti
pm2 logs ketopath-api # solo API
pm2 logs ketopath-web --lines 100 # ultime 100 righe del web
```
### Restart di un singolo processo
```bash
pm2 restart ketopath-api
pm2 restart ketopath-web
```
### Stato e metriche
```bash
pm2 status # CPU/RAM dei processi
pm2 monit # dashboard interattiva
```
### Backup manuale on-demand
```bash
sudo -u postgres pg_dump ketopath | gzip > ~/ketopath-$(date +%Y%m%d).sql.gz
```
### Restore di un backup
```bash
gunzip -c ~/ketopath-YYYYMMDD.sql.gz | sudo -u postgres psql ketopath
```
---
## 10. Troubleshooting
### Il backend riparte in loop
```bash
pm2 logs ketopath-api --lines 200
```
Cause comuni:
- `.env` mancante o malformato → 500 al primo request
- DB non raggiungibile → `Error: Can't reach database server`
- `BETTER_AUTH_SECRET` < 32 char → Zod throw al boot
### `bad gateway` (502/504) dal browser
Il proxy Apache risponde ma il backend Node non c'è.
```bash
pm2 status # ketopath-* deve essere "online"
ss -ltn | grep -E '3000|4000' # porte in ascolto
```
### `cookie not set` / login non persiste
`BETTER_AUTH_URL` deve essere **identico** in `apps/api/.env` e `apps/web/.env`, e il dominio dei cookie deve coincidere col dominio servito (HTTPS, no trailing slash).
### Webhook Stripe risponde 400 `invalid_signature`
Il body del webhook è stato modificato dal proxy. Su Apache, evita compressione/buffering per quel path:
```apache
<Location /webhooks/stripe>
RequestHeader unset Accept-Encoding
SetEnv no-gzip 1
SetEnv proxy-sendchunked 1
</Location>
```
### Performance lenta
Per un MVP single-VPS è atteso. Quando l'utenza cresce:
- `pm2 reload all` (zero-downtime restart)
- `pg_stat_statements` per le query lente
- Redis (richiesto per cache/queue dal CLAUDE.md, oggi non strettamente necessario)
- Splittare frontend e backend su due macchine + DB managed
---
## 11. Checklist finale
- [ ] OS aggiornato, firewall attivo, solo 22/80/443 esposte
- [ ] PostgreSQL 15+ in bind localhost, password robusta, backup giornaliero attivo
- [ ] Utente `lamiadieta` ha clonato il repo, install + build OK
- [ ] `.env` di api/web/db con valori reali, permessi 600
- [ ] PM2 avvia entrambi i processi, `pm2 save` eseguito
- [ ] Sottodominio `api.lamiadieta.luzaonline.net` creato e con SSL
- [ ] Apache fa proxy verso `:3000` (web) e `:4000` (api)
- [ ] HTTP forzato a HTTPS
- [ ] Stripe webhook puntato all'URL pubblico, `whsec_` in env, test webhook = 200
- [ ] Smoke test end-to-end: signup → onboarding → genera piano → vai su /billing → checkout test card → "Abbonamento attivo"
---
## 12. Note di sicurezza ricorrenti
- **Mai** committare `.env` o file `.env.local` — `.gitignore` li copre già, ma verifica con `git status` prima di ogni `git add`.
- **Mai** mostrare la password DB / il `BETTER_AUTH_SECRET` / le chiavi Stripe in log applicativi o tracker errori (Sentry maschera per default ma controlla).
- Ruota `BETTER_AUTH_SECRET` ogni 12 mesi — invaliderà tutte le sessioni esistenti, gli utenti dovranno rifare login.
- Aggiornamenti di sicurezza OS: `apt update && apt upgrade` settimanale, kernel update mensile (con reboot pianificato).
- Stripe webhook secret: ruotalo se sospetti compromissione → dashboard Stripe → endpoint → "Roll secret" → aggiorna env → `pm2 restart`.