Vai al contenuto

WordPress su home lab: nativo, Docker o LXC? Tre strategie a confronto

WordPress su home lab: nativo, Docker o LXC? Tre strategie a confronto

WordPress su home lab: nativo, Docker o LXC? Tre strategie a confronto

Una call di scoping di un’ora, fine marzo 2026. Dall’altra parte un freelance WordPress con dieci anni sulle spalle, una decina di clienti continuativi, e una domanda secca: “ho appena comprato un mini PC da 330 euro, ci voglio mettere sopra Proxmox e tirare giù i miei progetti dall’hosting condiviso, però non ho capito su che cosa li metto, su VM con HestiaCP, su Docker compose, o spalmati su VM separate”. La risposta corta, da freelance a freelance, è “dipende, ma in genere Docker compose”. La risposta lunga, che è quella di cui si paga la consulenza, sta nei prossimi venti minuti di chiamata e in questo post. Vedi anche gli altri post di Production.

Tre strategie di deploy WordPress in home lab. Tre architetture, tre profili di tradeoff. Le ho usate tutte e tre in produzione su nodi Romiltec o nei laboratori interni, e questo è il giro pratico per scegliere quella giusta in funzione di cosa devi farci sopra.

Strategia 1: nativo su VM o LXC, con pannello

Il pattern classico, quello che ho montato su 17 server di produzione Romiltec via HestiaCP. Una VM (o più spesso un LXC se sei su Proxmox), Debian o Ubuntu sopra, lo stack canonico installato a livello di sistema operativo: nginx come reverse proxy frontend, PHP-FPM con pool dedicato per versione PHP, MariaDB come database server, Redis come object cache, certbot integrato per i Let’s Encrypt. Sopra ci metti un pannello (HestiaCP open-source, oppure cPanel commerciale, o Webmin) che ti astrae il plumbing e ti dà una GUI per creare utenti, domini, database, certificati.

Setup tipico su LXC Proxmox:

# da Proxmox host, helper script community
bash -c "$(wget -qLO - https://github.com/community-scripts/ProxmoxVE/raw/main/ct/debian.sh)"
# crea LXC Debian 13, 4 vCPU, 4 GB RAM, 80 GB disco

# dentro al container
wget https://raw.githubusercontent.com/hestiacp/hestiacp/release/install/hst-install-debian.sh
bash hst-install-debian.sh --apache=no --proftpd=no --vsftpd=no --exim=yes --dovecot=yes --clamav=no --spamassassin=no --iptables=yes --fail2ban=yes

Quindici minuti dopo hai una macchina con HestiaCP esposto sulla porta :8083, da cui puoi creare un dominio (<dominio>), assegnargli un utente, generare il database WordPress, configurare il pool PHP-FPM, ottenere il cert Let’s Encrypt, fare l’install di WordPress dal pannello stesso.

Pro reali:

  • Stack dedicato, controllo totale. Hai accesso alle config nginx (i template Hestia sono in /usr/local/hestia/data/templates/, override per dominio nella directory standard), ai pool PHP, alle istanze MariaDB. Tuning fine per ogni dominio: cache fastcgi, headers custom per WP REST API, override per multisite, gestione robots.txt diversi per staging e produzione.
  • Zero overhead di astrazione. PHP-FPM gira nativo, MariaDB gira nativo, nginx gira nativo. Niente container che intermedia, niente layer di rete virtuale. Le performance sono massimo possibili sul ferro.
  • Monitoring lineare. htop, iotop, pmacct, nginx-status: tutto vede tutti i processi, niente nascosto in container ortogonali.
  • Costo cognitivo del primo setup pagato una volta. Hai imparato dove sta nginx, dove sta PHP-FPM, come si rinnovano i cert: lo sai per tutti i siti che ospiti dopo. Niente nuovi paradigmi da imparare per ogni progetto.

