Vai al contenuto

Mattermost self-hosted su Hetzner: Proxmox, ZFS RAID1, CrowdSec, lezioni

Mattermost self-hosted su Hetzner: Proxmox, ZFS RAID1, CrowdSec, lezioni

Mattermost self-hosted su Hetzner: <a href=Proxmox, ZFS RAID1, CrowdSec, lezioni” title=”Mattermost self-hosted su Hetzner: Proxmox, ZFS RAID1, CrowdSec, lezioni” loading=”eager” decoding=”async” />

A maggio 2026 ho montato la chat aziendale di Romiltec self-hosted su Hetzner. Non era una migrazione da Slack: era la decisione di non andarci mai, su Slack o su Mattermost Cloud. Spendere 8-12 € a utente al mese per cinque persone, con i dati su server americani, non aveva senso quando in casa abbiamo già skill di sistemista, un cluster Proxmox, e una preferenza esplicita per la sovranità del dato. Vedi anche gli altri post di questa rubrica: Production.

Il setup completo, con tutte le trappole che ho beccato sul campo, è documentato nel runbook interno. Qui faccio il pezzo-firma: cosa ho scelto, perché, dove ho perso tempo, e cosa mi sono portato a casa per i prossimi setup self-hosted.

Il dimensionamento: ferro vero, costi noti

Server: Hetzner Dedicated AX102, Xeon Gold 5412U (24 core / 48 thread), 256 GB DDR5 ECC, due NVMe enterprise KIOXIA KCD8XRUG1T92 da 1.92 TB ciascuno, datacenter FSN1 (Falkenstein, Germania). Costo a listino: 130 € al mese più IVA, contratto mensile, niente vincolo. È overkill per la sola chat di una software house di 5 persone, ovviamente: la macchina è stata dimensionata per ospitare anche la roadmap interna degli stack futuri (Mattermost oggi, monitoring + drive + CI runners domani). Per il discorso pulito sul TCO della sola chat conviene normalizzare: a fronte di 130 € totali, la quota imputabile a Mattermost è realisticamente 25-50 € al mese, ovvero una frazione del costo Cloud per la stessa quintetta. Se confronti con SaaS Mattermost Cloud Professional a 10 $ utente/mese, il break-even arriva con la prima unità. Per noi il calcolo si tiene anche scaricandoci il costo operativo di tenerlo in piedi.

OS: Proxmox VE 9.1.9 sopra Debian 13 Trixie, kernel 6.17.2-1-pve. Filesystem: ZFS RAID1 mirror sui due NVMe, ashift 12, compressione lz4, ARC max 32 GB. La scelta ZFS non è ideologica: è la combinazione di mirror a livello filesystem (no RAID hardware, no surprise) + snapshot atomici per i dataset LXC, che usiamo già su altri tre nodi. Se devi rifare un container in 30 secondi dopo un upgrade andato male, lo snapshot ZFS è la differenza fra un weekend rovinato e un caffè.

L’architettura: due LXC, un bridge NAT

Lo schema è elementare e replica un pattern che usiamo su altri nodi:

  • Bridge pubblico WAN legato all’NIC fisica, con l’IP pubblico Hetzner v4 + v6.
  • Bridge interno con subnet privata dedicata e MASQUERADE in uscita verso il bridge pubblico. I container vivono qui, non hanno IP pubblico.
  • Container LXC numerato N (reverse-proxy): Debian 13 unprivileged, 2 vCPU, 1 GB RAM. Reverse proxy Caddy con ACME automatico verso Let’s Encrypt. Cert HTTPS in 5 minuti, rinnovi gestiti da Caddy stesso, no certbot. Il container monta in bind un dataset host /var/log/caddy-shared per condividere i log JSON con CrowdSec sull’host (su questo torno fra due righe).
  • Container LXC numerato N+1 (server di chat): Ubuntu 24.04 unprivileged, 4 vCPU, 4 GB RAM, 40 GB disco, features nesting=1 e keyctl=1. Mattermost 11.5.1 installato dallo script community ProxmoxVE, niente container Docker dentro al container: il community-script mette Mattermost binario nativo sotto /opt/mattermost, systemd unit, postgres locale. Più semplice, meno strati.

Il bridge interno con subnet privata è la differenza chiave rispetto al setup bridged “tutto su IP pubblico” che vedi spesso nei tutorial Proxmox: le VM/CT non sono mai esposte direttamente a internet, l’unica porta che arriva al chat è :443 via Caddy, in più la porta UDP 49150 per le RTC delle Mattermost Calls (DNAT dedicato dal bridge pubblico verso il container di chat).

