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
179 lines
6.3 KiB
Caddyfile
179 lines
6.3 KiB
Caddyfile
# 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
|