# 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