Guida: Infrastruttura Docker su VPS Debian con Traefik, MariaDB, Redis e applicazioni multi-dominio

Introduzione

In questa guida realizzeremo un’infrastruttura Docker completa su una VPS Debian, organizzata in due livelli principali:

  • Stack Core: un insieme di servizi di base in esecuzione come container Docker, che includono:

    • Traefik v3.3 – un reverse proxy moderno per la gestione automatizzata dei sottodomini e dei certificati SSL.

    • MariaDB – un server database centralizzato (un’unica istanza) in cui più schemi/database possono essere ospitati per differenti applicazioni.

    • Redis – un server di cache in memoria, utile per caching e messaggistica veloce tra servizi.

    • phpMyAdmin – un’interfaccia web per la gestione di MariaDB, accessibile anch’essa tramite Traefik (protetta da sottodominio dedicato).

  • Stack Applicazioni: la collezione di container delle varie applicazioni (ad es. applicazioni Laravel PHP o Node.js). Ogni applicazione avrà il proprio container (o container stack) ed essere raggiungibile via un sottodominio dedicato (esempi: laravel.example.com, testlara.example.com, myapp.example.com). Questi container applicativi si registrano automaticamente su Traefik (tramite etichette Docker) per ottenere routing dal reverse proxy, e possono connettersi ai servizi core (database, cache) tramite le reti Docker condivise.

Utilizzeremo Docker Compose (nella sua versione moderna integrata nel comando docker, invocata come docker compose senza trattino) per definire e avviare sia lo stack core che i container applicativi. La guida fornirà dettagli su come organizzare i file, configurare Traefik per gestire automaticamente i sottodomini e i certificati, creare Dockerfile per applicazioni Laravel e Node.js, impostare le reti Docker necessarie, e discutere considerazioni di sicurezza, permessi, persistenza dei dati e gestione dei log. In chiusura, una sezione bonus descriverà come aggiungere un servizio centralizzato (es. un broker WebSocket/MQTT come Mosquitto) per consentire comunicazioni in tempo reale tra container.

Prerequisiti e Installazione di Docker

Prima di iniziare, assicurarsi che la VPS Debian sia predisposta con i componenti necessari:

  • Docker Engine installato (si consiglia di utilizzare l’ultima versione stabile). In Debian, è possibile installare Docker Engine seguendo la documentazione ufficiale (ad esempio usando il repository Docker APT) oppure tramite apt install docker.io.

  • Docker Compose plugin abilitato. Nelle versioni recenti di Docker, Compose è integrato come plugin. Verificare che il comando docker compose version fornisca un output valido. In caso contrario, installare il plugin seguendo le istruzioni ufficiali di Docker.

Inoltre, si presume di avere a disposizione:

  • Un nome di dominio (es. example.com) puntato all’indirizzo IP della VPS. Per gestire i sottodomini dinamici, è utile configurare un record DNS wildcard (ad esempio *.example.com) verso il server, oppure creare di volta in volta i record DNS per ciascun sottodominio richiesto.

  • Permessi amministrativi sulla VPS (accesso come root o utente nel gruppo docker) per eseguire i comandi Docker.

Nota: Tutti i comandi Docker in questa guida presuppongono che il tuo utente sia abilitato all’uso di Docker (ad esempio aggiunto al gruppo docker), altrimenti anteponi sudo ai comandi.

Struttura del progetto e reti Docker

Organizzare i file in maniera chiara è fondamentale. Proponiamo la seguente struttura di directory sul server per contenere i file di configurazione e definizione dei container:

docker-project/
├── core/
│   ├── docker-compose.yml          # Compose file per i servizi core
│   ├── .env                        # Variabili d'ambiente (es. credenziali DB)
│   ├── traefik/
│   │   ├── traefik.yml             # Configurazione statica Traefik
│   │   └── acme.json               # Archivio certificati Let's Encrypt
│   ├── data/
│   │   ├── mariadb/                # Dati persistenti MariaDB (montati come volume)
│   │   └── redis/                  # (Opzionale) Dati persistenti Redis
│   └── logs/                       # (Opzionale) Directory per log esterni
└── apps/
    ├── laravel-app/
    │   ├── docker-compose.yml      # Compose file per l'app Laravel
    │   ├── Dockerfile              # Dockerfile per build dell'app Laravel
    │   └── src/                    # Codice sorgente dell'applicazione Laravel
    └── node-app/
        ├── docker-compose.yml      # Compose file per l'app Node.js
        ├── Dockerfile              # Dockerfile per build dell'app Node.js
        └── src/                    # Codice sorgente dell'applicazione Node
  • La directory core/ contiene tutto il necessario per avviare lo stack core (Traefik, database, cache, ecc.). Il file docker-compose.yml definirà questi servizi. È presente anche un file .env (non obbligatorio ma consigliato) per gestire in un unico punto le variabili d’ambiente sensibili (come password di database). All’interno della sottodirectory traefik/ metteremo la configurazione statica di Traefik e il file acme.json che Traefik utilizzerà per memorizzare i certificati SSL ottenuti da Let’s Encrypt (è importante che questo file esista e sia scrivibile dal container Traefik). Nella sottodirectory data/ possiamo predisporre cartelle dove Docker monterà i volumi persistenti per MariaDB e Redis (così i dati restano salvati sul disco della VPS). Infine, una cartella logs/ può essere utilizzata se si decide di salvare i log su file del host (alternativamente, ci affideremo ai log interni di Docker come vedremo più avanti).

  • La directory apps/ contiene a sua volta sottocartelle per ciascuna applicazione/container aggiuntiva. Ogni applicazione ha il proprio docker-compose.yml e, se l’applicazione non usa un’immagine standard preconfezionata, includerà anche un Dockerfile per creare l’immagine Docker custom (ad esempio per una app Laravel con specifiche estensioni o per impacchettare il codice sorgente). La directory src/ contiene il codice dell’applicazione (puoi anche chiamarla diversamente, ad esempio nel caso di Laravel potrebbe essere l’intero progetto Laravel con la sua struttura, per Node.js il progetto Node, ecc.). In produzione, spesso il codice viene copiato nell’immagine Docker durante la build (evitando mount di volumi di codice sorgente), ma durante lo sviluppo potresti montare il codice per rapidità. In questa guida ci focalizzeremo su un approccio production-like (immagini che contengono il codice).

Reti Docker: Avremo bisogno di configurare due reti Docker personalizzate per connettere opportunamente i container:

  • rete proxy: rete condivisa a cui saranno collegati Traefik e tutti i container applicativi web. Traefik userà questa rete per raggiungere i servizi e instradare le richieste HTTP verso di essi. La chiameremo ad esempio proxy. Questa rete sarà di tipo bridge e definita come esterna in Docker Compose, così che stack diversi possano usarla in comune.

  • rete core: rete condivisa per i servizi backend (database, cache) e le applicazioni che ne hanno bisogno. Solo i container che devono comunicare internamente (ad esempio un’app Laravel che deve connettersi a MariaDB o Redis) saranno collegati a questa rete. Ciò consente di isolare il traffico di database dal resto. Possiamo chiamarla core (o un nome simile) e anche questa verrà definita esterna per permettere la connessione da più file Compose.

Creiamo anticipatamente queste reti eseguendo i comandi Docker (una tantum):

docker network create proxy
docker network create core

In questo modo, le reti Docker proxy e core esistono come risorse permanenti. Nei file Compose li utilizzeremo marcandole come external (esterne), quindi Docker Compose non tenterà di crearle ma solo di utilizzarle. Questa tecnica è fondamentale perché Docker Compose crea per default una rete isolata per ogni compose; infatti, ogni file docker-compose normalmente crea una propria rete virtuale isolata (se non diversamente specificato), impedendo a Traefik di vedere container definiti in un altro compose se non condividono una rete comune. Utilizzando reti esterne condivise, garantiamo che Traefik (nello stack core) e le app (in altri compose) possano comunicare.