Contro reali:

  • Niente isolamento. Se un sito ha un plugin che mangia memoria, mangia memoria di tutti i pool che girano sulla macchina. Se un sito è craccato e gira mining, lo gira sotto l’utente nginx con tutto l’accesso al filesystem dei suoi vicini di casa (mitigato da pool PHP separati per utente, ma il vettore di attacco resta).
  • Replica complessa. Vuoi tirare su lo stesso sito su un altro nodo per test? Ti tocca clonare il LXC intero, oppure replicare a mano utente, dominio, DB, file. Niente “stop & start” istantaneo come con Docker compose.
  • Gestione porte un casino quando vuoi multipli stack. Vuoi una versione di nginx 1.24 per un sito e una di nginx 1.26 per un altro, sulla stessa macchina? Bestemmie. Devi mettere su un altro container, e a quel punto stai facendo Docker informalmente. Idem per PHP: nativo gestisce versioni multiple solo se sei su Debian con i repo Sury, e anche lì è friction.
  • Snapshot meno granulari. Lo snapshot ZFS di tutto il LXC ti rollbackka tutti i siti insieme. Se un sito è andato in tilt e gli altri sono ok, devi recuperare a mano dalla copia di backup di un solo dominio. Non è la fine del mondo, ma è friction.

Caso d’uso ideale. Una piccola agenzia con 3-10 siti production-ready, contenuti stabili, traffico moderato (sotto 100k visite/mese aggregate), team che ha già skill di amministrazione Linux. Romiltec ha usato questa strategia su 17 server per gli stack di produzione editoriali, e per il giorno uno è la scelta giusta. Ne ho parlato in dettaglio nel post sulla prima infra Romiltec.

Strategia 2: Docker compose, multi-stack

L’approccio middle-ground per chi vuole isolamento senza moltiplicare le VM. Una VM (o LXC, ma con nesting=1 abilitato), Docker engine sopra, e poi N stack docker compose che girano in parallelo, ognuno con il suo nginx, il suo PHP-FPM, il suo MariaDB, il suo Redis. Le porte 80/443 le tieni su un reverse proxy unico (Caddy o traefik) che fa SNI routing verso il container giusto in base al dominio.

Esempio di docker-compose.yml per un singolo WordPress (con placeholder):

services:
  wp:
    image: wordpress:6.7-php8.3-fpm
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: <DB_USER>
      WORDPRESS_DB_PASSWORD: <DB_PASS>
      WORDPRESS_DB_NAME: <DB_NAME>
    volumes:
      - ./wp-content:/var/www/html/wp-content
    networks:
      - internal

  db:
    image: mariadb:11.4
    environment:
      MARIADB_ROOT_PASSWORD: <ROOT_PASS>
      MARIADB_DATABASE: <DB_NAME>
      MARIADB_USER: <DB_USER>
      MARIADB_PASSWORD: <DB_PASS>
    volumes:
      - db_data:/var/lib/mysql
    networks:
      - internal

  nginx:
    image: nginx:1.27-alpine
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./wp-content:/var/www/html/wp-content:ro
    networks:
      - internal
      - proxy
    expose:
      - "80"

  redis:
    image: redis:7-alpine
    networks:
      - internal

networks:
  internal:
  proxy:
    external: true

volumes:
  db_data:

Il network proxy è esterno e condiviso fra tutti gli stack: il reverse proxy frontend (Caddy in genere) ascolta su :80 e :443 esposti pubblicamente, vede tutti i container con label proxy e fa routing per dominio. Ogni stack WordPress vive in isolamento di rete, vede solo il suo DB e il suo Redis. Provisionare un nuovo cliente significa: copia template del compose, sostituisci i placeholder con valori del nuovo dominio, docker compose up -d, aggiungi il nuovo dominio al Caddyfile del reverse proxy. Tre minuti.

Pro reali:

  • Isolamento decente. Ogni stack vive in container separati. Se WP crash, crash dentro al suo container, non tocca gli altri stack. Se il DB di un cliente esplode di IO, non rallenta il DB di un altro cliente (al limite del kernel, ma il delta è enorme rispetto al nativo dove tutti i siti scrivono sullo stesso MariaDB).
  • Provisioning velocissimo. Il primo stack richiede di scrivere il template, dal secondo in poi è copia-incolla con sed. Tre minuti per un nuovo cliente, contro 12 minuti del nativo.
  • Versioning dello stack per progetto. Il cliente A usa PHP 8.3 e WordPress 6.7. Il cliente B usa PHP 8.4 e WordPress 6.8. Il cliente C ha un legacy WordPress 5.9 con PHP 7.4 (pietà). Tre compose diversi, tre runtime diversi, zero conflitti.
  • Reproducibility. Il docker-compose.yml è il source of truth. Lo committi su git nel repo runbook interno, e la stessa identica configurazione vive su altri host (staging, dev, produzione fallback) senza divergenze.
  • Snapshot granulari. Backup ZFS della directory dello stack è un backup self-contained del cliente. Restore di un singolo cliente senza toccare gli altri.