DNAT 80/443 e il pattern Caddy

Il pezzo che fa funzionare il reverse proxy verso il privato è uno script /root/caddy_fw.sh sull’host:

# regole DNAT dal bridge pubblico verso l'IP privato del reverse-proxy, porte 80/443
iptables -t nat -A PREROUTING -i <BRIDGE-WAN> -p tcp --dport 80  -j DNAT --to <IP-CADDY>:80
iptables -t nat -A PREROUTING -i <BRIDGE-WAN> -p tcp --dport 443 -j DNAT --to <IP-CADDY>:443

Niente di esotico, ma con un dettaglio operativo: PVE 9 usa nftables sotto, però il binario iptables di default su Debian 13 punta a iptables-legacy. Le rule via comando iptables finiscono effettivamente in iptables-nft in fase di esecuzione, ma il binario di lettura mostra le tabelle legacy (vuote) e ti fa pensare che le rule non ci siano. Risolto con un update-alternatives --set iptables /usr/sbin/iptables-nft. Persistenza via iptables-persistent sul file /etc/iptables/rules.v4.

Il Caddyfile finale del proxy è asciutto:

chat.example.com {
    log
    reverse_proxy http://<IP-PRIVATO-CHAT>:8065
}

ACME automatico al primo hit, certificato Let’s Encrypt in due minuti. Confronto onesto: un setup nginx + certbot --nginx per ottenere lo stesso risultato richiede tre file di configurazione, un cron per il rinnovo, e una manciata di passaggi DNS-challenge se sei dietro Cloudflare proxy. Caddy lo fa di default. Per single-vhost o handful di domini, Caddy è più veloce di nginx + certbot di un fattore reale: non è marketing, è ore di setup contate.

La trappola Cloudflare proxy + ACME HTTP-01