Di seguito, configureremo i file Compose in modo che:

  • Traefik sia connesso alla rete proxy.

  • MariaDB e Redis siano connessi alla rete core (non esposti direttamente al di fuori).

  • phpMyAdmin sia connesso a entrambe: alla rete core (per parlare con MariaDB) e proxy (per essere raggiungibile via Traefik).

  • Ogni container applicativo sia connesso anch’esso a entrambe: proxy (per ricevere traffico web da Traefik) e core (per effettuare query al DB o usare Redis).

Questa separazione garantisce che il database e Redis non siano accessibili dall’esterno se non attraverso le applicazioni autorizzate sulla rete interna, migliorando la sicurezza. Nel contempo, Traefik come unico entrypoint pubblica solo le porte HTTP/HTTPS necessarie.

Configurazione dello Stack Core

Passiamo ora alla definizione dello stack core, usando Docker Compose per definire i quattro servizi fondamentali: Traefik, MariaDB, Redis e phpMyAdmin. Tutti questi servizi saranno definiti nel file core/docker-compose.yml. Analizziamo ciascun componente e poi forniremo il file completo:

Traefik (reverse proxy)

Traefik è il punto nevralgico per instradare il traffico verso gli altri container in base al dominio richiesto. Useremo Traefik v3.3 (versione recente) e configureremo:

  • Gli entrypoint 80 (HTTP) e 443 (HTTPS) per Traefik, mappati sulla VPS, così che Traefik riceva tutto il traffico web.

  • L’abilitazione del provider Docker, in modo che Traefik rilevi automaticamente container Docker con etichette (labels) appropriate e configuri i relativi router.

  • L’opzione exposedByDefault=false, per fare in modo che solo i container esplicitamente etichettati (traefik.enable=true) vengano esposti tramite Traefik.

  • Una rete Docker specificata (providers.docker.network) impostata su proxy. Questo fa sì che Traefik consideri solo quella rete per trovare i container target; è importante quando i container sono connessi a più reti, per evitare confusione su quale IP usare. Imposteremo quindi network: proxy nella configurazione di Traefik.

  • Configurazione di Let’s Encrypt per ottenere certificati SSL automatici per i domini. Utilizzeremo il metodo HTTP-01 challenge che richiede che Traefik sia raggiungibile su porta 80 e 443 e che il DNS dei sottodomini punti al server. Configureremo un certificatesResolver con email del manutentore e un file di storage (montato nel container Traefik) per conservare i certificati (nel nostro caso traefik/acme.json).

  • (Facoltativo) Abilitazione della dashboard Traefik su un portale web (tipicamente su porta 8080). In produzione meglio tenerla disabilitata o protetta da autenticazione; qui potremmo attivarla solo per debug locale, proteggendola con regole se esposta.

Per organizzare la configurazione di Traefik, useremo un file statico traefik/traefik.yml montato nel container. Nel Docker Compose di Traefik indicheremo questo file. Ecco un esempio di contenuto essenziale di traefik/traefik.yml:

# core/traefik/traefik.yml
api:
  dashboard: true        # Abilita dashboard (considerare disabilitare o proteggere in prod)
  insecure: true         # Insecure true espone la dashboard senza auth (meglio mettere false e usare auth middleware)
entryPoints:
  web:
    address: ":80"
    # redirection da HTTP a HTTPS
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
  websecure:
    address: ":443"
providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false   # espone solo container con traefik.enable=true
    network: proxy           # usa la rete 'proxy' per connettersi ai container
certificatesResolvers:
  letsencrypt:
    acme:
      email: your-email@domain.com    # Sostituire con email valida
      storage: /certs/acme.json       # Percorso dentro il container per salvare le cert.
      # Utilizzo HTTP-01 challenge su porta 80
      httpChallenge:
        entryPoint: web

Nella configurazione sopra, abbiamo definito due entrypoint (web e websecure). Abbiamo incluso una redirezione automatica da HTTP a HTTPS (così le richieste su porta 80 verranno inoltrate su 443). Il provider Docker è configurato per non esporre container di default e per osservare la rete proxy. Infine, il resolver letsencrypt userà l’email fornita e salverà i certificati in /certs/acme.json (nel container). Questo file verrà montato dal nostro host (lo abbiamo chiamato acme.json nella directory traefik). Assicurarsi di creare un file vuoto acme.json sul server e dare i permessi adeguati (ad esempio chmod 600 acme.json), in modo che Traefik possa scriverci i certificati. In fase di test potremmo usare il server di staging di Let’s Encrypt (caServer: https://acme-staging-v02... come mostrato in alcuni esempi) per evitare limiti di richieste, ma in produzione va puntato al server reale di Let’s Encrypt.

Ora definiamo il servizio Traefik nel Compose file. Ecco la sezione Traefik di core/docker-compose.yml:

services:
  traefik:
    image: "traefik:v3.3"                 # Immagine Traefik v3.3
    container_name: "traefik"
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true            # Migliora la sicurezza evitando privilegi aggiuntivi
    networks:
      - proxy                             # Collega Traefik solo alla rete proxy (non ha bisogno della rete core)
    ports:
      - "80:80"                           # Traefik ascolta su 80 (HTTP)
      - "443:443"                         # Traefik ascolta su 443 (HTTPS)
      - "8080:8080"                       # (Opzionale) Dashboard Traefik
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"   # Monta la socket Docker (necessaria per provider Docker)
      - "./traefik/traefik.yml:/traefik.yml:ro"          # Monta il file di config statica
      - "./traefik/acme.json:/certs/acme.json"           # Monta il file per i certificati

Spiegazione: Specifichiamo l’immagine di Traefik (v3.3). Usiamo restart: unless-stopped per far sì che il container venga riavviato automaticamente al reboot o in caso di crash. security_opt: no-new-privileges:true impedisce al processo Traefik di elevare i propri privilegi all’interno del container, aggiungendo un ulteriore livello di sicurezza. Connettiamo Traefik solo alla rete proxy, perché deve comunicare con i container applicativi web, ma non ha ragione di stare sulla rete core (non deve accedere direttamente al DB o Redis). Espone le porte 80 e 443 al mondo esterno (host), e porta 8080 se vogliamo accedere alla dashboard (questa porta può essere poi limitata via firewall o protezioni Traefik, in quanto insecure: true la rende accessibile liberamente: si consiglia di disabilitarla in produzione o di proteggerla con autenticazione di base come mostrato da altre guide). Montiamo la socket Docker in sola lettura nel container, così Traefik può monitorare gli eventi Docker e configurare i router dinamicamente. (Nota sicurezza: esporre la socket Docker, seppur in read-only, comporta dei rischi di sicurezza: un container compromesso con accesso alla socket potrebbe interagire con l’host Docker. In contesto produttivo, valute l’utilizzo di un Docker Socket Proxy per limitare le chiamate consentite. In questa guida manteniamo la configurazione semplice, consapevoli del rischio residuo). Montiamo inoltre i file di configurazione: traefik.yml nella root del container (come specificato dall’immagine Traefik, che di default cerca /traefik.yml se non sovrascriviamo il comando) e la cartella/volume per i certificati in /certs.

Traefik è ora predisposto come reverse proxy globale. Sarà quello che intercetta le richieste HTTP/HTTPS verso i vari sottodomini e le inoltra correttamente. Tuttavia, finché non ci sono altri container con etichette, Traefik non servirà alcun sito (eccetto la propria dashboard se attiva).

MariaDB (database centralizzato)

Il servizio MariaDB fornirà il database SQL per tutte le applicazioni. Useremo l’immagine ufficiale di MariaDB (ad esempio mariadb:10.6 o la versione che preferisci, magari la latest se compatibile con le app). Configureremo:

  • Credenziali root iniziali tramite variabili d’ambiente (es. MARIADB_ROOT_PASSWORD).

  • Montaggio di un volume per i dati in modo che il contenuto del database (file in /var/lib/mysql nel container) persista su riavvi e ricreazioni del container.

  • La connessione solo sulla rete core (quindi nessuna porta esposta all’host). In questo modo MariaDB non è accessibile dall’esterno, solo dai container che condividono la rete Docker con lui (applicazioni e phpMyAdmin).

  • Opzionalmente, potremmo aggiungere variabili per creare database o utenti iniziali, ma dato che vogliamo avere più schemi per diverse app, potremo invece gestirli manualmente dopo l’avvio (ad esempio utilizzando phpMyAdmin o la CLI di MariaDB dentro il container).

Ecco la definizione nel Compose:

  mariadb:
    image: mariadb:10.6             # immagine MariaDB (scegli la versione desiderata)
    container_name: mariadb
    restart: unless-stopped
    env_file:
      - .env                        # carica variabili da file .env (per esempio password)
    environment:
      - MARIADB_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}   # nel file .env impostare MYSQL_ROOT_PASSWORD
      - MARIADB_INITDB_SKIP_TZINFO=1                   # opzione per saltare import timezone (facoltativa)
    volumes:
      - ./data/mariadb:/var/lib/mysql   # volume locale per i dati del database
    networks:
      - core                          # solo rete interna core

