Decisioni di architettura e stack
Partiamo da cosa stiamo davvero risolvendo. Diciamo che abbiamo varie web app indipendenti. Condividono lo stesso stack tecnico e parecchi componenti UI, ma servono scopi diversi e vivono su domini diversi.
Parti leggero. Se il tuo dolore principale è "troppo lento dall'idea al deploy", non costruire la piattaforma completa prima di spedire. L'architettura qui sotto descrive dove puoi arrivare. Il giorno uno deve essere: server Hetzner più Coolify (Traefik, SSL, deploy, Postgres, backup), una app Next.js, un database, Clerk, R2 se ti servono i file, Uptime Kuma. Niente Redis al giorno uno. Aggiungi Redis quando hai un bisogno concreto di cache (Next.js ha cache integrata fino a quel momento; Cloudflare copre il rate limiting del login). Aggiungi BullMQ quando ti servono job in background. La piattaforma cresce dai bisogni reali, non da quelli previsti. Questo articolo mappa la destinazione; ci puoi arrivare per gradi.
Ogni sito ha bisogno di:
- Una app Next.js full-stack (frontend + API routes)
- Un database PostgreSQL
- Autenticazione (via Clerk)
- Certificati SSL (automatici, niente rinnovi a mano)
Sopra alle cose per-sito, mi serve infrastruttura condivisa:
- Object storage per upload di file e media (S3-compatible)
- Monitoring per sapere quando qualcosa si rompe alle 3 di notte
- Logging centralizzato per debuggare i problemi senza fare SSH al server
E deve stare tutto in un budget di 50€/mese.
Ho valutato vari provider e penso che oggi Hetzner sia la scelta migliore per qualità e prezzo. Hetzner ti dà significativamente più calcolo per euro, e la loro cloud platform è semplice senza essere semplicistica.
Per dare contesto, ecco cosa ti dà 29€/mese su Hetzner (CPX41):
| Risorsa | Spec |
|---|---|
| vCPU | 8 (AMD EPYC) |
| RAM | 16 GB |
| Storage | 240 GB NVMe SSD |
| Traffico | 20 TB/mese |
| Location | EU (Falkenstein, Norimberga, o Helsinki) |
Prova a prendere 8 vCPU e 16 GB di RAM su AWS per 29€/mese. Aspetto.
Aggiungi una Hetzner Storage Box (100 GB per 3,81€/mese) per backup off-server, e il costo totale dell'infrastruttura è circa 33€/mese. Restano margini comodi nel budget di 50€ per crescere.
Panoramica dell'architettura
Ecco il quadro completo:
Tutto quello che sta dentro il confine del server gira sulla tua macchina Hetzner. Coolify orchestra: Traefik, le tue app, Postgres, Redis quando lo aggiungi. Push su main e va in deploy. R2 e la Storage Box sono servizi managed esterni.
Ora ti racconto ogni decisione.
Coolify: il control plane
Coolify gira sul tuo server e ti dà Traefik (routing, SSL, deploy zero-downtime), provisioning dei database, gestione delle env, backup e una UI. Hai un IP pubblico e cinque domini; Coolify instrada ogni dominio sul container giusto, ottiene i certificati Let's Encrypt e gestisce lo switch quando fai deploy. Aggiungi un nuovo sito connettendo una repo. Niente file di config da editare. Se mai dovessi uscire, Docker Compose puro più GitHub Actions è la via di fuga.
PostgreSQL: uno per app
Crea un'istanza Postgres separata (suo container) per app, un click: Add Resource → PostgreSQL → fatto. Coolify gestisce i backup di ognuna.
Perché una per app: prima di tutto isolamento. Container separati significano memory pool, limiti di connessione e processi separati. Una query impazzita sull'app-2 non può affamare l'app-3. Con carico medio e query pesanti, conta. Secondo, backup per-app facili: Coolify ti dà la config dei backup per-resource out of the box. Un click per ogni Postgres, schedule individuali, restore individuali. Con un'istanza condivisa dovresti scriverti tu lo script pg_dump per database. Terzo, ops: aggiungere un nuovo Postgres in Coolify è Add Resource → fatto. Cancelli un'app, cancelli il suo Postgres. Niente database orfani da pulire. Per ultimo la RAM: 3-5 container Postgres a ~100-150 MB ciascuno fanno 300-750 MB. Su 16 GB, irrilevante.
Se l'app-3 lancia una migrazione brutta che blocca una tabella o crasha Postgres, va giù solo l'app-3. Inizia con un'app, un Postgres. Quando aggiungi l'app #2, crei un altro Postgres. Non pre-pianificare la topologia del database.
Connection pooling (aggiungilo quando serve)
Con poco traffico e cinque app Prisma userai grossomodo 25 connessioni in totale. PostgreSQL le regge tranquillamente. PgBouncer aggiunge un livello di debug fra te e Postgres, e Prisma con PgBouncer ha quirk noti sui prepared statement in transaction mode. Aggiungi PgBouncer quando vedi davvero esaurimento delle connessioni, non prima. Se un sito diventa virale o aggiungi più app, riconsidera.
Migrazioni con Prisma
Visto che lo stack è Next.js con Prisma, le migrazioni le gestisce Prisma. Ogni sito ha il suo schema e le sue migrazioni:
apps/site-1/
├── prisma/
│ ├── schema.prisma
│ └── migrations/
│ ├── 20250110_create_users/
│ └── 20250115_add_orders/
└── ...
Il workflow è semplice:
- Modifichi lo schema Prisma
- Dalla directory dell'app, esegui
pnpm prisma migrate dev --name add_email_column(es.cd apps/site-1 && pnpm prisma migrate dev --name add_email_column) - Prisma genera l'SQL della migrazione e la applica in locale
- Al deploy,
prisma migrate deployapplica le migrazioni pending
Ho considerato strumenti agnostici al linguaggio come dbmate o golang-migrate (che usano file SQL puri). Hanno senso se mescoli linguaggi backend. Con Next.js + Prisma, l'approccio schema-first tiene le migrazioni allineate ai modelli e cattura il drift in automatico.
Le migrazioni partono come parte della pipeline di deploy, subito prima che il nuovo container inizi a servire traffico. Un backup pg_dump parte prima di ogni migrazione, caricato sulla Storage Box. Ogni app ha il suo database e le sue migrazioni, quindi una migrazione del site-1 non può rompere i dati del site-3. Il rischio sta nell'orchestrazione CI/CD: se la migrazione del site-3 fallisce a metà del deploy, devi saperlo subito e fermare il deploy di quell'app senza toccare le altre. Esegui le migrazioni in sequenza e fail-fast, così una migrazione rotta non lascia un'app in deploy a metà.
Redis: aggiungilo quando ti serve
Non aggiungere Redis al giorno uno. Next.js ha cache integrata: ISR, fetch cache, unstable_cache. Per il rate limiting, il free tier di Cloudflare ti dà una regola, che copre il tuo endpoint di login. Aggiungi Redis quando hai un bisogno concreto di cache (session store, query consultate spesso, rate limiting a livello app per le API pubbliche). Si aggiunge in 10 minuti circa. Quando lo aggiungi, usa una singola istanza con prefissi di chiave per separare i siti (es. site1:cache:*, site2:cache:*). Footprint di memoria: circa 50-100 MB.
Job in background: se un sito ti serve cron task o processi long-running, usa BullMQ con Redis. Esegui i worker come container separati così un job bloccato non blocca le tue API. Aggiungilo quando ti serve, non prima.
Object storage: Cloudflare R2
Se le tue applicazioni gestiscono upload di file (immagini, documenti, media), ti serve object storage. Potresti usare il disco locale, ma si rompe nel momento in cui vuoi scalare, migrare o condividere file fra servizi.
Ho valutato MinIO (self-hosted) contro le alternative managed. Per questo setup, Cloudflare R2 è la scelta pragmatica.
Il caso contro MinIO sul tuo server:
- Consuma ~500 MB di RAM su un server in cui ogni gigabyte conta
- Aggiungi complessità operativa: un altro container da mantenere, monitorare e backuppare
- I file vivono sullo stesso disco di tutto il resto; se il server muore, muoiono anche i file
Perché Cloudflare R2:
| Feature | Cloudflare R2 | MinIO (self-hosted) |
|---|---|---|
| Costo | Gratis fino a 10 GB, poi $0,015/GB/mese | Gratis ma usa la RAM del server |
| Egress fees | $0 (zero) | N/A (la tua banda) |
| Ridondanza geografica | Sì (built-in) | No (server singolo) |
| RAM server | 0 | ~500 MB |
Egress fees = paghi ogni volta che qualcuno scarica un file dal tuo storage. Carichi un'immagine, un utente la guarda sul tuo sito; quello è egress. La maggior parte dei provider lo fa pagare: AWS S3 ~$0,09/GB, Google Cloud ~$0,12/GB. R2 chiede $0. Se i tuoi siti servono molte immagini o file, i costi di egress possono superare in silenzio quelli dello storage. R2 lo elimina del tutto. Zero egress, S3-compatible, niente da mantenere sul server.
R2 è S3-compatible. Il tuo codice Next.js usa l'AWS SDK (@aws-sdk/client-s3) o qualunque client S3-compatible. Cambi l'endpoint URL e le credenziali; il codice non cambia. Se mai vuoi passare a MinIO o AWS S3, cambi un valore di config.
Hetzner Object Storage (5,99€/mese per 1 TB) è un'alternativa valida: stesso provider, S3-compatible, latenza molto bassa col tuo server. Se i tuoi file sono usati prevalentemente lato server (backend che leggono/scrivono fra servizi, batch processing), vince Hetzner Object Storage. Se i tuoi file sono serviti soprattutto agli utenti (immagini sui siti, download), vince R2: egress gratis più una CDN globale built-in significa consegna più veloce in tutto il mondo. Per cinque web app con upload tipici, R2 è la scelta di default migliore.
Ogni sito ha il suo bucket R2:
site-1-uploads/
site-2-uploads/
site-3-uploads/
shared-assets/
L'unica ragione per tenersi MinIO è se devi tenere i dati on-premise (compliance, sovranità del dato). Per cinque web app con upload? R2 è la scelta pragmatica.
Autenticazione: Clerk
Costruirsi l'autenticazione da zero è una trappola. Sembra semplice: hash delle password, gestione delle sessioni, fatto. Poi ti servono email verification, flussi di password reset, provider OAuth, multi-factor authentication, rate limiting sui tentativi di login, invalidazione di sessione su più device, protezione CSRF, e improvvisamente hai speso tre mesi a costruire infrastruttura invece del prodotto.
Clerk gestisce tutto questo. L'integrazione ha due lati:
Frontend (Next.js)
Il pacchetto @clerk/nextjs fornisce componenti UI pre-costruiti e personalizzabili per sign-in, sign-up e gestione utente. Li metti nella tua app, configuri la dashboard Clerk, e l'autenticazione è gestita.
Backend (Next.js API routes)
Questa è la parte che a volte la gente salta, ed è un errore critico. Clerk autentica gli utenti sul frontend e rilascia un JWT (JSON Web Token). Le tue API route Next.js devono verificare quel JWT a ogni richiesta. Senza, la tua API è completamente non protetta.
Il motivo: il tuo frontend parla con la tua API via richieste HTTP. Niente impedisce a qualcuno di chiamare quegli stessi endpoint API direttamente, con curl, Postman o uno script. Se l'API non verifica il token, chiunque può accedere ai tuoi dati senza mai toccare Clerk. Il pacchetto @clerk/nextjs fornisce auth() e middleware per verificare i token nelle API route e nei server component.
Ognuno dei cinque siti ha la sua applicazione Clerk (base utenti separata, configurazione separata, API key separate). Le chiavi vivono nel .env di ogni sito e vengono iniettate nei container a runtime.
Costo: il free tier di Clerk include 10.000 Monthly Active Users per applicazione. Per siti a basso traffico è estremamente generoso. Se cresci oltre, il piano Pro è $25/mese più $0,02 per ogni MAU aggiuntivo. Clerk è una dipendenza SaaS che scala con gli utenti ed è più difficile da abbandonare di un'auth self-hosted (es. Auth.js). Il tradeoff è reale: UI pronta, integrazione fluida con Stripe e gestione webhook che ti fanno risparmiare tempo significativo. Se tieni Clerk, sappi che è il tuo lock-in più appiccicoso. Documenta una strategia d'uscita se mai dovessi spostarti.
ClerkProvider e browser privacy (Brave, ecc.): se il tuo sito ha caricamento infinito su Brave, la causa è spesso Brave Shields che blocca gli script Clerk da clerk.com. Di default, ClerkProvider avvolge tutta l'app e carica quegli script su ogni pagina. Le pagine pubbliche non hanno bisogno di Clerk. Avvolgi ClerkProvider solo intorno alle route admin o auth (es. in admin/layout.tsx), non nel root layout. Le pagine pubbliche caricheranno senza Clerk; admin e sign-in continueranno a funzionare. Il middleware (clerkMiddleware) gira lato server e non dipende dal provider client-side.
Monitoring: Uptime Kuma
Uptime Kuma è uno strumento di monitoring self-hosted che gira come singolo container Docker (~100 MB di RAM). Pinga ognuno dei tuoi siti a un intervallo configurabile (io uso 60 secondi) e ti avvisa via Telegram, Discord, email o Slack quando qualcosa va giù.
Ti dà anche una dashboard pulita che mostra storico di uptime, tempi di risposta e date di scadenza dei certificati, utile per pescare la degradazione delle performance prima che diventi un outage.
Ho considerato Prometheus + Grafana, che è lo standard industriale per il monitoring d'infrastruttura. È anche progettato per team che operano a scala con centinaia di metriche su decine di servizi. Per uno sviluppatore solitario con cinque siti, è come usare un'autopompa per innaffiare il giardino. Uptime Kuma copre il 90% di quello che ti serve col 10% della complessità.
Health check leggeri del server: Uptime Kuma ti dice quando un sito smette di rispondere. Non ti dice quando il disco è pieno, la RAM è bassa o le connessioni PostgreSQL stanno schizzando. Aggiungi un cron job (es. ogni 5 minuti) che controlla soglie e avvisa via lo stesso webhook Telegram o Discord usato da Uptime Kuma quando hai traffico che vale la pena monitorare. Uno script semplice può controllare df per il disco, free per la memoria e pg_stat_activity per il numero di connessioni. Se un valore supera la soglia, fa POST sul webhook. Zero overhead infrastrutturale, copre la maggior parte dei "le cose vanno male alle 3 di notte" prima che diventino outage.
Recovery automatico quando non puoi fare SSH: se sei al lavoro e non puoi intervenire, configura Uptime Kuma per avvisarti subito su Telegram così almeno sai che qualcosa è andato giù. Coolify può riavviare in automatico container unhealthy; per cascade failure, uno script watchdog semplice che innesca una full reconciliation può aiutare.
Setup Netdata (metriche e indagine sugli spike)
Netdata fornisce metriche real-time di CPU, RAM, disco e per-container. Quando vedi uno spike nelle metriche server di Coolify, Netdata ti permette di scendere fino al processo o container che l'ha causato. Lo storico viene tenuto per qualche ora di default, abbastanza per indagare a posteriori.
- Aggiungi il servizio in Coolify
- Resources → + Add Resource → Docker Compose
- Scegli Docker Compose Empty e incolla il compose qui sotto (oppure connetti la tua repo e imposta il path del compose a
docker/netdata.yml) - Scegli il tuo server (es. platform-1)
- Deploy
# docker/netdata.yml
services:
netdata:
image: netdata/netdata:stable
container_name: netdata
hostname: platform-1
restart: unless-stopped
pid: host
cap_add:
- SYS_PTRACE
- SYS_ADMIN
security_opt:
- apparmor:unconfined
ports:
- "19999:19999"
volumes:
- netdata-config:/etc/netdata
- netdata-lib:/var/lib/netdata
- netdata-cache:/var/cache/netdata
- /:/host/root:ro,rslave
- /etc/passwd:/host/etc/passwd:ro
- /etc/group:/host/etc/group:ro
- /etc/localtime:/etc/localtime:ro
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /etc/os-release:/host/etc/os-release:ro
- /var/log:/host/var/log:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- /run/dbus:/run/dbus:ro
volumes:
netdata-config:
netdata-lib:
netdata-cache:
-
Assegna un dominio
- Nella resource Netdata → Configuration → Domains
- Aggiungi
https://netdata.your-domain.com(ometrics.your-domain.com) - Imposta la port a
19999(default di Netdata) - Salva e ridistribuisci; Traefik otterrà i certificati Let's Encrypt
-
Accedi alla dashboard
- Apri l'URL Netdata (es.
https://netdata.your-domain.com) - Vedrai CPU, memoria, disco e container Docker. Usa il selettore di range temporale per ispezionare spike passati.
- Netdata non ha auth integrata; restringi l'accesso via Hetzner Firewall (porta 19999 solo dal tuo IP) oppure mettilo dietro Cloudflare Access se ti serve protezione con login.
- Apri l'URL Netdata (es.
Setup Uptime Kuma
-
Aggiungi il servizio in Coolify
- Resources → + Add Resource → Service
- Cerca Uptime Kuma e selezionalo
- Scegli il tuo server (es. platform-1)
- Deploy
-
Assegna un dominio
- Nella resource Uptime Kuma → Configuration → Domains
- Aggiungi
https://uptime.your-domain.com(ostatus.your-domain.com) - Salva e ridistribuisci; Traefik otterrà i certificati Let's Encrypt
-
Crea l'account admin
- Apri l'URL Uptime Kuma (es.
https://uptime.your-domain.com) - Imposta username e password al primo login
- Apri l'URL Uptime Kuma (es.
-
Aggiungi monitor per Blog e Mazza Immobiliare
- Add New Monitor
- Monitor Type: HTTP(s)
- Friendly Name:
Blog(osaveriomazza.com) - URL:
https://your-domain.com(il dominio del blog) - Heartbeat Interval: 60 secondi
- Retries: 3
- Salva
- Ripeti per Mazza Immobiliare: URL
https://your-real-estate-domain.com(o il tuo dominio immobiliare)
-
Configura gli alert
- Settings → Notifications → Add Notification
- Scegli Telegram o Discord
- Per Telegram: crea un bot via @BotFather, prendi il token; crea un gruppo, aggiungi il bot, manda un messaggio, poi
/my_idper avere il chat ID - Per Discord: crea un webhook in Server Settings → Integrations → Webhooks
- Collega la notifica a ogni monitor (oppure usa Default per applicarla a tutti)
Setup Dozzle
-
Aggiungi il servizio in Coolify
- Resources → + Add Resource → Service
- Cerca Dozzle e selezionalo
- Scegli il tuo server
- Deploy
-
Assegna un dominio
- Nella resource Dozzle → Configuration → Domains
- Aggiungi
https://logs.your-domain.com(odozzle.your-domain.com) - Salva e ridistribuisci
-
Opzionale: abilita l'autenticazione
- Coolify offre Dozzle with Authentication come variante; usala se vuoi proteggere la UI dei log con password
- Per uso interno dietro una rete fidata va bene la default (no auth); restringi l'accesso via Hetzner Firewall o Cloudflare Access se serve
-
Visualizza i log
- Apri l'URL Dozzle; auto-discovery di tutti i container sul server
- Filtra per nome container (es.
blog,mazzaimmobiliare,postgres-blog,postgres-mazzaimmobiliare) per ispezionare i log di ogni app
Setup DNS: aggiungi i sottodomini su Cloudflare (o sul tuo provider DNS):
uptime.your-domain.com→ record A → IP del tuo serverlogs.your-domain.com→ record A → IP del tuo servernetdata.your-domain.com→ record A → IP del tuo server
Se usi Cloudflare Proxied (nuvoletta arancione), l'SSL funziona via Traefik; nessuna config aggiuntiva.
Logging: Dozzle
Quando qualcosa va storto, i log sono la prima cosa che controlli. Il logging integrato di Docker funziona bene da riga di comando (docker compose logs -f site-1), ma scrollare l'output del terminale fra 15+ container diventa noioso in fretta.
Dozzle è un visualizzatore di log Docker leggero e read-only. Gira come singolo container, fa auto-discovery di tutti gli altri container e fornisce una UI web per cercare e tailare log in real time. Richiede zero configurazione: niente log shipper, niente database, niente dashboard da costruire. Legge direttamente dagli stream di log di Docker.
Per evitare che i log consumino spazio su disco, configura il driver di log JSON di Docker con la rotation:
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
Questo blocca i log di ogni container a 30 MB (3 file × 10 MB), e Docker li ruota in automatico. Coolify gestisce i tuoi container; configura la rotation dei log nelle impostazioni Docker o Coolify.
Ho considerato lo stack ELK (Elasticsearch + Logstash + Kibana) e Grafana Loki. Entrambi sono eccellenti per aggregazione di log su larga scala. Entrambi consumano anche più RAM di alcuni dei miei container applicativi reali. A questa scala, Dozzle è lo strumento giusto.
Struttura monorepo (aggiungila quando hai codice condiviso)
Con 0-1 app, un monorepo con pnpm workspaces, Turborepo e pacchetti condivisi (ui/, api-client/, rate-limit/) è prematuro. Ogni pacchetto è codice che scrivi e mantieni prima di sapere cosa è davvero condiviso. Inizia con repo indipendenti per app. Quando l'app #3 duplica davvero qualcosa dell'app #1, estrai un pacchetto condiviso o passa al monorepo. L'overhead della cache di Turborepo, della gestione delle dipendenze workspace e dei deploy coordinati è reale e ti rallenta in quella fase.
Con 5-10 app, l'argomento monorepo diventa più forte. Avrai codice condiviso vero: pattern di auth, componenti UI, utility API. A quel punto un monorepo con pnpm workspaces si guadagna la sua complessità. Saprai cosa condividere perché hai già costruito le app, non l'avrai indovinato in anticipo.
Tutti i cinque siti, i pacchetti condivisi e la configurazione d'infrastruttura vivono in un'unica repository. Ecco la struttura quando ci arrivi:
I cinque siti condividono parecchio codice: componenti UI, utility API, pattern di autenticazione, definizioni di tipo. In un setup multi-repo, condividere codice significa pubblicare pacchetti npm privati, gestire versioni fra repository e fare i conti con l'inevitabile confusione "che versione della libreria condivisa sta usando il site-3?". In un monorepo, il codice condiviso si importa direttamente. Cambi un componente, e ogni sito che lo usa riceve l'update.
platform/
├── apps/
│ ├── site-1/ # Next.js full-stack
│ │ ├── Dockerfile
│ │ ├── package.json
│ │ ├── prisma/
│ │ │ ├── schema.prisma
│ │ │ └── migrations/
│ │ └── src/
│ ├── site-2/
│ │ └── ...
│ └── ...
├── packages/
│ ├── ui/ # Componenti React condivisi
│ ├── api-client/ # Utility API condivise
│ ├── rate-limit/ # Rate limiter basato su Redis (quando aggiungi Redis)
│ └── common/ # Tipi e costanti condivise
├── etl/ # Job ETL Python (quando servono)
│ └── ...
├── pnpm-workspace.yaml
└── turbo.json
La condivisione di codice è gestita dai pnpm workspaces. La directory packages/ contiene librerie condivise che qualunque apps/*/ può importare. Turborepo gestisce la cache di build e l'orchestrazione dei task: se il site-1 non è cambiato, non viene rifatto. Coolify builda dalla tua repo; connetti ogni app e va in deploy.
Estrarre un'app (es. per venderla o migrarla): ogni app ha il suo database, istanza Clerk, bucket R2 e env var. Copi apps/site-x/ più i pacchetti condivisi che importa in una nuova repo. Esporti il dump Postgres, passi le credenziali. Il setup a server singolo non lo blocca.
Python è riservato ai processi ETL: batch job, pipeline dati, import da fonti esterne. Lo stack applicativo principale è Next.js + Prisma. Quando ti serve ETL, aggiungi script sotto etl/ ed eseguili via cron o un job runner.
Deploy
Push su main, Coolify builda e mette in deploy. Ti dà Traefik, SSL, deploy-on-push, provisioning di database, gestione env, backup e una UI. Per un dev solitario il cui dolore è la velocità di deploy, scriversi a mano GitHub Actions, config Traefik manuale e script di backup sono settimane di debugging YAML invece di spedire. Coolify gestisce 10 app su un CPX41 senza problemi. A quella scala, una dashboard, log per-app, rollback in un click e gestione env fra app ti fanno risparmiare ore a settimana. Coolify ha le sue opinioni; se ti serve logica molto custom (migrazioni sequenziali per-app con rollback, script watchdog), potresti fare a botte. Puoi uscirne verso Docker Compose puro più GitHub Actions se mai dovessi farlo. Quasi sicuramente non lo farai.
Backup
La strategia è lineare:
Coolify gestisce i backup database out of the box. Configura la retention (es. 7 giornalieri, 4 settimanali, 6 mensili) e punta i backup sulla tua Hetzner Storage Box o un'altra destinazione. La Storage Box è fisicamente separata dal server; se il server brucia, i backup sopravvivono.
R2: i file sono già off-server con ridondanza geografica. R2 ha durabilità integrata; per uso tipico non serve backup separato.
Testa i restore. Un backup da cui non hai mai fatto restore è solo una speranza. Programma un test di restore trimestrale: tira su un database temporaneo, carica il backup più recente, verifica l'integrità dei dati. Meglio: aggiungi un cron job settimanale che fa restore dell'ultimo dump in un database temporaneo, esegue una query base (es. SELECT count(*) FROM users) e ti avvisa se fallisce. Costo zero, prende la corruzione silenziosa dei backup.
Disaster recovery: e se il nodo Hetzner muore? Scrivi i passi e testali una volta. Obiettivo: di nuovo online in meno di un'ora. Il playbook: provisiona un nuovo server, installa Coolify, ripristina config e .env dal backup criptato o dal secrets manager, ripristina l'ultimo backup PG dalla Storage Box, aggiorna il DNS Cloudflare al nuovo IP. Fai il giro una volta a trimestre.
Sicurezza essenziale
Far girare tutto su un server solo significa che la sicurezza è critica. Se qualcuno entra, ha accesso a tutto.
SSH: autenticazione solo per chiave. Disabilita il login con password. Login root limitato alla key only (richiesto da Coolify, che fa SSH come root per gestire l'host). Usa un utente deploy per l'accesso quotidiano. Cambia la porta di default a 2222 (riduce il rumore degli scanner automatici).
Hetzner Cloud Firewall (richiesto): il firewall Hetzner di default permette solo la porta 22. Dopo aver spostato SSH sulla 2222, aggiungi regole inbound per TCP 2222, 80, 443 (da Any), e 8000, 6001, 6002 (solo dal tuo IP). Rimuovi la porta 22 dopo il setup. Questo è l'unico modo efficace di restringere le porte di Coolify, dato che Docker bypassa UFW.
Firewall (UFW + Hetzner Cloud Firewall): UFW permette le porte 80, 443, SSH (2222). Tutto il resto chiuso. Docker bypassa UFW per le porte che pubblica (8000, 6001, 6002), quindi le porte Coolify devono essere ristrette via Hetzner Cloud Firewall (imposta source al tuo IP per le porte 8000, 6001, 6002).
ufw default deny incoming
ufw default allow outgoing
ufw allow 2222/tcp # SSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
# Porte Coolify (8000, 6001, 6002): restringi via Hetzner Cloud Firewall
Fail2ban: banna in automatico gli IP che falliscono ripetutamente l'autenticazione SSH (jail sshd sulla porta 2222).
Cloudflare (DNS + protezione edge): se usi Cloudflare per il DNS (raccomandato nell'architettura), ogni richiesta passa dalla rete Cloudflare. Il free tier include: protezione DDoS (assorbe attacchi volumetrici prima che arrivino al tuo server), regole WAF di base (blocca exploit comuni come SQL injection, XSS negli URL), bot mitigation e rate limiting (1 regola gratis, basta a proteggere l'endpoint di login). È un livello diverso da UFW e Fail2ban: Cloudflare blocca il traffico cattivo prima che raggiunga Hetzner; il tuo server non spreca CPU su quello. Abilita la modalità SSL Full (Strict) in Cloudflare così il traffico è cifrato end-to-end (Cloudflare verso Traefik usa un certificato vero).
| Livello | Strumento | Protegge da |
|---|---|---|
| Edge (prima che il traffico arrivi al server) | Cloudflare free | DDoS, bot, attacchi web comuni |
| Cloud provider | Hetzner Cloud Firewall (richiesto) | Restringe porte inbound; unico modo per restringere le porte pubblicate da Docker (8000, 6001, 6002) |
| Firewall server | UFW | Accesso non autorizzato alle porte |
| Protezione SSH | Fail2ban | Tentativi di brute-force SSH |
| Livello app | Clerk + validazione JWT | Accesso non autorizzato all'API |
Update automatici: abilita gli unattended security updates per l'OS host. I container girano isolati, ma il kernel host è condiviso; tienilo patchato.
Gestione secret: Coolify ha un editor di env per app. Tieni .env.example nella repo come fonte di verità per le variabili richieste. Ruotare password DB, API key o secret Clerk su cinque app senza downtime diventa caotico. Pianifica: documenta quali servizi hanno bisogno di coordinamento e considera un secrets manager (es. HashiCorp Vault, Doppler) se la rotazione diventa frequente.
Sviluppo locale
Non vuoi far girare lo stack di produzione completo sul laptop. Non ti serve Traefik, certificati SSL o Uptime Kuma quando stai debuggando un problema di CSS.
Database: un solo Postgres, più database. Localmente non ti serve specchiare il setup di produzione un-container-per-app. Una sola istanza Postgres con un database per app va benissimo. L'isolamento conta in produzione perché un'app che crasha Postgres impatta gli utenti. In locale, se Postgres muore lo riavvii in pochi secondi.
# docker-compose.dev.yml
services:
postgres:
image: postgres:17
ports:
- "5432:5432"
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
volumes:
- pgdata:/var/lib/postgresql/data
- ./scripts/init-databases.sh:/docker-entrypoint-initdb.d/init.sh
volumes:
pgdata:
# scripts/init-databases.sh
#!/bin/bash
set -e
for db in site_1 site_2 site_3; do
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-SQL
CREATE DATABASE ${db};
SQL
done
Il file .env.development di ogni app punta al suo database:
# apps/site-1/.env.development
DATABASE_URL="postgresql://dev:dev@localhost:5432/site_1"
Poi pnpm dev in qualsiasi app funziona. Niente Docker per l'app stessa, solo per il database.
Per una singola app, pnpm dev con un Postgres locale o in Docker spesso basta. Quando hai più app, tira su Postgres con lo script di init e fai girare l'app con hot reload. Differenze chiave dalla produzione:
| Aspetto | Produzione | Sviluppo locale |
|---|---|---|
| Postgres | Un container per app | Un container, più database |
| Proxy | Traefik con SSL | Niente (accesso porta diretto) |
| App Next.js | Immagine buildata | Volume mount + hot reload |
| Object storage | R2 (esterno) | R2 o MinIO locale per dev |
| Monitoring | Uptime Kuma + Dozzle | docker compose logs |
Tenere allineati locale e produzione: il runtime diverso (Node, OS, deps) lo risolvi col Dockerfile. Tieni la stessa versione di Postgres in locale e in produzione. Il Dockerfile è il tuo contratto fra ambienti. Tieni .env.example come fonte di verità per le variabili richieste.
Budget RAM
Con 16 GB disponibili, ecco l'allocazione stimata:
Il totale stimato è circa 6,5 GB (niente MinIO sul server; R2 è esterno), lasciando circa 9 GB di margine per spike di traffico, processi di build e crescita futura. È comodo. Se la crescita lo richiede, l'upgrade al CPX51 di Hetzner (32 GB di RAM, 54€/mese) è un'operazione in un click con downtime minimo.
A 10 app: la matematica della RAM regge ancora (10 app Next.js a ~500 MB ciascuna, più le istanze Postgres, Redis se l'hai aggiunto, Traefik, monitoring, lascia margine). Con Coolify avrai un Postgres per app di default; l'isolamento è incluso. Aggiungere un altro database è banale: Add Resource → PostgreSQL → fatto.