Contro reali:

  • Overhead di astrazione. Ogni container ha il suo overhead di runtime (qualche decina di MB per container, qualche % di CPU per ciclo IO). Su un mini PC con 32 GB di RAM ci stai dentro per 10-15 stack senza problemi, su una VM più piccola ti accorgi.
  • Curva di apprendimento Docker. Per un dev che viene da PHP+nginx+MariaDB nativo, capire Docker compose, networks, volumes, healthcheck non è banale. Servono almeno due o tre giorni di studio prima di sentirsi a casa.
  • Reverse proxy come single point of failure. Se Caddy crash, tutti i siti sono down. Mitigabile con restart: always e supervisione systemd, ma è un point of failure aggiuntivo che il setup nativo non ha.
  • Persistent storage da gestire. I volumi Docker per i DB devono essere su filesystem affidabile. Se metti i volumi su una VM senza ZFS sotto, perdi il vantaggio degli snapshot atomici.

Caso d’uso ideale. Un dev freelance che gestisce 5-15 progetti WordPress eterogenei (versioni diverse, plugin set diversi, esigenze di test diverse). Un team che fa molto staging e branching, dove tirare su un nuovo ambiente in tre minuti è quello che ti permette di lavorare. Una piccola agenzia che vuole isolamento ma non si vuole permettere il costo operativo di N VM. Per il freelance della call di scoping con cui ho aperto, la mia raccomandazione finale è stata questa strada.

Strategia 3: componenti su VM separate

Il livello massimo di atomicità. Niente “tutto su una VM”: separi i componenti su VM (o LXC) dedicate. DB su una VM database-only. nginx + PHP-FPM su una o più VM webserver. Reverse proxy frontend su una VM dedicata. Le VM si parlano via bridge interno Proxmox (vmbr1 con subnet privata <SUBNET-PRIVATA>/24), niente esposizione pubblica fra di loro.

Architettura tipica per 3 siti production-ready:

  • VM 1 (webserver-1): Debian 13, nginx + PHP-FPM 8.3 nativi, hostname interno web1.lab. Ospita i file di tutti i siti in /var/www/<dominio>/. Un solo IP pubblico esposto in DNAT da Proxmox host.
  • VM 2 (db-1): Debian 13, MariaDB 11.4 nativo, hostname interno db1.lab. Niente IP pubblico. Ascolta solo sulla bridge interna. WordPress sulla VM webserver si connette via wp-config.php con define('DB_HOST', 'db1.lab').
  • VM 3 (cache-redis): opzionale, Debian 13, Redis nativo, hostname interno cache1.lab. Object cache di tutti i siti.
  • VM 4 (proxy): Debian 13, Caddy, hostname interno proxy1.lab. Termina TLS, fa routing verso web1.lab:80 per gli HTTP request. È l’unica VM con IP pubblico forwardato.

Setup workflow: provisioni le 4 VM con script Ansible standardizzato (10-15 minuti per VM con cloud-init), configuri il bridge interno, copi le config standard, e parti.

Pro reali:

  • Massima atomicità di backup. Vuoi backuppare il DB di un cliente? Snapshot ZFS della VM db-1, fine. Recovery del solo DB senza toccare i file: import del dump dalla snapshot. Restore di una VM compromessa senza toccare le altre. Per clienti con compliance pesante (GDPR, dati sensibili in zona EU) questa atomicità è la differenza fra “abbiamo un piano DR” e “speriamo che vada bene”.
  • Scaling indipendente dei componenti. Il DB diventa lento? Aggiungi RAM alla VM db-1 senza toccare i webserver. I webserver vanno in panne sotto picco di traffico? Cloni la VM web1 in web2 e metti un load balancer davanti, lasciando il DB intatto.
  • Sicurezza per layer. Il DB non ha mai un IP pubblico. Anche se il webserver viene compromesso, l’attacker entra al massimo sulla bridge privata, non sul DB direttamente. Per contesti dove la difesa in profondità conta (un cliente che aveva un’esigenza di isolamento esplicito), questa è la struttura che gli ho proposto e ha accettato.
  • Test di disaster recovery realistici. Spegni la VM db-1, vedi che succede ai webserver. Spegni il proxy, vedi che succede al pubblico. Ogni componente è un punto di test isolato.