Spiegazione: Utilizziamo un file .env (nella directory core) per definire MYSQL_ROOT_PASSWORD (nome variabile scelto da noi) e magari altre variabili (come nome database predefinito se ne volessimo creare uno all’avvio usando MARIADB_DATABASE, oppure utente/app specifici). Nel compose, con env_file: - .env, Docker Compose carica quelle variabili e possiamo referenziarle con la sintassi ${NOME_VAR}. Qui passiamo solo la password di root. Montiamo la directory ./data/mariadb sull’host in /var/lib/mysql nel container: è qui che MariaDB memorizza i file dei database. Così facendo i dati persistono. (Nota: l’utente sotto cui gira MariaDB nel container scriverà su questa directory; accertarsi che i permessi sulla cartella host siano adeguati, Docker in Linux generalmente la crea con l’owner 999:999 o simile, corrispondente all’utente mysql nel container). La rete è solo core. Quindi altri container sulla rete core potranno risolvere il nome host mariadb e connettersi (sulla porta 3306 di default) al database.

Gestione di più schemi per diverse app: Una volta che MariaDB è in esecuzione, è possibile creare più database (schemi) al suo interno, uno per ogni applicazione, e gestirli con credenziali separate. Ad esempio, potremmo creare un database app1_db con un utente dedicato app1_user (password dedicata), poi app2_db con app2_user, ecc. Ciò si può fare lanciando il client MySQL nel container MariaDB: docker compose exec mariadb mysql -u root -p (inserendo la root password impostata) e poi eseguendo i comandi SQL CREATE DATABASE ...; CREATE USER ...; GRANT ALL PRIVILEGES ON ... TO ...;. In alternativa, usando phpMyAdmin come vedremo, via interfaccia grafica.

MariaDB di per sé non necessita di configurazioni speciali per lavorare con Traefik, poiché non verrà esposto tramite Traefik (non è un servizio HTTP). Saranno le app a connettersi ad esso internamente.

Redis (cache server)

Redis è un archivio chiave-valore in memoria, usato spesso per cache applicativa, sessioni o messaggistica pub/sub tra componenti. Lo includiamo nello stack core in modo centralizzato. La configurazione è semplice:

  • Usa l’immagine ufficiale (redis:alpine ad esempio, per avere un footprint leggero).

  • Montare un volume se si vuole persistenza dei dati (Redis di default tiene i dati in RAM e su disco in AOF/RDB se configurato, ma per caching pura non serve persistere; se invece viene usato come database di messaggi o altro, potremmo voler mantenere i file di dump).

  • Rete solo core.

  • Nessuna porta esposta all’host (non accessibile dall’esterno, solo da container interni).

Compose:

  redis:
    image: redis:alpine
    container_name: redis
    restart: unless-stopped
    networks:
      - core
    # volumes:
    #   - ./data/redis:/data   # (opzionale) persistenza di /data se si vuole snapshot su disco

Spiegazione: Redis non richiede credenziali per default (a meno di configurare un file redis.conf con una password, il che è possibile ma per reti interne spesso si omette per semplicità). L’immagine di Redis accetta parametri di configurazione se necessari, ma per caching di base non occorre. La porta di Redis è la 6379, aperta sul container. Qualsiasi container sulla rete core potrà connettersi a redis:6379. (Se volessimo una sicurezza aggiuntiva, potremmo configurare una password Redis e passarla come command o attraverso un config file montato, ma ciò richiede poi che le app la conoscano).
Abbiamo commentato la sezione volume: se attivata, la directory dei dati Redis (dump e append-only file) verrà salvata sotto ./data/redis sul host, garantendo persistenza oltre il ciclo di vita del container. Questo è facoltativo in base all’uso di Redis (per semplice cache si può anche perdere i dati su restart).

phpMyAdmin (interfaccia web DB)

phpMyAdmin fornirà un’interfaccia web (accessibile via browser) per gestire il server MariaDB e i suoi database. Questo è utile soprattutto se preferisci non gestire i database solo da riga di comando. Lo esporremo su un sottodominio dedicato, ad esempio pma.example.com (puoi scegliere un nome differente, come dbadmin.example.com). Configurazioni:

  • Usa l’immagine ufficiale phpmyadmin:latest (o una versione specifica compatibile con la versione di MariaDB in uso).

  • Variabili d’ambiente per indicare a phpMyAdmin come connettersi al DB: principalmente PMA_HOST (host del server DB) e PMA_PORT (porta, default 3306 quindi potrebbe non servire specificarlo). Dal momento che MariaDB è su rete core con nome host mariadb, imposteremo PMA_HOST=mariadb. Possiamo anche impostare PMA_USER e PMA_PASSWORD se vogliamo che faccia login automatico con un certo utente di default, ma non è obbligatorio; potremmo lasciare che phpMyAdmin mostri la schermata di login e inserire lì utente e password (ad esempio, user root + password root, oppure gli utenti applicativi).

  • phpMyAdmin deve essere su entrambe le reti: core (per raggiungere MariaDB sull’hostname mariadb) e proxy (per essere raggiunto da Traefik).

  • Etichette Traefik appropriate per esporlo su un sottodominio e eventualmente forzare HTTPS e usare il resolver di certificati.

Compose:

  phpmyadmin:
    image: phpmyadmin:latest
    container_name: phpmyadmin
    depends_on:
      - mariadb
    restart: unless-stopped
    environment:
      - PMA_HOST=mariadb               # host del DB a cui connettersi
      - PMA_PORT=3306                  # porta del DB (3306 di default)
    networks:
      - core
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.pma.rule=Host(`pma.example.com`)"
      - "traefik.http.routers.pma.entrypoints=websecure"
      - "traefik.http.routers.pma.tls=true"
      - "traefik.http.routers.pma.tls.certresolver=letsencrypt"
      - "traefik.http.services.pma.loadbalancer.server.port=80"
    expose:
      - "80"

Spiegazione: Usiamo depends_on: - mariadb per indicare di avviare MariaDB prima di phpMyAdmin (questo garantisce che il DB sia up, anche se phpMyAdmin tentasse di connettersi subito; in pratica phpMyAdmin ha un meccanismo di retry, ma è buona pratica definire le dipendenze). Le variabili d’ambiente PMA_HOST/PMA_PORT fanno sì che l’interfaccia sappia a quale server collegarsi di default. (Se volessimo gestire più server MySQL da un’unica interfaccia potremmo passare PMA_HOSTS con più host, ma qui non serve). Non impostiamo le credenziali nell’env: phpMyAdmin mostrerà la pagina di login, dove inseriremo root e la password (o eventuali utenti creati). Le networks collegano il container a entrambe le reti come detto.

