Files
lucianoandClaude Opus 4.7 38f28e015b docs: documenta NEXT_PUBLIC_APP_URL e setup Google OAuth nel runbook
Due colpevoli scoperti durante il deploy live:
- `NEXT_PUBLIC_APP_URL` mancava nel runbook ed in .env.example. Il client di Better Auth (lib/auth-client.ts) la usa come baseURL — senza, fallback a localhost:3000 → CORS error sul click di "Continua con Google". Va inlined al build (è NEXT_PUBLIC_*), quindi modificarla richiede rebuild.
- Setup Google OAuth: il bottone è SSG (prerendered al build), quindi aggiungere le env GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET non basta — serve rebuild perché `enabledSocialProviders` è valutato a build-time.

Aggiunta una sezione 4.3.bis dedicata al setup Google con i punti critici evidenziati.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:52:53 +02:00

19 KiB

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:

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

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

# come root
apt update
apt install -y curl git build-essential ca-certificates gnupg ufw

CentOS/AlmaLinux:

# come root
dnf install -y curl git gcc gcc-c++ make ca-certificates

1.2 Installa Node.js 20

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

curl -fsSL https://rpm.nodesource.com/setup_20.x | bash -
dnf install -y nodejs

1.3 Installa pnpm

# come root
npm install -g pnpm@9
pnpm --version    # 9.x

1.4 Installa PostgreSQL 15+

Debian/Ubuntu:

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

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

# 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

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

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

# 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

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

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

sed -i "s/^#*listen_addresses.*/listen_addresses = 'localhost'/" /etc/postgresql/15/main/postgresql.conf
systemctl restart postgresql

Test connessione:

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)

# 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

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

# 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

# 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

# come lamiadieta
cd ~/apps/ketopath
pnpm install --frozen-lockfile
# (può richiedere 2-3 minuti la prima volta)

4. Configurazione env

4.1 Genera segreti

# 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

# come lamiadieta
cd ~/apps/ketopath/apps/api
cp .env.example .env
nano .env       # o vim

Compila i valori reali:

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

# 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>
# IMPORTANTE: il client di Better Auth (lib/auth-client.ts) usa questa env per
# costruire la baseURL delle fetch OAuth. Se manca → fallback a localhost:3000
# → CORS error sul login Google. È inlined al build, quindi cambiarla richiede
# un nuovo `pnpm --filter web build`.
NEXT_PUBLIC_APP_URL=https://lamiadieta.luzaonline.net
EOF

4.3.bis Google OAuth (opzionale, per il login con Google)

Se vuoi abilitare "Continua con Google" su /sign-in e /sign-up:

  1. Google Cloud Console (https://console.cloud.google.com/) → crea progetto KetoPath.
  2. APIs & Services → OAuth consent screen → External, scopes userinfo.email + userinfo.profile, Authorized domain <DOMAIN>. Aggiungi te stesso come Test User finché l'app è in modalità Testing.
  3. APIs & Services → Credentials → Create OAuth client ID (Web application):
    • Authorized JavaScript origins: https://<DOMAIN>
    • Authorized redirect URI: https://<DOMAIN>/api/auth/callback/google
  4. Aggiungi le credenziali a entrambi i .env (api e web) — Better Auth le legge da process.env al boot:
# Da fare in apps/api/.env E apps/web/.env
echo "GOOGLE_CLIENT_ID='<client_id>.apps.googleusercontent.com'" >> apps/api/.env
echo "GOOGLE_CLIENT_SECRET='GOCSPX-...'" >> apps/api/.env
# (idem per apps/web/.env)
  1. Rebuild + restart: la pagina /sign-in è SSG (prerendered al build). Per renderizzare il bottone Google serve un nuovo build.
pnpm --filter web build
pm2 restart ketopath-web --update-env

4.4 Crea packages/db/.env

Prisma legge da qui durante le migration.

# 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

# 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

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

# come lamiadieta
cd ~/apps/ketopath
pnpm db:seed
# Output: "[seed] inserted 96 recipes, 220 ingredients, ..."

5.3 Build

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

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

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

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:
# come root
nano /etc/apache2/sites-enabled/api.lamiadieta.luzaonline.net.conf

Aggiungi nel <VirtualHost *:80> e nel <VirtualHost *:443> (se già creato):

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:

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à:

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

RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^/?(.*)$ https://%{HTTP_HOST}/$1 [R=301,L]

7.5 Verifica finale

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

# 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

# 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

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

pm2 restart ketopath-api
pm2 restart ketopath-web

Stato e metriche

pm2 status                         # CPU/RAM dei processi
pm2 monit                          # dashboard interattiva

Backup manuale on-demand

sudo -u postgres pg_dump ketopath | gzip > ~/ketopath-$(date +%Y%m%d).sql.gz

Restore di un backup

gunzip -c ~/ketopath-YYYYMMDD.sql.gz | sudo -u postgres psql ketopath

10. Troubleshooting

Il backend riparte in loop

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'è.

pm2 status                         # ketopath-* deve essere "online"
ss -ltn | grep -E '3000|4000'      # porte in ascolto

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:

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