Contro reali:

  • N volte le VM da gestire. Patch Debian su 4 VM contro 1. Monitoring Prometheus su 4 VM contro 1. Cert SSH su 4 VM. Snapshot ZFS schedulati su 4 VM. Ogni cosa è moltiplicata. Senza Ansible diventa ingestibile, con Ansible è gestibile ma comunque 4x lavoro.
  • Latenza interna fra VM. Anche con bridge interno Proxmox (che è memoria condivisa, non rete fisica), c’è una manciata di microsecondi di overhead per ogni call DB. Su WordPress con 30+ query per pagina, su pagine non cachate, lo senti come 5-10 ms aggiuntivi rispetto al setup nativo “tutto su una VM”. Per pagine cachate (fastcgi cache di nginx, redis object cache) è invisibile.
  • Costo di provisioning per cliente nuovo. Se devi tirare su un sito nuovo, devi: aggiungere il DB sulla VM db-1, aggiungere il vhost sulla VM web1, aggiungere il routing sul proxy. Tre punti da toccare invece di un solo docker compose up. Tempi di provisioning realistici: 25 minuti per sito nuovo, contro 3 minuti del Docker compose.
  • Overhead RAM. 4 VM contro 1 significa 4 kernel Linux in esecuzione, 4 stack systemd, 4 filesystem. Tipicamente 1-1.5 GB di RAM in più per VM aggiuntiva. Su un mini PC da 32 GB ce ne stanno comunque tante, ma il delta non è zero.

Caso d’uso ideale. Un’azienda di hosting WordPress dedicato con esigenze di isolamento esplicito. Un cliente con compliance (data residency EU strict, certificazioni di sicurezza). Un team che vuole infrastruttura production-grade dal day uno e non si pone vincoli di velocità di provisioning. Romiltec usa questo pattern sui nodi che ospitano siti editoriali ad alto traffico, dove il DB MariaDB con replica circolare (codename interni james/jason, cluster che ho descritto in altri post) sta su VM dedicate e i webserver stanno su altre VM ancora.

La matrice decisionale, dieci secondi

Quando il freelance della call mi ha chiesto la regola spannometrica, gli ho detto questa.

Profilo Strategia
3-10 siti stabili, low-traffic, team con skill Linux base Nativo + HestiaCP
5-15 progetti eterogenei, molto staging/branching Docker compose multi-stack
Un singolo sito ad altissimo traffico, scaling indipendente VM separate
Cliente con compliance, isolamento esplicito VM separate
Dev solitario che fa test di 8 plugin in parallelo Docker compose multi-stack
Hosting di 30 siti per piccola agenzia, traffico medio aggregato Docker compose multi-stack se hai skill Docker, altrimenti nativo
Vuoi sentirti senior dev, controllo del byte, no abstractions Nativo (e te ne farai una ragione del friction operativo)

Le tre strategie non sono mutuamente esclusive, e i nodi interni Romiltec ne mescolano due o tre a seconda del contesto: i siti editoriali production girano nativi su HestiaCP (singolo cliente per nodo), i progetti di sperimentazione e staging girano su Docker compose, il cluster MariaDB sta su VM dedicate isolate.

Numeri reali, tempi di provisioning misurati

Su un mini PC Proxmox con 32 GB RAM, 8 core, NVMe gen4:

  • Provisioning singolo WordPress nativo + HestiaCP (LXC Debian 13 + install Hestia + creazione utente + creazione dominio + install WP via wp-cli): 12 minuti.
  • Provisioning singolo WordPress Docker compose (docker compose up -d da template + install WP via wp-cli dentro al container): 3 minuti.
  • Provisioning singolo WordPress su VM separate (creazione VM webserver, VM db, configurazione interconnessione, install nginx/PHP/Maria, deploy WP, config proxy): 25 minuti.
  • Reprovisioning n-esimo sito sulla stessa infra (con template/playbook standardizzati): 5 minuti per nativo (Hestia GUI), 2 minuti per Docker (sed sul template + up), 8 minuti per VM separate (aggiungi DB su VM esistente, vhost su VM esistente, routing).

Un fattore 4x di delta fra Docker e VM separate al primo provisioning si appiattisce su replica successiva, ma resta significativo.