Le labels configurano Traefik: abilitiamo il container, definiamo una regola Host per pma.example.com (sostituisci con il tuo dominio), associamo l’entrypoint sicuro (443) e abilitiamo TLS con il resolver definito (letsencrypt). In questo modo Traefik, alla partenza del container, registrerà un router chiamato “pma” che instraderà le richieste https://pma.example.com al servizio phpMyAdmin. La label traefik.http.services.pma.loadbalancer.server.port=80 specifica a Traefik di usare la porta 80 interna del container come destinazione. In alternativa, avremmo potuto evitare questa label e usare expose: - "80" come indicato: abbiamo comunque incluso expose: - "80" per documentare l’intento. L’uso di expose dichiara la porta 80 del container come esposta per gli altri servizi Docker (ma non pubblicata sull’host). Ciò è utile perché, come menzionato, evitiamo di usare ports (che mappa su host) in quanto il traffico passerà tramite Traefik. Infatti, esponendo la porta e mettendo traefik.enable=true, Traefik individuerà la porta corretta su cui inoltrare (l’abbiamo anche specificata con la label per essere espliciti). Non è necessario mappare la porta 80 di phpMyAdmin sul host (abbiamo omesso ports: per phpMyAdmin), poiché l’accesso avverrà tramite Traefik sul 443.

Ora abbiamo definito tutti e quattro i servizi core. Possiamo presentare l’intero file core/docker-compose.yml unendo le sezioni, con anche la configurazione delle reti esterne:

version: "3.8"
services:
  traefik:
    image: traefik:v3.3
    container_name: traefik
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    networks:
      - proxy
    ports:
      - "80:80"
      - "443:443"
      - "8080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik/traefik.yml:/traefik.yml:ro
      - ./traefik/acme.json:/certs/acme.json
 
  mariadb:
    image: mariadb:10.6
    container_name: mariadb
    restart: unless-stopped
    env_file:
      - .env
    environment:
      - MARIADB_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
    volumes:
      - ./data/mariadb:/var/lib/mysql
    networks:
      - core
 
  redis:
    image: redis:alpine
    container_name: redis
    restart: unless-stopped
    networks:
      - core
    # volumes:
    #   - ./data/redis:/data
 
  phpmyadmin:
    image: phpmyadmin:latest
    container_name: phpmyadmin
    depends_on:
      - mariadb
    restart: unless-stopped
    environment:
      - PMA_HOST=mariadb
      - PMA_PORT=3306
    networks:
      - core
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.pma.rule=Host(`pma.example.com`)"
      - "traefik.http.routers.pma.entrypoints=websecure"
      - "traefik.http.routers.pma.tls=true"
      - "traefik.http.routers.pma.tls.certresolver=letsencrypt"
      - "traefik.http.services.pma.loadbalancer.server.port=80"
    expose:
      - "80"
 
networks:
  proxy:
    external: true
  core:
    external: true

Alcune osservazioni su questo file:

  • La presenza di networks: proxy e networks: core sotto la chiave top-level networks con external: true indica a Compose che quelle reti devono esistere già (come creato prima) e di utilizzarle. I nomi devono combaciare esattamente con quelli creati (proxy e core).

  • Tutti i servizi core (tranne Traefik) non espongono porte sull’host. Questo significa che MariaDB e Redis non saranno accessibili dall’esterno, e phpMyAdmin solo tramite Traefik (sottodominio).

  • Avvio dello stack core: spostarsi nella directory core/ e lanciare docker compose up -d. La prima volta, Docker scaricherà le immagini necessarie. Dopo l’avvio, puoi controllare con docker compose ps che i container siano tutti healthy (ad esempio MariaDB e Redis di solito diventano healthy se l’immagine definisce healthchecks, altrimenti almeno devono essere “Up”). Controlla i log con docker compose logs -f traefik per vedere se Traefik ha ottenuto i certificati (la prima volta, con ACME, potrebbe essere andato sul server staging se configurato, oppure su produzione; eventuali errori di DNS o port potrebbero comparire qui). Se è tutto corretto, aprendo un browser su https://pma.example.com dovresti raggiungere phpMyAdmin (con un certificato valido se Let’s Encrypt è riuscito a crearlo). Lo stesso per la dashboard Traefik su http://<IP_VPS>:8080 (non protetta in questa config – ricorda di disabilitarla o proteggerla in futuro!).

A questo punto, il core è pronto. Possiamo aggiungere database via phpMyAdmin (login come root, creare nuovi DB e utenti). Supponiamo di creare un database laravel_db con utente laravel_user (password secretlaravel) e un database node_db con utente node_user (password secretnode), per le nostre due applicazioni di esempio. Queste credenziali andranno poi nei file di configurazione delle applicazioni.

Aggiunta di nuove applicazioni (Laravel, Node.js) con Docker Compose

Una volta che l’infrastruttura core è in esecuzione, possiamo facilmente aggiungere nuove applicazioni containerizzate. Ogni applicazione avrà idealmente il proprio file docker-compose.yml nella sua directory dedicata (sotto apps/), e i propri file Dockerfile o di configurazione. Questa separazione permette di versionare e gestire le applicazioni indipendentemente, pur condividendo i servizi core. Grazie a Traefik, l’aggiunta è plug-and-play: basta avviare il container con le giuste labels e sarà subito esposto sul sottodominio scelto.

Prima di creare le definizioni, ricordiamo che i container applicativi dovranno:

  • Connettersi alle reti Docker: al minimo la rete proxy (per Traefik). Se l’app necessita del database o di Redis, dovrà unirsi anche alla rete core per comunicare con quei servizi.

  • Esporre una porta HTTP internamente e indicarla a Traefik via label o automaticamente via expose.

  • Avere un hostname di dominio configurato nelle labels Traefik (Host()) e usare il resolver TLS come fatto per phpMyAdmin, in modo da ottenere i certificati e forzare HTTPS.

  • Configurare le proprie credenziali di accesso ai servizi (ad esempio variabili d’ambiente per connessione DB, che puntino all’host mariadb e alle credenziali appropriate, come create in MariaDB).

Vediamo ora due esempi concreti: uno per un’app Laravel (PHP) e uno per un’app Node.js. Si assumerà che hai già il codice dell’applicazione pronto nella sottodirectory src/ corrispondente, o quantomeno un progetto di base.

Esempio: Applicazione Laravel (PHP)

Supponiamo di voler distribuire un’app Laravel chiamata “laravel-app” (usiamo questo nome anche per la directory). Laravel è un framework PHP che necessita di PHP (con estensioni) e di un web server. Nel nostro caso, useremo un container basato su PHP con Apache per semplicità: l’immagine php:8.2-apache fornisce PHP 8.2 con Apache HTTP Server integrato. Configureremo Apache per servire Laravel correttamente (document root nella cartella public/ di Laravel, mod_rewrite attivo per le pretty URL, etc.). Tutto questo può essere fatto tramite un Dockerfile personalizzato.

Creiamo dunque apps/laravel-app/Dockerfile con ad esempio il seguente contenuto:

# Fase base: immagine PHP con Apache
FROM php:8.2-apache
 
