Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Deploy stack su Proxmox / deploy (push) Failing after 3s
Deploy stack su Proxmox / deploy (push) Failing after 3s
Monorepo npm con apps/public (Angular SSR), apps/private (Angular SPA), apps/api (Fastify), packages/shared e packages/styles; Medusa 2.21 in medusa/; infrastruttura Docker (Postgres PostGIS+pgvector, Redis, Caddy, backup) e workflow Gitea Actions per il deploy via SSH a ogni push su master. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jQyT6dR9czzGwqb7FkG6u
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
#!/bin/sh
|
||||
# Backup giornaliero di produzione: dump dei due database + archivi dei file caricati, con rotazione.
|
||||
# Gira nel container "backup" (profilo tools) di docker-compose.prod.yml:
|
||||
# docker compose -f docker-compose.prod.yml run --rm backup (oppure: npm run backup:prod)
|
||||
# Risultato in BACKUP_DIR (host) → <AAAAMMGGTHHMMSSZ>/
|
||||
# app.dump, medusa.dump pg_dump formato custom (pg_restore), proprietari inclusi
|
||||
# uploads.tar.gz volume uploads dell'API (public/ e private/)
|
||||
# medusa-files.tar.gz file caricati dal dashboard Medusa (immagini prodotto)
|
||||
# SHA256SUMS verificati dal ripristino
|
||||
# Tiene gli ultimi BACKUP_KEEP_DAYS backup (default 14). Redis non serve: cache, sessioni e code.
|
||||
set -eu
|
||||
umask 077
|
||||
|
||||
KEEP="${BACKUP_KEEP_DAYS:-14}"
|
||||
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
TMP="/backups/.tmp-$STAMP"
|
||||
DEST="/backups/$STAMP"
|
||||
|
||||
log() { echo "[backup $(date -u +%H:%M:%S)] $*"; }
|
||||
|
||||
rm -rf /backups/.tmp-*
|
||||
mkdir -p "$TMP"
|
||||
|
||||
log "dump $APP_DB_NAME"
|
||||
pg_dump --format=custom --dbname="$APP_DB_NAME" --file="$TMP/app.dump"
|
||||
log "dump $MEDUSA_DB_NAME"
|
||||
pg_dump --format=custom --dbname="$MEDUSA_DB_NAME" --file="$TMP/medusa.dump"
|
||||
|
||||
log "archivio uploads"
|
||||
tar -C /data/uploads -czf "$TMP/uploads.tar.gz" .
|
||||
log "archivio medusa-files"
|
||||
tar -C /data/medusa-files -czf "$TMP/medusa-files.tar.gz" .
|
||||
|
||||
(cd "$TMP" && sha256sum app.dump medusa.dump uploads.tar.gz medusa-files.tar.gz > SHA256SUMS)
|
||||
# Rinomina solo a backup completo: una cartella con il timestamp è sempre un backup integro
|
||||
mv "$TMP" "$DEST"
|
||||
log "completato: $DEST ($(du -sh "$DEST" | cut -f1))"
|
||||
|
||||
# Rotazione: i nomi sono ordinabili, si tengono gli ultimi $KEEP
|
||||
OLD="$(ls -1d /backups/[0-9]*T*Z 2>/dev/null | sort | head -n "-$KEEP")"
|
||||
for d in $OLD; do
|
||||
log "rimosso $d"
|
||||
rm -rf "$d"
|
||||
done
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/bin/sh
|
||||
# Ripristino di un backup creato da backup.sh. Sostituisce COMPLETAMENTE i due database e i file caricati.
|
||||
# Prima fermare chi usa i database:
|
||||
# docker compose -f docker-compose.prod.yml stop api medusa public
|
||||
# docker compose -f docker-compose.prod.yml run --rm restore <AAAAMMGGTHHMMSSZ | latest>
|
||||
# docker compose -f docker-compose.prod.yml up -d --wait
|
||||
set -eu
|
||||
|
||||
ID="${1:-}"
|
||||
if [ -z "$ID" ]; then
|
||||
echo "Uso: restore <cartella del backup | latest>. Backup disponibili:" >&2
|
||||
ls -1 /backups | grep -E '^[0-9]+T[0-9]+Z$' >&2 || echo " (nessuno)" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ "$ID" = "latest" ]; then
|
||||
ID="$(ls -1 /backups | grep -E '^[0-9]+T[0-9]+Z$' | sort | tail -n 1)"
|
||||
[ -n "$ID" ] || { echo "Nessun backup in /backups" >&2; exit 1; }
|
||||
fi
|
||||
SRC="/backups/$ID"
|
||||
[ -d "$SRC" ] || { echo "Backup $SRC inesistente" >&2; exit 1; }
|
||||
|
||||
log() { echo "[restore $(date -u +%H:%M:%S)] $*"; }
|
||||
|
||||
log "verifica checksum di $ID"
|
||||
(cd "$SRC" && sha256sum -c SHA256SUMS)
|
||||
|
||||
# Nessuna connessione degli utenti applicativi: API e Medusa devono essere ferme
|
||||
ACTIVE="$(psql -d postgres -tA -v ON_ERROR_STOP=1 \
|
||||
-c "SELECT count(*) FROM pg_stat_activity WHERE usename IN ('$APP_DB_USER', '$MEDUSA_DB_USER')")"
|
||||
if [ "$ACTIVE" != "0" ]; then
|
||||
echo "Ci sono $ACTIVE connessioni di $APP_DB_USER/$MEDUSA_DB_USER: fermare prima api, medusa e public" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
restore_db() {
|
||||
db="$1"; owner="$2"; dump="$3"
|
||||
log "ricreo il database $db (proprietario $owner)"
|
||||
psql -d postgres -v ON_ERROR_STOP=1 -q \
|
||||
-c "DROP DATABASE IF EXISTS \"$db\" WITH (FORCE)" \
|
||||
-c "CREATE DATABASE \"$db\" OWNER \"$owner\" ENCODING 'UTF8' TEMPLATE template0" \
|
||||
-c "REVOKE ALL ON DATABASE \"$db\" FROM PUBLIC"
|
||||
# Da superuser: ricrea anche le estensioni (postgis, vector, …) e mantiene i proprietari originali
|
||||
log "pg_restore $dump → $db"
|
||||
pg_restore --exit-on-error --dbname="$db" "$SRC/$dump"
|
||||
}
|
||||
|
||||
restore_db "$APP_DB_NAME" "$APP_DB_USER" app.dump
|
||||
restore_db "$MEDUSA_DB_NAME" "$MEDUSA_DB_USER" medusa.dump
|
||||
|
||||
restore_files() {
|
||||
dir="$1"; archive="$2"
|
||||
log "ripristino $dir da $archive"
|
||||
# Svuota il contenuto ma NON le cartelle di primo livello (public/, private/): Caddy monta uploads/public
|
||||
# con un subpath, e una cartella ricreata non sarebbe più quella montata (file serviti come 404)
|
||||
find "$dir" -mindepth 1 -maxdepth 1 ! -type d -delete
|
||||
find "$dir" -mindepth 2 -delete
|
||||
tar -C "$dir" -xzpf "$SRC/$archive"
|
||||
}
|
||||
|
||||
restore_files /data/uploads uploads.tar.gz
|
||||
restore_files /data/medusa-files medusa-files.tar.gz
|
||||
|
||||
log "ripristino di $ID completato: riavviare i servizi (docker compose -f docker-compose.prod.yml up -d --wait)"
|
||||
@@ -0,0 +1,178 @@
|
||||
# Caddy di produzione ITI TECHNOLOGY: reverse proxy + HTTPS automatico (Let's Encrypt) su sottodomini di {$DOMAIN}.
|
||||
#
|
||||
# {$DOMAIN} → sito pubblico (public:4000, SSR) www.{$DOMAIN} → 301 su {$DOMAIN}
|
||||
# area.{$DOMAIN} → area riservata (statico /srv/area, fallback SPA) + /config.json generato qui
|
||||
# api.{$DOMAIN} → API Fastify (api:3000) + il solo webhook Stripe di Medusa
|
||||
# shop-admin.{$DOMAIN} → dashboard Medusa (medusa:9000/app), whitelist IP + basic auth
|
||||
# media.{$DOMAIN} → uploads/public dell'API e file pubblici di Medusa (/shop/); uploads/private non è montato
|
||||
#
|
||||
# Variabili (dal docker-compose): DOMAIN, ACME_EMAIL, SHOP_ADMIN_USER, SHOP_ADMIN_PASSWORD_HASH (bcrypt di
|
||||
# `caddy hash-password`), SHOP_ADMIN_ALLOWED_IPS (CIDR separati da spazio; default tutti).
|
||||
# La Store API di Medusa non è esposta: il sito passa sempre dall'API Fastify.
|
||||
# Nota: dentro un sito Caddy riordina le direttive per tipo; dove l'ordine conta si usa `route`, che lo conserva.
|
||||
{
|
||||
email {$ACME_EMAIL}
|
||||
}
|
||||
|
||||
# --- Snippet comuni ---
|
||||
|
||||
(common) {
|
||||
encode zstd gzip
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000"
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
-Server
|
||||
-X-Powered-By
|
||||
}
|
||||
log {
|
||||
output stdout
|
||||
format json
|
||||
}
|
||||
}
|
||||
|
||||
# --- Sito pubblico ---
|
||||
|
||||
www.{$DOMAIN} {
|
||||
import common
|
||||
redir https://{$DOMAIN}{uri} permanent
|
||||
}
|
||||
|
||||
{$DOMAIN} {
|
||||
import common
|
||||
header {
|
||||
X-Frame-Options "DENY"
|
||||
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(self \"https://js.stripe.com\")"
|
||||
# La CSP delle pagine la manda il server SSR, con un nonce diverso per ogni risposta (apps/public/src/server/csp.ts:
|
||||
# script solo da self, nonce e Stripe). Qui solo un default restrittivo per le risposte che non ce l'hanno
|
||||
# ("?" = solo se assente): statici, errori di Express.
|
||||
?Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
|
||||
}
|
||||
# Il server SSR imposta già la cache degli statici (hash nel nome → 1 anno) e comprime le risposte
|
||||
reverse_proxy public:4000
|
||||
}
|
||||
|
||||
# --- Area riservata (SPA statica) ---
|
||||
|
||||
area.{$DOMAIN} {
|
||||
import common
|
||||
header {
|
||||
X-Frame-Options "DENY"
|
||||
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
|
||||
X-Robots-Tag "noindex, nofollow"
|
||||
# Nessuno script inline: la build non inserisce i CSS critici (inlineCritical: false in angular.json).
|
||||
# Stili inline: quelli dei componenti Angular e gli attributi style di Leaflet.
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://media.{$DOMAIN} https://tile.openstreetmap.org; font-src 'self' data:; connect-src 'self' https://api.{$DOMAIN}; form-action 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'"
|
||||
}
|
||||
|
||||
# Configurazione runtime letta da main.ts prima del bootstrap (la stessa build vale per ogni dominio)
|
||||
handle /config.json {
|
||||
header Content-Type "application/json"
|
||||
header Cache-Control "no-store"
|
||||
respond `{"apiUrl":"https://api.{$DOMAIN}","siteUrl":"https://{$DOMAIN}","medusaAdminUrl":"https://shop-admin.{$DOMAIN}/app"}` 200
|
||||
}
|
||||
|
||||
handle {
|
||||
root * /srv/area
|
||||
# Asset con hash nel nome (main-XXXXXXXX.js, chunk-…, styles-….css): un anno. Tutto il resto, index.html
|
||||
# compreso, si rivalida a ogni visita, così un nuovo deploy è visibile subito.
|
||||
@hashed path_regexp -[A-Za-z0-9_-]{8}\.(js|mjs|css)$
|
||||
header @hashed Cache-Control "public, max-age=31536000, immutable"
|
||||
@not_hashed not path_regexp -[A-Za-z0-9_-]{8}\.(js|mjs|css)$
|
||||
header @not_hashed Cache-Control "no-cache"
|
||||
# Fallback SPA: un percorso che non è un file riceve index.html (decide il router Angular)
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
|
||||
# --- API ---
|
||||
|
||||
api.{$DOMAIN} {
|
||||
import common
|
||||
header X-Robots-Tag "noindex, nofollow"
|
||||
|
||||
# Unico endpoint di Medusa esposto pubblicamente: il webhook Stripe (firma verificata da Medusa)
|
||||
handle /hooks/payment/stripe_stripe {
|
||||
reverse_proxy medusa:9000
|
||||
}
|
||||
|
||||
# L'API imposta da sé header di sicurezza (helmet) e CORS; il request id lo genera Caddy
|
||||
handle {
|
||||
reverse_proxy api:3000 {
|
||||
header_up X-Request-Id {http.request.uuid}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Dashboard Medusa ---
|
||||
|
||||
shop-admin.{$DOMAIN} {
|
||||
import common
|
||||
header {
|
||||
X-Frame-Options "DENY"
|
||||
X-Robots-Tag "noindex, nofollow"
|
||||
}
|
||||
|
||||
route {
|
||||
# 1) whitelist IP (default: tutti; in produzione gli IP dell'ufficio o della VPN)
|
||||
@denied not remote_ip {$SHOP_ADMIN_ALLOWED_IPS:0.0.0.0/0 ::/0}
|
||||
respond @denied "Accesso non consentito" 403
|
||||
|
||||
@root path /
|
||||
redir @root /app/ 302
|
||||
|
||||
# Solo dashboard (/app), Admin API (/admin) e autenticazione degli utenti admin: niente Store API
|
||||
@not_admin not path /app /app/* /admin/* /auth/user/* /auth/session /auth/token/refresh
|
||||
respond @not_admin "Not Found" 404
|
||||
|
||||
# 2) basic auth. Eccezione: le chiamate /auth/* con "Authorization: Bearer" che il dashboard fa dopo il
|
||||
# login (creazione della sessione, reset password, inviti): il browser non può mandarle insieme alle
|
||||
# credenziali basic (stesso header). Medusa verifica il token, ottenibile solo passando dalla basic auth.
|
||||
@needs_basic not {
|
||||
path /auth/*
|
||||
header Authorization "Bearer *"
|
||||
}
|
||||
basic_auth @needs_basic {
|
||||
{$SHOP_ADMIN_USER} {$SHOP_ADMIN_PASSWORD_HASH}
|
||||
}
|
||||
|
||||
reverse_proxy medusa:9000
|
||||
}
|
||||
}
|
||||
|
||||
# --- Media pubblici ---
|
||||
|
||||
media.{$DOMAIN} {
|
||||
import common
|
||||
header {
|
||||
X-Robots-Tag "noindex"
|
||||
# Nessun contenuto attivo: anche un file malevolo aperto direttamente non può eseguire script
|
||||
Content-Security-Policy "default-src 'none'; img-src 'self'; sandbox"
|
||||
Access-Control-Allow-Origin "*"
|
||||
Cache-Control "public, max-age=86400"
|
||||
}
|
||||
|
||||
# File nascosti (dotfile) mai serviti
|
||||
@hidden path */.*
|
||||
|
||||
# File pubblici di Medusa (immagini prodotto), volume medusa-files. I file "private-*" (export/import CSV
|
||||
# del dashboard) stanno nella stessa cartella: non vanno mai serviti.
|
||||
handle_path /shop/* {
|
||||
@private_medusa path_regexp ^/private-
|
||||
respond @private_medusa 404
|
||||
respond @hidden 404
|
||||
root * /srv/medusa-files
|
||||
file_server
|
||||
}
|
||||
|
||||
# Solo la sottocartella public/ del volume uploads è montata in Caddy (subpath): private/ non esiste qui
|
||||
handle {
|
||||
respond @hidden 404
|
||||
root * /srv/uploads
|
||||
file_server
|
||||
}
|
||||
}
|
||||
|
||||
# Siti aggiuntivi sullo stesso Caddy (es. n8n già presente sulla VPS): file *.caddy in infra/caddy/sites/
|
||||
import /etc/caddy/sites/*.caddy
|
||||
@@ -0,0 +1,31 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Caddy (reverse proxy + TLS automatico) con dentro la build statica dell'area riservata (apps/private).
|
||||
# Contesto di build: radice del monorepo.
|
||||
# docker build -f infra/caddy/Dockerfile -t iti-technology/caddy .
|
||||
# La configurazione dell'area riservata (/config.json) non è nella build: la genera Caddy dalle variabili
|
||||
# d'ambiente (vedi Caddyfile), quindi la stessa immagine vale per qualunque dominio.
|
||||
|
||||
ARG NODE_IMAGE=node:24-bookworm-slim
|
||||
ARG CADDY_IMAGE=caddy:2-alpine
|
||||
|
||||
FROM ${NODE_IMAGE} AS private-deps
|
||||
WORKDIR /repo
|
||||
COPY package.json package-lock.json tsconfig.base.json ./
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY apps/public/package.json apps/public/
|
||||
COPY apps/private/package.json apps/private/
|
||||
RUN npm ci --ignore-scripts --no-audit --no-fund -w @iti/private -w @iti/shared --include-workspace-root
|
||||
|
||||
FROM private-deps AS private-build
|
||||
COPY packages/shared packages/shared
|
||||
COPY packages/styles packages/styles
|
||||
COPY apps/private apps/private
|
||||
RUN npm run build -w @iti/shared && npm run build -w @iti/private \
|
||||
# in sviluppo config.json non esiste (404 → default); in produzione lo risponde Caddy
|
||||
&& rm -f apps/private/dist/private/browser/config.json
|
||||
|
||||
FROM ${CADDY_IMAGE}
|
||||
COPY --from=private-build /repo/apps/private/dist/private/browser /srv/area
|
||||
COPY infra/caddy/Caddyfile /etc/caddy/Caddyfile
|
||||
COPY infra/caddy/sites /etc/caddy/sites
|
||||
@@ -0,0 +1,7 @@
|
||||
# Esempio di sito aggiuntivo servito dallo stesso Caddy (rinominare in n8n.caddy per attivarlo).
|
||||
# Serve se n8n (già presente sulla VPS) occupava le porte 80/443: va messo dietro questo Caddy, collegando il
|
||||
# suo container alla rete "edge" del compose di produzione (o raggiungendolo via host.docker.internal).
|
||||
n8n.{$DOMAIN} {
|
||||
import common
|
||||
reverse_proxy n8n:5678
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Postgres 16 + PostGIS 3.4 + pgvector
|
||||
FROM postgis/postgis:16-3.4
|
||||
|
||||
# L'immagine è basata su Debian bullseye, che PGDG ha spostato su apt-archive.postgresql.org:
|
||||
# il repo originale (apt.postgresql.org) risponde 404, quindi si punta all'archivio (stessa chiave GPG; http, i pacchetti sono firmati).
|
||||
RUN sed -i 's#http://apt.postgresql.org/pub/repos/apt/\?#http://apt-archive.postgresql.org/pub/repos/apt/#' \
|
||||
/etc/apt/sources.list.d/pgdg.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql-16-pgvector \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Eseguito una sola volta, al primo avvio con volume dati vuoto
|
||||
-- (docker-entrypoint-initdb.d, come superuser POSTGRES_USER).
|
||||
-- Le credenziali arrivano dalle variabili d'ambiente del container via \getenv (psql >= 15).
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
\getenv app_db_name APP_DB_NAME
|
||||
\getenv app_db_user APP_DB_USER
|
||||
\getenv app_db_password APP_DB_PASSWORD
|
||||
\getenv medusa_db_name MEDUSA_DB_NAME
|
||||
\getenv medusa_db_user MEDUSA_DB_USER
|
||||
\getenv medusa_db_password MEDUSA_DB_PASSWORD
|
||||
|
||||
-- Utenti distinti per app custom e Medusa
|
||||
CREATE ROLE :"app_db_user" LOGIN PASSWORD :'app_db_password';
|
||||
CREATE ROLE :"medusa_db_user" LOGIN PASSWORD :'medusa_db_password';
|
||||
|
||||
-- Database, ciascuno di proprietà del rispettivo utente
|
||||
-- (in PG16 lo schema public appartiene a pg_database_owner, quindi l'owner del DB ci può creare tabelle)
|
||||
CREATE DATABASE :"app_db_name" OWNER :"app_db_user" ENCODING 'UTF8' TEMPLATE template0;
|
||||
CREATE DATABASE :"medusa_db_name" OWNER :"medusa_db_user" ENCODING 'UTF8' TEMPLATE template0;
|
||||
|
||||
REVOKE ALL ON DATABASE :"app_db_name" FROM PUBLIC;
|
||||
REVOKE ALL ON DATABASE :"medusa_db_name" FROM PUBLIC;
|
||||
|
||||
-- Database dei test automatici di apps/api (<APP_DB_NAME>_test, stesso utente):
|
||||
-- i test lo svuotano a ogni esecuzione, mai puntarli sul DB applicativo.
|
||||
SELECT format('%s_test', :'app_db_name') AS app_test_db_name \gset
|
||||
CREATE DATABASE :"app_test_db_name" OWNER :"app_db_user" ENCODING 'UTF8' TEMPLATE template0;
|
||||
REVOKE ALL ON DATABASE :"app_test_db_name" FROM PUBLIC;
|
||||
|
||||
-- Estensioni del DB applicativo e di quello di test (create da superuser: postgis non è "trusted")
|
||||
\connect :"app_db_name"
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE EXTENSION IF NOT EXISTS unaccent;
|
||||
|
||||
\connect :"app_test_db_name"
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE EXTENSION IF NOT EXISTS unaccent;
|
||||
Reference in New Issue
Block a user