Postmortem: il conflitto di porte 80/443 fra due stack

Una storia di un’ora di debugging che vale la pena raccontare. Stavo migrando due siti di test da nativo a Docker compose sullo stesso mini PC. Avevo scritto due docker-compose.yml quasi identici, copiati da template. Il primo compose up, parte tutto. Il secondo compose up, errore: “port 80 is already allocated”.

Diagnosi del problema: il template del primo stack aveva ports: - "80:80" sul container nginx, esponendo la porta 80 dell’host. Il secondo stack stava cercando di fare lo stesso, e Docker giustamente rifiutava. Ma io sapevo che la mia architettura era con un Caddy frontend e gli stack interni che esponevano solo via network proxy, non via porta host.

Cosa era successo. Avevo copiato il template di un setup vecchio che usava nginx-as-frontend (senza reverse proxy davanti, esposizione diretta), e mi ero dimenticato di rimuovere il ports: dai compose dei singoli stack quando ho aggiunto Caddy frontend al fleet. I template erano divergenti dal pattern attuale, e nessuno dei due stack si era accorto perché il primo che parte si prende la porta e funziona, il secondo crash.

Fix in 5 minuti dopo capito il problema: ho rimosso il blocco ports: da entrambi i compose, ho controllato che il Caddyfile del frontend avesse i due dominii, restart pulito, tutto online. Ma 50 minuti di debugging per arrivarci, perché stavo cercando il problema in posti dove non era (firewall iptables, configurazione bridge Docker, conflitto su iptables -t nat).

Lezione: i template dei docker-compose.yml vanno standardizzati e versionati. Il template “current” deve essere unico, nel repo runbook interno, e ogni nuovo stack parte da quello. Niente copia da setup di tre mesi fa, dove magari l’architettura era diversa. Da quel giorno ho un README nel repo dei template che dice esplicitamente “il pattern attuale è reverse-proxy-frontend, gli stack non espongono porte host”.

Cosa rifarei diversamente

Tre cose, in ordine.

Una. Standardizzare Docker compose come default per ogni progetto WordPress nuovo, lasciando nativo solo per legacy che non si possono toccare e VM separate solo per esigenze esplicite di compliance/isolamento. Tre anni fa partivo nativo “perché è più semplice da capire”. Oggi so che il delta di velocità di provisioning, di reproducibility git-tracked, di possibilità di staging atomico, paga abbondantemente la curva di apprendimento Docker.

Due. Tenere un repo runbook interno con i template Docker compose canonici, versionati. Un solo template “current” per WordPress single-instance, un template per WP multisite, un template per legacy WP+PHP7.4. Niente “lo copio da quel cliente di sei mesi fa” che è esattamente il pattern che mi ha fatto perdere un’ora con il conflitto porte.

Tre. Reverse proxy unico (Caddy) su tutti i nodi, dal day uno. Anche se hai un solo sito sopra, e ti sembra overkill avere Caddy davanti a un singolo container nginx. La differenza si vede al secondo sito che aggiungi: con Caddy frontend già su, ti basta aggiungere una entry al Caddyfile e parte. Senza Caddy frontend, devi rifattorizzare il primo stack quando aggiungi il secondo. È esattamente il pattern che ho usato sul Mattermost self-hosted, ne parlo nel post dedicato.

La conversazione, in chiusura

Tornando alla call con il freelance. La raccomandazione finale è stata Docker compose con Caddy frontend, runbook git-tracked, template canonici per i suoi 10 progetti, e un piano di migrazione progressivo dall’hosting condiviso (un cliente alla settimana, snapshot pre-migrazione, rollback pronto). A sei settimane di distanza ha ottenuto: provisioning di un nuovo progetto in 4 minuti contro le 2-3 ore tipiche del provider precedente, costo mensile per il mini PC + energia + Tailscale per il backup remoto sotto i 25 euro al mese contro i 80 euro/mese di hosting condiviso plurimo, controllo totale sullo stack PHP/WordPress per ogni cliente.

Quello che mi ha colpito della conversazione è che il primo dubbio del freelance era “ma ci capisco abbastanza di Docker?”. La risposta onesta è “ci capirai abbastanza in due settimane di pratica, e il delta di produttività poi è enorme”. L’ostacolo non era la tecnologia: era la diffidenza verso un livello di astrazione in più. Una volta dentro, non si torna indietro.