# Installa estensioni di sistema e PHP necessarie per Laravel
RUN apt-get update && apt-get install -y \
    libzip-dev zip unzip git \
 && docker-php-ext-install pdo_mysql zip \
 && a2enmod rewrite \
 && apt-get clean && rm -rf /var/lib/apt/lists/*
 
# Configura la DocumentRoot di Apache alla cartella public di Laravel
ENV APACHE_DOCUMENT_ROOT=/var/www/html/public
RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
 && sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
 
# Copia il codice sorgente nella image
COPY . /var/www/html
 
# Imposta la working directory
WORKDIR /var/www/html
 
# Installa Composer (gestore pacchetti PHP)
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
 
# Installa le dipendenze PHP del progetto Laravel
RUN composer install --no-interaction --optimize-autoloader --no-dev
 
# Imposta permessi corretti per storage e bootstrap/cache
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache

Vediamo cosa fa questo Dockerfile:

  • Parte dall’immagine base php:8.2-apache che ha Apache HTTPd.

  • Installa alcune librerie di sistema utili: libzip-dev (necessaria per l’estensione zip), zip e unzip (per gestire archivi, spesso usati da Composer o Laravel per plugin), git se il progetto lo usa. Installa estensioni PHP: pdo_mysql (necessaria per connettersi a MySQL/MariaDB) e zip (spesso usata da Laravel). Abilita il modulo rewrite di Apache (per permettere il file .htaccess di Laravel di gestire le route). Pulisce la cache apt.

  • Imposta la variabile d’ambiente APACHE_DOCUMENT_ROOT a /var/www/html/public e modifica i file di configurazione di Apache per puntare il DocumentRoot a quella directory (Laravel espone la sua app al pubblico tramite la cartella public, quindi Apache deve servire quella come root). Questo evita di dover creare un VirtualHost custom manualmente.

  • Copia tutto il contenuto della directory corrente (che sarà la directory laravel-app con dentro presumibilmente il progetto Laravel) nella directory di destinazione nel container /var/www/html.

  • Imposta la working directory (non strettamente necessario, ma conveniente).

  • Installa Composer (scaricandolo direttamente).

  • Esegue composer install per installare le dipendenze PHP del progetto. Si usano opzioni per ottimizzare l’autoloader ed escludere i dev dependency, considerando che stiamo creando un container per produzione. (Nota: Assicurarci che all’interno della directory copiata ci sia il file composer.json e composer.lock del progetto Laravel, altrimenti questo step fallirà. In alternativa, potremmo decidere di non fare l’install in Dockerfile e montare i vendor dal host, ma qui scegliamo l’approccio container completo).

  • Modifica i permessi delle cartelle di storage di Laravel, assegnandoli all’utente www-data (che è l’utente con cui Apache/PHP gira nel container). Ciò è importante affinché Laravel possa scrivere i log e cache.

Una volta creato il Dockerfile, passiamo al docker-compose.yml per questa app Laravel. Nel file apps/laravel-app/docker-compose.yml definiremo un servizio, ad esempio lo chiamiamo laravel:

version: "3.8"
services:
  laravel:
    build:
      context: .
      dockerfile: Dockerfile
    image: laravel-app:latest      # nome che diamo all'immagine costruita (locale)
    container_name: laravel-app
    restart: unless-stopped
    env_file:
      - ../core/.env               # carichiamo magari le stesse variabili, oppure un .env specifico per l'app
    environment:
      - DB_HOST=mariadb
      - DB_DATABASE=laravel_db
      - DB_USERNAME=laravel_user
      - DB_PASSWORD=secretlaravel
      # (Altre variabili d'ambiente Laravel come APP_KEY, ecc, potrebbero essere impostate qui o nel .env file dell'app)
    networks:
      - core
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.laravel.rule=Host(`laravel.example.com`)"
      - "traefik.http.routers.laravel.entrypoints=websecure"
      - "traefik.http.routers.laravel.tls=true"
      - "traefik.http.routers.laravel.tls.certresolver=letsencrypt"
      - "traefik.http.services.laravel.loadbalancer.server.port=80"
    expose:
      - "80"
    depends_on:
      - mariadb
      - redis
networks:
  proxy:
    external: true
  core:
    external: true

Analisi:

  • Usiamo la direttiva build: per costruire l’immagine dal Dockerfile locale. context: . indica che il contesto di build è la directory corrente (dove si trova il docker-compose.yml, presumibilmente apps/laravel-app/), e il Dockerfile da usare è quello in Dockerfile (nome standard, poteva essere omesso se è appunto “Dockerfile”).

  • Diamo un nome/tag locale all’immagine: laravel-app:latest. Questo non è strettamente necessario, ma può essere utile per riferimenti e se volessimo pusharla su un registry.

  • Carichiamo le variabili d’ambiente da ../core/.env solo come comodità se, ad esempio, lì abbiamo definito le stesse credenziali (in realtà, meglio tenere un .env separato per l’app con le sue variabili come APP_NAME, APP_KEY, ecc. Qui per brevità usiamo il core/.env giusto per riutilizzare magari la DB_PASSWORD se definita, ma potremmo anche scriverle in chiaro o avere un .env dedicato).

  • Impostiamo nel container le variabili DB_HOST, DB_DATABASE, DB_USERNAME, DB_PASSWORD che l’app Laravel userà per connettersi al DB (Laravel legge queste tipicamente dal suo file .env interno, che potremmo preimpostare prima di buildare l’immagine, oppure sovrascrivere passando env al container come stiamo facendo). Stiamo indicando di connettersi all’host mariadb (risolvibile sulla rete core), al database laravel_db con credenziali laravel_user/secretlaravel (che abbiamo ipotizzato di creare prima in MariaDB). Analogamente, potremmo aggiungere REDIS_HOST=redis se Laravel è configurato per usare Redis (nel file .env di Laravel di solito c’è questa variabile; se quell’.env è già presente nel codice copiato e contiene REDIS_HOST=127.0.0.1, vorremmo sovrascriverla con redis perché il container troverà Redis su quell’hostname, non su localhost).

  • Reti: colleghiamo laravel sia a core che a proxy. Così potrà raggiungere MariaDB/Redis, e Traefik potrà raggiungere lui.

  • Labels: simili a prima, ma con host laravel.example.com. Indichiamo a Traefik di inoltrare sul suo interno port 80. Abbiamo messo expose: - "80" per esporre la porta 80 del container (Apache in container ascolta su 80). Anche qui niente ports: perché non vogliamo esporlo direttamente.

  • depends_on: - mariadb - redis per fare in modo che Docker Compose al momento dell’up lanci prima i servizi core (però attenzione: in questo compose file “locale”, mariadb e redis non sono definiti, essendo in un altro stack; Docker Compose farà partire solo laravel e ignorerà dipendenze esterne non note. Dunque questa riga in realtà ha effetto solo se MariaDB e Redis fossero definiti nello stesso compose, altrimenti Compose emetterà un warning. Possiamo togliere depends_on qui oppure definire la dipendenza solo concettualmente. Un approccio più sofisticato sarebbe usare Docker Compose Project Reunion o Compose v2 che consente di riferirsi a servizi esterni, ma per semplicità assumiamo che lo stack core sia già up e funzionante). In pratica, assicurati che il core sia avviato prima di avviare l’app Laravel.

Adesso, per avviare l’app Laravel, andiamo nella directory apps/laravel-app/ ed eseguiamo docker compose up -d --build. L’opzione --build forza la (re)build dell’immagine. Docker compilerà l’immagine laravel-app secondo il Dockerfile, poi creerà ed eseguirà il container. Se tutto va a buon fine, Traefik dovrebbe rilevare il nuovo container laravel e loggare qualcosa tipo “Router laravel@example.com configured”. Aprendo https://laravel.example.com dovresti vedere l’app Laravel in esecuzione (magari la pagina di benvenuto di Laravel se è un progetto fresco).

Possibili regolazioni Laravel:

  • Assicurati che l’app abbia generato l’APP_KEY (ad esempio eseguendo php artisan key:generate prima di buildare, o eseguendolo dentro il container se necessario).

  • Puoi eseguire comandi artisan nel container con docker compose exec laravel php artisan migrate etc., se devi fare migrate del database.

  • Il container Laravel che abbiamo creato usa Apache + PHP in uno. In scenari avanzati, si può separare Nginx e PHP-FPM in due container, ma qui abbiamo preferito la via semplice.

Esempio: Applicazione Node.js

Ora consideriamo un’app Node.js (ad esempio un’applicazione Express che serve un’applicazione web su una certa porta). Creiamo directory apps/node-app/ con il codice Node in src/ e un Dockerfile. Immaginiamo che la nostra app Node ascolti sulla porta 3000 e magari usi anche lei il database MariaDB o Redis (per mostrare la connessione ai core services). Per semplicità, diremo che la app Node usa solo Redis come esempio (ad esempio per qualche cache o message queue), e magari legge dal DB MariaDB pure.

Dockerfile per Node (apps/node-app/Dockerfile):

FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
# Se la tua app è costruita (per esempio React SSR o altro) aggiungi build step
EXPOSE 3000
CMD ["npm", "start"]

Questo Dockerfile:

  • Usa Node 18 su base Alpine (leggero).

  • Imposta la directory di lavoro.

  • Copia i file di dipendenze (package.json e package-lock.json) e installa solo le dipendenze di produzione (--production). Questo consente di non includere eventuali devDependencies nel container finale.

  • Copia il resto del codice.

  • Espone la porta 3000 (non strettamente necessario dichiararla, ma documenta quale porta il container servirà; inoltre Traefik può usare questa info).

  • Imposta il comando di avvio, assumendo che nel package.json lo script “start” lanci il server (es: node app.js o simili). Adatta questo in base alla tua app (se ascolta su 3000, manteniamo coerenza).

docker-compose.yml per Node app (apps/node-app/docker-compose.yml):

version: "3.8"
services:
  nodeapp:
    build:
      context: .
      dockerfile: Dockerfile
    image: node-app:latest
    container_name: node-app
    restart: unless-stopped
    env_file:
      - ../core/.env     # opzionale, se vuoi riutilizzare variabili, oppure:
    environment:
      - DB_HOST=mariadb
      - DB_DATABASE=node_db
      - DB_USER=node_user
      - DB_PASSWORD=secretnode
      - REDIS_HOST=redis
    networks:
      - core
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.node.rule=Host(`node.example.com`)"
      - "traefik.http.routers.node.entrypoints=websecure"
      - "traefik.http.routers.node.tls=true"
      - "traefik.http.routers.node.tls.certresolver=letsencrypt"
      - "traefik.http.services.node.loadbalancer.server.port=3000"
    expose:
      - "3000"
    depends_on:
      - mariadb
      - redis
networks:
  proxy:
    external: true
  core:
    external: true

Spiegazione:

  • Simile a prima, costruiamo l’immagine Node.

  • Passiamo alcune variabili d’ambiente: se l’app Node ha bisogno di collegarsi a MariaDB e Redis, forniamo host e credenziali. Nell’esempio, DB_* sono ipotetiche variabili che l’app potrebbe usare (bisogna assicurarsi che il codice Node legga queste, ad esempio via process.env.DB_HOST, etc. Spesso nelle app Node si può usare un pacchetto come dotenv e avere un .env file, ma passando env nel container va bene). REDIS_HOST=redis per connettersi al container Redis interno.

  • Reti: core e proxy.

  • Labels: dominio node.example.com su porta 3000. Qui evidenziamo la necessità di indicare la porta 3000 a Traefik: l’abbiamo fatto con traefik.http.services.node.loadbalancer.server.port=3000. In alternativa, avendo messo EXPOSE 3000 nel Dockerfile, Traefik potrebbe rilevarla automaticamente, ma la label è più esplicita. Mettiamo anche expose: - "3000" per coerenza.

  • depends_on con mariadb e redis, con la stessa avvertenza fatta per Laravel (in un compose separato non li vede; possiamo ometterli se generano confusione, oppure tenerli come commento per ricordare la dipendenza logica).

Avviamo questa applicazione Node con docker compose up -d --build nella directory apps/node-app/. Verrà costruita l’immagine e lanciato il container. Traefik rileverà e configurerà node.example.com. Testando https://node.example.com dovresti vedere la tua applicazione Node (ad esempio l’endpoint root di Express).

A questo punto, abbiamo due applicazioni funzionanti ciascuna su un sottodominio diverso, servite tramite Traefik, che condividono il database e redis centralizzati:

  • laravel.example.com container Laravel (PHP Apache) usa MariaDB e Redis interni.

  • node.example.com container Node (Express) usa MariaDB e Redis interni.

È possibile aggiungere ulteriori applicazioni seguendo lo stesso pattern: creare una cartella, Dockerfile, docker-compose, definire reti e labels. Traefik non ha bisogno di restart per aggiungere nuovi router: grazie al provider Docker, ogni nuovo container con label viene rilevato dinamicamente.

Esempio struttura di rete (riassunto): tutti i container Traefik e app sono su rete proxy. Tutti i container app e core (DB, cache) sono su rete core. Questo consente: Traefik (rete proxy) App; App (rete core) DB/Redis. Una tabella riassuntiva potrebbe essere:

ServizioContainerRete proxyRete corePorta (container)
TraefiktraefikNo8080, 443443 (host)
Database MariaDBmariadbNo3306 (non esposta)
Cache RedisredisNo6379 (non esposta)
phpMyAdminphpmyadmin80 (Traefik instrada)
App Laravellaravel-app80 (Traefik instrada)
App Nodenode-app3000 (Traefik instrada)

(Legenda: “Rete Sì/No” indica se il container è collegato a quella rete.)

Come si nota, MariaDB e Redis non stanno sulla rete proxy, quindi Traefik non li vede affatto (anche se mettessimo erroneamente un label traefik.enable su di essi, Traefik li ignorerebbe poiché non condividono la rete che Traefik monitora). Al contrario, tutte le app e phpMyAdmin stanno su proxy e infatti Traefik le espone.

Uso dei container e gestione

Con i container su, e presumendo tutto funzioni:

  • Puoi controllare i log delle applicazioni con docker compose logs -f nella relativa directory. Ad esempio, docker compose logs -f laravel mostrerà i log Apache/PHP, mentre i log di Node con docker compose logs -f nodeapp. I log di Traefik (errori di routing, richieste, rinnovo certificati) con docker compose logs -f traefik nella dir core.

  • Per aggiornare il codice di un’applicazione, in questo setup production style, dovresti ricostruire l’immagine e riavviare il container. Ad esempio, se fai deploy di una nuova versione di Laravel (aggiornando i file in src/), esegui di nuovo docker compose up -d --build nella dir dell’app per creare una nuova immagine ed effettuare il rolling update.

  • I container core (DB, etc.) continuano a girare e mantengono i dati. Se serve entrare nel DB, puoi usare docker compose exec mariadb mariadb -u root -p per la console MySQL, oppure semplicemente usare phpMyAdmin via web.

  • Manutenzione: in caso di reboot della VPS, i container con restart: unless-stopped ripartiranno automaticamente insieme al servizio Docker. Puoi orchestrare tutto via docker compose, ma considera che docker compose non ha di default un daemon orchestrator, quindi potresti voler utilizzare systemd to run docker compose up at startup, oppure convertire in stack Swarm. Tuttavia, per pochi container, avviare manualmente al bisogno può bastare.

Sicurezza, permessi e persistenza dei dati

Implementare questa infrastruttura richiede attenzione ad alcuni aspetti di sicurezza e gestione:

Sicurezza dei servizi esposti

L’unico servizio esposto a internet in questo design è Traefik (porte 80/443) e di riflesso le applicazioni web tramite Traefik. Assicurati di mantenere Traefik aggiornato all’ultima versione, poiché è il gateway frontale (Traefik v3.x è relativamente nuovo, conviene monitorare gli update). La dashboard Traefik è utile ma di default (con api.insecure=true) è aperta a chiunque: disabilitala o proteggila prima di andare in produzione. Puoi proteggerla con autenticazione HTTP Basic tramite middleware Traefik (oltre lo scopo qui, ma la documentazione di Traefik lo copre).

Le applicazioni Laravel/Node dovrebbero implementare le proprie misure di sicurezza (autenticazione utenti, validazione input, etc.) come faresti in qualsiasi ambiente.

Docker socket: come accennato, Traefik richiede accesso alla socket Docker per funzionare in questo scenario. Ciò è comodo ma presenta un rischio: se qualcuno compromettesse il container Traefik, potrebbe potenzialmente controllare Docker (anche con socket in read-only, ci sono chiamate Docker che possono essere fatte senza scrittura completa). Una best practice avanzata è di utilizzare un docker socket proxy – un container specializzato che espone una versione filtrata della socket Docker. Traefik si collega a quel proxy invece che direttamente al socket. Questo riduce la superficie di attacco filtrando le API Docker accessibili. In configurazioni enterprise vale la pena implementarlo (ci sono immagini come tecnativa/docker-socket-proxy). In ambienti piccoli, potresti accettare il rischio ma essere consapevole: mantieni Traefik isolato (lo abbiamo messo con no-new-privileges e senza accesso al core network, per limitare danni). Anche usare AppArmor o SELinux policies per Docker può aiutare.

Permessi dei file e utenti nei container

Abbiamo cercato di eseguire i processi nei container come utenti non privilegiati ove possibile:

  • Traefik gira come root nel container per default (necessario per binding porte 80/443), ma poi droppa privilegi sulle richieste. Non c’è utente alternativo in Traefik image standard, ma di solito è considerato sicuro se aggiornato costantemente.

  • MariaDB e Redis image eseguono i servizi con l’utente dedicato mysql e redis rispettivamente (non root).

  • phpMyAdmin probabilmente gira con Apache o php-fpm user (www-data) all’interno.

  • L’app Laravel (php:apache) utilizza Apache che gira come www-data (dopo startup) e abbiamo assegnato i file a www-data.

  • L’app Node di default nell’immagine node:alpine gira come root a meno che si specifiche diversamente. Sarebbe opportuno eseguire Node come utente non root. Una semplice modifica: aggiungere nel Dockerfile Node un comando USER node (l’immagine node:alpine definisce un utente node con UID non privilegiato). Se fai ciò, assicurati di copiare file con permessi appropriati (la directory /app dovrebbe essere posseduta da node user, potresti aggiungere chown -R node:node /app prima di USER). Questo miglioramento impedisce che un exploit nell’app Node dia privilegi root nel container. Per brevità non l’abbiamo aggiunto sopra, ma è raccomandato.

Persistenza dei volumi e backup

Abbiamo usato volumi per:

  • MariaDB: dati persistenti in core/data/mariadb. Docker memorizza i file InnoDB lì. È fondamentale includere questa directory nel tuo piano di backup, perché contiene tutte le informazioni del database. Una strategia di backup potrebbe essere: eseguire periodicamente un docker exec mariadb mysqldump --all-databases e salvare l’output, oppure arrestare il container e copiare i file raw altrove (mysqldump è preferibile per consistenza a caldo). In ogni caso, non dimenticare backup, specialmente se i dati sono critici.

  • Redis: abbiamo reso il volume per Redis opzionale. Se la tua applicazione usa Redis solo come cache (che può essere rigenerata), puoi anche non persisterlo. Se invece lo usi per code o dati importanti, attiva il volume per mantenere i file AOF/RDB. Anche quelli andrebbero inclusi nei backup (es. core/data/redis).

  • Traefik: il file acme.json è persistente, contiene i certificati TLS generati. Fai il backup di esso (è già in git? In teoria non dovresti metterlo in git perché contiene certificati privati; trattalo come segreto). In caso di migrazione server, ti consente di non perdere i cert e non sforzare Let’s Encrypt con rinnovi.

  • Codice delle applicazioni: nel nostro approccio, il codice è baked-in nelle immagini Docker, quindi non è montato come volume. Ciò significa che, una volta buildata l’immagine, puoi distruggere la cartella src sul server e l’app continuerebbe a funzionare dal codice nell’immagine (non farlo se prevedi di aggiornare). In produzione spesso il codice risiede in un repository Git altrove e si ricreano le immagini. Non è necessario fare backup della cartella apps/<app>/src sul server se hai il repository altrove, ma se stai sviluppando direttamente lì, allora fanne il backup o meglio usa un VCS.

  • Logs: di default Docker cattura gli stdout/stderr dei processi container nei cosiddetti json-file logs. Questi file risiedono sotto /var/lib/docker/containers/<container-id>/*-json.log e possono crescere col tempo. È buona prassi impostare un log rotation. In Compose, puoi specificare opzioni di logging per limitare dimensione e numero dei file di log. Ad esempio:

    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

    aggiunto per ciascun servizio, assicura che per ogni container Docker mantenga al massimo ~30MB di log (3 file da 10MB) ruotandoli. Ciò previene che log molto verbosi (es. un’app in errore che stampa molto) riempiano il disco. In ambienti avanzati, potresti inviare i log a un sistema esterno (ELK stack, Loki, ecc.), ma per semplicità almeno fai la rotazione. Puoi anche configurarlo a livello daemon in /etc/docker/daemon.json per applicarlo globalmente.

  • Differenza Named Volume vs Bind Mount: Nel compose, abbiamo usato path locali (ad esempio ./data/mariadb) quindi in pratica abbiamo usato bind mount (montando directory dell’host nel container). In alternativa, avremmo potuto usare named volumes Docker (es. definire volumes: mariadb_data: e montarlo). Un named volume è gestito interamente da Docker (i dati stanno in /var/lib/docker/volumes/...), mentre un bind mount è una cartella esplicita dell’host. Entrambi persistono, ma con i bind mount hai la trasparenza di poter navigare i file dal host facilmente. I named volume sono utili per decouple dal filesystem host. La scelta dipende dalle preferenze; qui abbiamo usato bind mount per vedere facilmente i file. In ogni caso i concetti di persistenza valgono uguale: Docker non elimina i volumi named a meno che glielo chiedi espressamente, e li puoi riutilizzare montandoli su container nuovi.

Aggiornamento e manutenzione

  • Aggiornamento container: per aggiornare Traefik o altri servizi core, puoi modificare la versione immagine nel compose e fare docker compose pull && docker compose up -d per applicare (Traefik per esempio manterrà le configurazioni e riacquisirà i cert da acme.json se già presenti). Attento che MariaDB major upgrade vanno pianificati (non fare un salto di versione senza backup).

  • Gestione credenziali: non lasciare credenziali in chiaro in file commitatti su git. Usa .env (che puoi non includere in git) per le password. Ad esempio, abbiamo MYSQL_ROOT_PASSWORD nel file .env in core (assicurati di .gitignore). Lo stesso per password app (nel compose dell’app potresti usare variabili d’ambiente riferite a .env file non commitato).

  • Firewall: anche se i container non espongono porte, è buona norma a livello di VPS chiudere porte non usate. Assicurati che solo 80/443 (e 22 per SSH) siano aperte al mondo. Porta 8080 (Traefik dash) se attiva può essere filtrata per IP se vuoi vederla solo tu (ad es. tramite ufw o iptables).

  • Resource limits: per ambienti con molte app, valutare l’uso di limiti CPU/RAM in Compose (opzioni deploy o mem_limit nel service) per prevenire che un container saturi tutte le risorse. Docker Compose (non Swarm) supporta mem_limit sotto deploy: resources: se eseguito con Docker Compose v2 plugin.

Con quanto impostato, hai un ambiente modulare: puoi avviare/fermare ogni applicazione indipendentemente (docker compose stop nella dir dell’app per fermarla, Traefik smetterà di inoltrare richieste e darà 404 per quel dominio, che è ok). Il core stack dovrebbe restare sempre in esecuzione.

Bonus: Sistema centralizzato per comunicazione in tempo reale (WebSocket/MQTT)

In alcuni scenari, potresti voler far comunicare direttamente vari container tra loro in maniera pub/sub o real-time, ad esempio per notifiche in tempo reale ad applicazioni web, IoT, o coordinazione di microservizi. Una soluzione comune è introdurre un message broker centralizzato. Mentre Redis stesso offre funzionalità pub/sub (che potresti sfruttare senza componenti aggiuntivi), una tecnologia dedicata come MQTT o altri sistemi di messaging può essere utile. Qui proponiamo come esempio l’aggiunta di Eclipse Mosquitto – un broker MQTT leggero che supporta anche connessioni WebSocket.

Mosquitto (MQTT broker): MQTT è un protocollo publish/subscribe tipicamente usato per IoT, ma può essere usato in generale per distribuire messaggi tra componenti. Mosquitto è disponibile come immagine Docker pronta (eclipse-mosquitto). Lo aggiungeremo allo stack core come servizio opzionale.

Aggiorniamo core/docker-compose.yml aggiungendo:

  mosquitto:
    image: eclipse-mosquitto:latest
    container_name: mosquitto
    restart: unless-stopped
    networks:
      - core
    ports:
      - "1883:1883"
      - "9001:9001"
    volumes:
      - ./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf
      - ./mosquitto/data:/mosquitto/data
      - ./mosquitto/log:/mosquitto/log

Cosa fa questa configurazione:

  • Collega Mosquitto solo alla rete core (quindi accessibile ai container interni). Abbiamo però mappato porte sull’host: 1883 è la porta MQTT standard (TCP), 9001 è una porta che useremo per il protocollo WebSocket (nel contesto MQTT). Se desideri che le app web possano connettersi al broker via WebSocket attraverso Traefik, potresti in teoria anche mettere mosquitto sulla rete proxy e configurare Traefik per inoltrare, ma spesso conviene connettersi direttamente se la sicurezza lo permette. In ambienti cluster potresti esporre via Traefik con un router TCP, ma rimaniamo semplici.

  • Montiamo volumi per la configurazione e per dati/log. È necessario fornire una config, perché per default Mosquitto potrebbe limitare le connessioni. Creeremo core/mosquitto/mosquitto.conf.

Esempio di mosquitto.conf minimale per abilitare WebSocket e sicurezza:

persistence true
persistence_location /mosquitto/data/
allow_anonymous false
password_file /mosquitto/config/passwd
listener 1883 0.0.0.0
listener 9001 0.0.0.0
protocol websockets

Spiegazione:

  • persistence true con persistence_location permette di tenere memorizzati i dati delle sessioni (utile se usi QoS e retained messages).

  • allow_anonymous false disabilita accessi anonimi: obbliga l’uso di credenziali.

  • password_file specifica un file di password (verrà creato con utility).

  • Definiamo due listener: uno su 1883 per MQTT standard, e uno su 9001 con protocol websockets (così i client possono connettersi via WS, utile ad esempio se da una pagina web vuoi connetterti al broker MQTT direttamente).

  • 0.0.0.0 li fa ascoltare su tutti gli interfaccie/container. Così i container sulla rete core potranno raggiungere mosquitto:1883. Dall’esterno, la porta 1883 è esposta sulla VPS (potresti limitarla se non vuoi device esterni, oppure puoi configurare Traefik TCP router come accennato per usarlo su un dominio).

Dobbiamo impostare le credenziali per Mosquitto:
Creiamo un file passwd nella dir core/mosquitto/. Possiamo utilizzare l’utility mosquitto_passwd per generare voci. Ad esempio, sul server:

docker compose exec mosquitto mosquitto_passwd -c /mosquitto/config/passwd mqttuser
# Ti chiederà una password da assegnare a 'mqttuser'

Questo comando (dopo che mosquitto container è su) creerà il file di password con l’utente mqttuser e password hashata. Abbiamo montato la cartella config, quindi il file rimane persistente sul host (core/mosquitto/passwd). Ora Mosquitto è protetto: i container o client dovranno autenticarsi.

Le applicazioni (Laravel, Node, etc.) su rete core possono ora utilizzare questo broker:

  • Una app Laravel può usare un client MQTT (es. una libreria PHP mqtt) per sottoscriversi/pubblicare su mosquitto host porta 1883, con le credenziali. Oppure più comunemente, Laravel può pubblicare eventi su Redis (se usi Laravel Echo, però quello tipicamente vuole un server WebSocket - potresti integrarlo con Mosquitto via MQTT-bridge, ma andremmo oltre).

  • Una app Node potrebbe usare ad esempio mqtt (pacchetto npm) per connettersi a mqtt://mosquitto:1883 e sottoscriversi/publish su determinati topic.

  • Se vuoi che un client front-end web riceva notifiche push in tempo reale, potrebbe connettersi via WebSocket a wss://:9001 (se 9001 è aperta e magari protetta con TLS staccato - potenzialmente potresti aggiungere Traefik router TCP+TLS su 9001, ma Traefik 3 supporta L7/SSL passthrough per WS? Forse conviene aprire direttamente 9001 in TLS terminato dal broker). In questo scenario, potresti anche generare certificati per Mosquitto e farlo ascoltare su 9001 con SSL; però più semplice: apri 9001 e fai terminare SSL su Traefik se lo configuri come servizio TCP. Approccio più semplice: se i client WS sono sullo stesso dominio dell’app (ad esempio app JS servita da example.com), potresti configurare un subdominio tipo mqtt.example.com e istruire Traefik a fare da proxy TCPMosquitto. Data la complessità, assumiamo l’uso solo interno o su porte dirette con firewall.

In breve, l’aggiunta di Mosquitto offre un canale di comunicazione publish/subscribe efficiente tra container e potenzialmente fino ai client finali. È opzionale: se non ne hai bisogno, puoi omettere questa parte.

Altre alternative per comunicazione in real-time potrebbero includere:

  • RabbitMQ o NATS come broker più robusti per messaging tra microservizi.

  • Socket.IO server integrato nelle app Node (che però sarebbe specifico a quell’app, non centralizzato).

  • Usare Redis Pub/Sub integrato: Laravel e Node potrebbero entrambi sottoscriversi a canali Redis. Dal momento che condividono Redis, è possibile (Redis Pub/Sub non ha security per utente, ma se tutti sono trusted va bene). Tuttavia, un broker dedicato come Mosquitto è progettato proprio per decine di migliaia di topic e sottoscrizioni e supporta nativamente i client.

Se implementi Mosquitto e lo esponi, ricordati di applicare regole di firewall se opportuno (es: se vuoi che solo i container possano usarlo, non esporre 1883 oltre la VPS o limitane l’accesso agli IP interni). L’abilitazione allow_anonymous false nel config è fondamentale: altrimenti chiunque potrebbe connettersi e leggere/scrivere messaggi.

Conclusione

Seguendo questa guida, hai impostato una infrastruttura Docker modulare e scalabile su Debian:

  • Un reverse proxy Traefik che gestisce automaticamente host virtuali e certificati SSL per ogni container applicativo aggiunto, rendendo trivialmente facile l’esposizione di nuovi servizi web su sottodomini distinti.

  • Un set di servizi core condivisi (database MariaDB con più schemi, cache Redis) che riducono la duplicazione di risorse e facilitano la gestione centralizzata dei dati.

  • Una struttura a directory ordinata che ti consente di aggiungere nuove applicazioni (Laravel, Node.js, o qualunque altro stack come Python/Flask, Ruby on Rails, ecc.) semplicemente creando un nuovo file Compose e Dockerfile, sfruttando le stesse reti e principi.

  • Considerazioni di sicurezza implementate (isolamento delle reti, nessuna esposizione di porte non necessarie, possibilità di introdurre autenticazioni e restrizioni) e discussione di best practice come l’uso di utenze non root, la rotazione dei log e la protezione della docker socket.

  • Possibilità di estendere le funzionalità con componenti aggiuntivi come un broker MQTT/WebSocket centralizzato per esigenze di comunicazione realtime tra container.

Questa infrastruttura è estendibile: ad esempio, potresti aggiungere un servizio di monitoraggio (es. Portainer per gestire i container via interfaccia web, che a sua volta potrebbe stare dietro Traefik con un sottodominio admin), oppure aggiungere un container MailHog/SMTP per test di email se le app ne mandano. Basta collegarli e etichettarli come fatto.

Ricorda di testare accuratamente ogni componente e tenere sotto controllo risorse e sicurezza con aggiornamenti continui. Docker Compose ti facilita molto la vita nel mantenere documentata e ripetibile l’infrastruttura (il compose file funge da documentazione vivente), e Traefik elimina molto del lavoro manuale di configurazione proxy/webserver per ogni nuovo sito.

Buon lavoro con la tua VPS Dockerizzata!