Su questa il primo cert l’ho dovuto rifare due volte. Dietro al proxy Cloudflare (arancione attivato), il setting zone-level “Always Use HTTPS” intercetta /.well-known/acme-challenge/* e lo redireziona con un 308 verso HTTPS. Caddy chiede un challenge HTTP-01, Let’s Encrypt segue il redirect, e il challenge fallisce.

Soluzioni reali:

  1. Disattivare temporaneamente il proxy CF (cloud grigia) per la durata della prima emissione (1-2 minuti), poi riattivare.
  2. Disattivare temporaneamente “Always Use HTTPS” zone-wide.
  3. Spostare Caddy a TLS-ALPN-01 (challenge che CF passa al backend), che funziona anche con proxy attivo.

Ho usato l’opzione 3 dopo la prima emissione: i rinnovi successivi vanno via TLS-ALPN-01 senza toccare nulla. La 2 è una zone-level change che impatta tutti i domini sotto la zona Cloudflare, e per una regola che ci siamo dati internamente quel tipo di modifiche non si fanno senza valutazione esplicita.

Le cinque trappole che mi hanno fatto perdere ore

Le scrivo perché chi monta questo setup la prima volta le incontra tutte, e non sono documentate nei tutorial allegri.

Trappola 1: KIOXIA CD8 in LBA Format 0. I due NVMe enterprise vengono dal vendor in LBA Format 0 (settori da 512 byte) anche se supportano nativamente i 4096. Senza format esplicito a 4K prima di installare Proxmox, ZFS scrive con write amplification non banale per anni. Fix: nvme format /dev/nvmeXn1 --lbaf=2 --ses=0 --force da rescue, una sola volta, distruttivo. Da fare prima di qualsiasi installazione.

Trappola 2: virtio-blk QEMU espone i drive 4K come 512B di default. Se installi Proxmox via QEMU in modalità “VM dell’installer da rescue”, il guest QEMU mostra al kernel guest dischi con block size logico 512B anche se sotto sono 4K. L’installer Proxmox scrive la GPT a offset 512 invece che 4096. Quando poi il kernel reale fa boot fisico (legge a 4K), il pool ZFS non si importa. Fix: passare logical_block_size=4096,physical_block_size=4096 ai device virtio-blk-pci nella riga di lancio QEMU.

Trappola 3: pkill qemu senza -no-reboot rovina il pool. Se mandi SIGTERM al QEMU dell’installer per chiuderlo, il guest non fa shutdown ACPI ordinato, ZFS non flushare i dataset, e al successivo boot fisico il pool è in stato inconsistente. Fix: lanciare QEMU con -no-reboot. Quando l’installer Proxmox a fine setup fa “Reboot Now”, il guest fa shutdown pulito e QEMU termina da solo.

Trappola 4: ARC max settato dal limite RAM dell’installer. Se l’installer Proxmox gira dentro QEMU con -m 16G, vede 16 GB di RAM e ti propone ARC max ~14961 MiB. Sul ferro reale hai 256 GB di RAM, vuoi un ARC ben più grosso. Fix: accettare il valore in fase install, post-install correggere in /etc/modprobe.d/zfs.conf con options zfs zfs_arc_max=34359738368 (32 GB) e update-initramfs -u -k all.

Trappola 5: notifica Discord CrowdSec che fallisce silenziosa. La trappola più subdola, perché non rompe niente: ti fa solo perdere le notifiche di sicurezza per settimane. Il template default del plugin HTTP di CrowdSec invia il payload con Content-Type: text/plain, e Discord risponde HTTP 400 silenzioso. La notifica non arriva mai, ma non c’è errore evidente nel log se non guardi a livello debug. Fix: aggiungere a mano il blocco header in /etc/crowdsec/notifications/discord.yaml:

method: POST
headers:
  Content-Type: "application/json"

Questa l’ho beccata su questo nodo e ho dovuto retroportarla anche su un altro nostro nodo dove le notifiche Discord erano mute da un mese senza che nessuno se ne fosse accorto. Lezione: ogni notifier va testato con un alert reale, non con cscli notifications test.

CrowdSec invece di fail2ban: motivazione operativa

Sul nodo gira CrowdSec con il bouncer firewall per nftables. Ho usato fail2ban per anni e funziona, ma fail2ban out-of-the-box è un set di regex su file di log: ogni nuovo servizio vuole un parser nuovo, ogni log format change ti rompe la jail. CrowdSec viene con collezioni pre-fatte (crowdsecurity/sshd, crowdsecurity/caddy, crowdsecurity/base-http-scenarios, crowdsecurity/http-cve) e con whitelist post-overflow per i CDN noti (la cdn-whitelist scarta i ban su IP Cloudflare, Akamai, Fastly: se Caddy logga sempre l’IP CF in client_ip, senza la whitelist banneresti gli edge CF al primo scan).

Acquis log Caddy via mount bind condiviso host ↔ container:

filenames:
  - /var/log/caddy-shared/*.log
labels:
  type: caddy

Il container Caddy scrive lì, l’host CrowdSec legge da lì. Niente agent dentro al container, niente forwarding di rete, niente complicazioni. Per la mappatura degli UID nei container unprivileged ho settato l’ownership della cartella host a 100999:100999 (UID 999 dentro il container che è caddy, mappato a 100999 sull’host).

Mattermost: scelte sul dettaglio

Setup Mattermost via community-script mattermost.sh di Proxmox community-scripts. Versione installata: 11.5.1 (ESR), Postgres locale dentro al container, niente Docker, niente reverse proxy interno: il binary Mattermost ascolta su :8065 per HTTP application e :8443 per RTC, Caddy sull’altro container fa SSL termination.

Il config (/opt/mattermost/config/config.json) l’ho editato fuori dalla GUI per portarlo allineato al pattern che usiamo:

  • SiteURL: il dominio dedicato della chat
  • MaximumLoginAttempts: 10
  • SessionLengthWebInDays: 30
  • EmailSettings via Mailgun EU: SMTP STARTTLS, sottodominio mail dedicato, credenziali in .env. Mailgun EU per data residency in zona EU.
  • LogSettings: ConsoleJson + FileJson + Sentry abilitati.

Plugin abilitati: Calls (videocall via WebRTC, TURN su un nostro server condiviso), Boards (Focalboard pre-bundled). Plugin pronti ma da configurare prima di abilitare runtime: Agents (mattermost-ai per integrazione LLM via key Anthropic/OpenAI), GitHub (notifiche commit/PR).

TURN configurato su un nostro Hetzner DE NBG1 condiviso con altri nodi del fleet, ICE host override sull’IP pubblico del nodo, ICE port override su 49150 (la porta DNAT dedicata). Le chiamate funzionano sia su WiFi simmetrico che dietro NAT cliente.

Il backup: separare il dato dal compute

Backup verso Backblaze B2 via restic, snapshot ZFS giornalieri sul ferro per recovery rapido (rollback di 30 secondi se un upgrade Mattermost va male), backup off-site di tutti i dataset. La regola che applichiamo dovunque: il dato va separato dal compute. Il ferro Hetzner, per quanto enterprise, può anche bruciare. Il backup B2 deve essere recuperabile su un nodo qualsiasi entro un’ora. La presenza dei snapshot ZFS giornalieri sul ferro non è backup: è solo undo locale. La separazione è la differenza.

Costi reali, ammortizzati

A regime, per la chat di 5 persone:

  • Quota Hetzner imputabile a Mattermost: ~25-50 € al mese (60-100 € se ci metti backup B2, monitoring, e ammortizzi le ore di setup iniziali sui 24 mesi).
  • Confronto Mattermost Cloud Professional 10 $/utente: ~50 $ al mese per 5 persone, ~600 $ annui.
  • Confronto Slack Pro 7,25 $/utente: ~36 $ al mese per 5 persone, ~430 $ annui.

A 5 utenti il break-even non è il driver. Quello che mi ha fatto scegliere self-hosted è la combinazione di tre cose: dato in EU sotto controllo nostro (per noi che vendiamo a editori GDPR-strict è coerenza, non paranoia), capacità di estendere lo stesso ferro a roadmap interne future (Drive, monitoring, CI runners) senza moltiplicare provider, e il fatto che a 5 persone lo abbiamo, lo skillset di sistemista dentro al team. Se mancasse uno di questi tre, prenderei SaaS senza pensarci.

I tradeoff onesti

Niente self-hosted hagiography. I tre tradeoff veri:

  1. Ownership operativa. Se Mattermost si pianta alle 23 di domenica, sei tu a guardarlo. Niente status page di terzi, nessun supporto enterprise. Per 5 persone con un sistemista interno è gestibile, per un team senza skill ops è un rischio.
  2. Upgrade. I rilasci Mattermost vanno seguiti. ESR aiuta (canali stabili), ma comunque ogni 6-12 mesi devi pianificare un upgrade. Snapshot ZFS prima, esecuzione, verifica, rollback se necessario. Serve un piano, non improvvisi.
  3. Disponibilità. Il SLA reale del setup è quello del singolo ferro Hetzner (99,9% indicativo) più il tuo tempo di risposta. Mattermost Cloud ti dà SLA contrattuale. Se la chat è mission critical per il fatturato (per noi non lo è, lavoriamo asincroni), il calcolo cambia.

Cosa mi porto via dal setup

Tre cose, in ordine.

Una. Caddy + ACME è una rivoluzione silenziosa. Per 4-5 vhost dietro CDN, configurazione e rinnovo cert si fanno in cinque minuti contro mezz’ora di nginx + certbot. Su un nodo nuovo lo metto sempre.

Due. CrowdSec out-of-the-box batte fail2ban. Le collezioni pre-fatte coprono già SSH, HTTP scrapers, scenari CVE noti, scanner. Le whitelist CDN risolvono il problema più fastidioso del WAF self-hosted dietro proxy. L’unica trappola seria è il Content-Type Discord: ora che lo so, i prossimi setup partono già col fix.

Tre. ZFS snapshot è il vero salvavita. Più dei backup, dei rollback Postgres, dei piani di disaster recovery: la possibilità di tornare indietro in 30 secondi a uno stato di un container prima di toccarlo cambia il modo in cui tocchi le cose in produzione. Diventi più audace dove serve, più cauto dove conta. Non è una banalità: è la differenza fra un sistemista che lavora rilassato e uno che procede con paura.

Il setup è in produzione, gira da maggio 2026 senza incidenti rilevanti. La chat aziendale di Romiltec vive lì, i 5 utenti del team lavorano lì. Il prossimo giro sullo stesso ferro è il monitoring stack (Prometheus + Grafana, due LXC dedicati con un prefisso comune di naming), poi il CI runner per la build pipeline interna. La macchina è dimensionata per portarli tutti senza ridiscutere il piano.

Ogni tanto, quando guardo il pannello Hetzner e vedo 130 € di canone mensile per un ferro che ne sostiene almeno tre stack di servizi interni, mi torna in mente il calcolo che facevo nei primi mesi di Romiltec: in molti scenari controllare il ferro è più razionale di quanto i preventivi cloud lascino pensare. Se hai sotto la skill, l’unica cosa che paghi davvero è il tempo. E quel tempo, dopo il primo setup, si ammortizza su tutti i nodi che monti dopo.