Ansible per PMI: automatizzare il provisioning di VPS Linux senza DevOps dedicato

Ansible per PMI: automatizzare il provisioning di VPS Linux senza DevOps dedicato

Nel 2025 ho provisionato 34 VPS per clienti italiani diversi - Hetzner, OVH, Contabo, Digital Ocean, Aruba - ognuno con la stessa sostanziale configurazione di base: Debian 12 o Ubuntu 24.04, LEMP stack con PHP 8.2 o 8.3, MariaDB 10.11, Nginx con Let's Encrypt, firewall nftables hardening-ready, backup automatico verso Storage Box esterno, monitoring minimale con monit e alert via Telegram, fail2ban per SSH e portali web, user-separation per applicazioni diverse, cron schedulati per rotazione log e manutenzione. Fino al 2023 ogni nuovo server mi costava tre ore piene di tempo-dito fra comandi manuali, copia-incolla da checklist markdown personali, verifica dei risultati, debugging delle piccole differenze fra provider (DigitalOcean Droplet non ha kernel personalizzato, Contabo usa un default locale C.UTF-8 diverso da Hetzner, Aruba monta /tmp con flag noexec che rompe alcuni deploy tools). Dal 2024 lo stesso provisioning richiede 8 minuti di lavoro, è completamente riproducibile, è versionato in Git, e ha accumulato nel tempo correzioni e miglioramenti che si propagano automaticamente a tutti i server nuovi - perché è scritto come playbook Ansible.

Il principio operativo che ripeto a ogni cliente quando spiego perché vale la pena imparare Ansible è semplice: il provisioning fatto a mano è un debito tecnico che cresce in silenzio e che esplode solo quando qualcosa va male. Se il tuo server di produzione muore a causa di un guasto hardware del provider, e tu devi ricostruire in fretta la stessa configurazione su un server nuovo, il tempo di ricostruzione a mano è proporzionale a quanto è cresciuta nel tempo la configurazione - spesso molto di più delle tre ore originali. Se invece hai un playbook Ansible, la ricostruzione è questione di venti minuti dal momento in cui il nuovo VPS è disponibile. Questo articolo descrive l'architettura dei miei playbook, la struttura di inventory che uso per gestire decine di clienti differenti senza duplicare codice, e le lezioni operative che rendono sostenibile il pattern nel medio-lungo periodo per una PMI che non ha un DevOps dedicato e che usa Ansible come moltiplicatore di produttività, non come progetto a sé stante.

Perché Ansible invece di Terraform, Pulumi o uno script bash ben fatto?

La domanda è legittima, perché le alternative sono tante e ognuna ha un caso d'uso dove è migliore. Lo script bash "che ho sempre usato" è il concorrente più vicino di Ansible su piccola scala, perché sembra più semplice e richiede zero dipendenze aggiuntive. Il problema dello script bash è l'assenza di idempotenza: uno script che esegue apt install nginx funziona la prima volta ma fallisce (o comunque non dà l'effetto previsto) alla seconda esecuzione, richiedendo al autore di aggiungere manualmente i controlli "se X esiste già non farlo" per ogni operazione. Ansible gestisce nativamente l'idempotenza attraverso i suoi moduli (apt, user, file, service, template) che sanno leggere lo stato corrente del sistema e applicare solo le modifiche necessarie - uno script idempotente in bash è realizzabile ma richiede tanto codice difensivo che diventa equivalente in LOC a un playbook Ansible, senza averne i benefici.

Terraform e Pulumi sono strumenti di infrastructure as code a più alto livello, specificamente progettati per gestire risorse cloud (VPS, load balancer, DNS, storage) come dichiarazioni di stato desiderato. Sono eccellenti quando il tuo mondo è Kubernetes, AWS, GCP, Azure con dozzine di servizi gestiti integrati. Per il mio caso d'uso - provisionare VPS nudi su provider europei come Hetzner, OVH e Contabo, con solo 3-5 risorse base per cliente (il VPS, uno Storage Box, una zona DNS Cloudflare) - sono un martello esagerato per il chiodo. Ansible gestisce i VPS già creati come "host target" e si concentra sulla configurazione del sistema operativo, che è dove vive il 90% del valore. Il complemento naturale, quando il cliente cresce e la complessità infra aumenta, è combinare Terraform (per creare le risorse cloud) con Ansible (per configurarle) - l'architettura classica dei progetti IaC descritta in dettaglio nel mio articolo sui benefici dell'infrastructure as code per le PMI italiane.

La scelta di Ansible come strumento primario ha anche una ragione non tecnica: la curva di apprendimento. Un developer PHP con conoscenze bash medie diventa produttivo con Ansible in 3-5 giorni; diventa produttivo con Terraform in 2-3 settimane; diventa produttivo con Pulumi (specie se deve scrivere in TypeScript) ancora più tempo. Per una PMI dove il provisioning è un'attività mensile e non giornaliera, la semplicità di Ansible vince sul potere delle alternative. La documentazione ufficiale di Ansible mantenuta da Red Hat e disponibile integralmente su docs.ansible.com è completa e la community stabile ha prodotto role riutilizzabili per praticamente qualunque esigenza standard, da Geerlingguy a Debops.

Struttura del repository: inventory, group_vars e role riutilizzabili

Il repository Ansible che mantengo per i miei clienti segue una struttura piatta e pragmatica, frutto di iterazioni su molti progetti e conversazioni con colleghi più esperti sulla manutenibilità a medio termine. La struttura è questa:

ansible-infra/
├── ansible.cfg
├── inventory/
│   ├── hosts.yml
│   └── group_vars/
│       ├── all.yml
│       ├── production.yml
│       ├── staging.yml
│       └── client_acme.yml
├── playbooks/
│   ├── provision-vps.yml
│   ├── security-hardening.yml
│   ├── deploy-laravel.yml
│   └── backup-rotation.yml
├── roles/
│   ├── base/
│   ├── lemp/
│   ├── hardening/
│   ├── backup/
│   └── monitoring/
└── vault/
    └── secrets.yml

Il file inventory/hosts.yml è la mappa di tutti i server gestiti, raggruppati per cliente e per ambiente. Un estratto reale (con identificativi anonimizzati):

# inventory/hosts.yml
all:
  vars:
    ansible_user: root
    ansible_python_interpreter: /usr/bin/python3

production:
  hosts:
    acme-web-01:
      ansible_host: 10.1.2.3
      php_version: "8.2"
      domain: portale.acme.local
    acme-db-01:
      ansible_host: 10.1.2.4
      mysql_innodb_buffer_pool: 24G
    contoso-web-01:
      ansible_host: 10.2.3.4
      php_version: "8.3"
      domain: app.contoso.local

staging:
  hosts:
    acme-web-staging:
      ansible_host: 10.1.99.3
      php_version: "8.2"
      domain: staging.acme.local

client_acme:
  children:
    production:
      hosts:
        acme-web-01:
        acme-db-01:
    staging:
      hosts:
        acme-web-staging:
  vars:
    backup_storage_box: [email protected]
    timezone: Europe/Rome

La struttura di variabili (group_vars/all.yml, group_vars/production.yml, group_vars/client_acme.yml) segue la convenzione di merge di Ansible: le variabili in all.yml sono i default globali, quelle in production.yml o staging.yml sovrascrivono i default per ambiente, quelle in client_acme.yml sovrascrivono per cliente. Questo permette di personalizzare un singolo parametro (ad esempio il dominio di produzione di un cliente) in un singolo file, senza dover duplicare l'intera configurazione.

I secrets - password di root MySQL, chiavi API di Cloudflare, token Telegram per alert, credenziali Storage Box - non vanno mai in group_vars chiaro. Risiedono in vault/secrets.yml cifrato con Ansible Vault (AES-256), e vengono richiamati come {{ mysql_root_password }} nei playbook. La password di vault è condivisa in modo sicuro con il cliente (tipicamente tramite Bitwarden aziendale o 1Password), e l'accesso al repository senza quella password è inutile per chi non ha il permesso di accedere al contenuto cifrato - un pattern semplice ma efficace di separation of concerns fra codice infra e segreti.

Stai cercando un Consulente Informatico esperto per impostare una pipeline Ansible per la tua PMI con provisioning riproducibile di VPS e hardening automatizzato, senza un team DevOps dedicato? Nel mio profilo professionale trovi l'esperienza concreta su automazione infrastrutturale per aziende italiane con server su Hetzner, OVH, Contabo, Digital Ocean e Aruba.

Il role base: da VPS nudo a sistema minimo sicuro

Il primo role che eseguo su qualunque VPS nuovo è base, responsabile di portare il server da "VPS appena creato dal provider" a "sistema minimo sicuro pronto per l'installazione applicativa". Le task di questo role sono la minima intersezione che applico a ogni singolo server, indipendentemente dalla sua destinazione d'uso.

Un estratto dei task più rilevanti, da roles/base/tasks/main.yml:

# roles/base/tasks/main.yml
- name: Aggiorna tutti i pacchetti di sistema all'ultima versione
  ansible.builtin.apt:
    update_cache: yes
    upgrade: dist
    cache_valid_time: 3600

- name: Installa pacchetti di base per amministrazione
  ansible.builtin.apt:
    name:
      - vim
      - curl
      - wget
      - unzip
      - git
      - htop
      - ncdu
      - rsync
      - logrotate
      - unattended-upgrades
      - fail2ban
      - nftables
      - monit
      - python3-pip
    state: present

- name: Imposta hostname secondo inventory
  ansible.builtin.hostname:
    name: "{{ inventory_hostname }}"

- name: Imposta timezone
  community.general.timezone:
    name: "{{ timezone | default('Europe/Rome') }}"

- name: Crea utente deploy per applicazioni
  ansible.builtin.user:
    name: deploy
    shell: /bin/bash
    groups: sudo
    password: "{{ deploy_user_password | password_hash('sha512') }}"
    state: present

- name: Disabilita login root via SSH
  ansible.builtin.lineinfile:
    path: /etc/ssh/sshd_config
    regexp: "^PermitRootLogin"
    line: "PermitRootLogin prohibit-password"
  notify: restart sshd

- name: Disabilita autenticazione password SSH
  ansible.builtin.lineinfile:
    path: /etc/ssh/sshd_config
    regexp: "^PasswordAuthentication"
    line: "PasswordAuthentication no"
  notify: restart sshd

- name: Installa chiave pubblica dell'admin per utente deploy
  ansible.posix.authorized_key:
    user: deploy
    state: present
    key: "{{ admin_public_key }}"

Due dettagli meritano attenzione. Primo: l'uso di notify: restart sshd invece di riavviare SSH direttamente. Questo pattern - handler in risposta a change - garantisce che SSH venga riavviato solo se il file di configurazione è effettivamente cambiato, evitando restart inutili su run successivi. Secondo: l'utente deploy viene creato con password ma l'accesso via password è disabilitato al livello SSH globale, quindi la password serve solo per comandi sudo espliciti, non per il login. La chiave pubblica per accesso SSH è registrata come authorized_key, e da quel momento in poi tutto l'accesso amministrativo al server avviene via chiave, mai via password.

Il role base completo contiene altre 20-25 task che configurano unattended-upgrades per patch di sicurezza automatiche, rotazione dei log di sistema con logrotate, monitoraggio disco e RAM via monit con alert via email o Telegram, configurazione nftables per firewall minimale (default deny inbound, allow SSH e HTTP/HTTPS), e installazione di strumenti di troubleshooting standard. L'idea guida è che qualunque server provisionato con questo role sia immediatamente utilizzabile e sicuro come baseline, anche prima di installare qualunque applicazione.

Il role lemp e il deploy di un'applicazione Laravel end-to-end

Il role lemp è quello che trasforma il baseline in uno stack LEMP pronto per applicazioni PHP. Installa Nginx, PHP-FPM con le estensioni tipiche (pdo_mysql, mbstring, xml, curl, gd, zip, bcmath, redis), MariaDB, Redis, e configura certificati TLS via Let's Encrypt con renewal automatico. La scelta di separare base da lemp è deliberata: ho clienti che hanno bisogno solo di un server Node.js o Python, e per quelli eseguo solo il role base più un role node o python senza installare lo stack PHP. La composabilità dei role è la leva che rende l'intero sistema riutilizzabile su scenari eterogenei senza duplicazioni.

Un pattern interessante nel role lemp è la gestione dei virtual host Nginx tramite template Jinja2. Invece di scrivere un file statico per ogni sito, uso un template parametrizzato:

# roles/lemp/templates/nginx-vhost.conf.j2
server {
    listen 80;
    server_name {{ domain }};
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name {{ domain }};
    root /var/www/{{ app_name }}/public;
    index index.php;

    ssl_certificate /etc/letsencrypt/live/{{ domain }}/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/{{ domain }}/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';

    # Header di sicurezza
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php{{ php_version }}-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    # Disabilita esecuzione PHP in /storage/ e /public/uploads/
    location ~ ^/(storage|uploads)/.*\.php$ {
        deny all;
        return 403;
    }
}

Il dettaglio sulla location che blocca l'esecuzione di PHP in /storage/ e /uploads/ è critico - è esattamente la protezione che evita scenari come quello descritto nel mio articolo sull'analisi forense di un attacco Laravel via webshell e IDOR, dove una combinazione di upload non validato e location Nginx permissiva aveva dato all'attaccante l'esecuzione di codice remoto. Avere questa regola scolpita nel template Ansible significa che qualunque nuovo server provisionato dal nostro playbook parte già con quella protezione attiva - un bug di sicurezza che non può manifestarsi per omissione perché è incorporato nella configurazione di default.

Il deploy applicativo vero e proprio è demandato a un playbook deploy-laravel.yml separato, che usa pattern di zero-downtime deployment con symlink atomico - complementare al workflow automatizzazione del deploy di applicazioni Laravel su server Linux Hetzner e OVH con Deployer ma fatto con Ansible invece che con Deployer. La scelta fra i due è pragmatica: uso Deployer quando il team del cliente è già abituato ai suoi workflow; uso Ansible quando il team è più a suo agio con un singolo strumento per tutto.

Gestione della drift configurazione e il problema dell'esecuzione periodica

Un problema che i team novizi di Ansible scoprono dopo sei mesi di utilizzo è la configuration drift: un amministratore modifica manualmente qualcosa sul server (ad esempio aggiunge una regola nftables temporanea per un debug urgente, o cambia un parametro di MySQL mentre testa un'ottimizzazione), e quel cambio non finisce mai nel playbook. Il server diverge silenziosamente dallo stato dichiarato, e la prossima esecuzione del playbook o annulla il cambio manuale (rompendo qualcosa) o lo preserva per caso (accumulando tecnicamente debito nascosto).

La soluzione che applico è duplice. Prima: eseguire i playbook Ansible periodicamente anche senza cambi pianificati, tipicamente una volta a settimana su un crontab dedicato. Se il playbook segnala cambiamenti (changed: 3 invece di changed: 0), significa che c'è stata drift e qualcuno l'ha causata - va investigata e portata nel playbook o rimossa dal server. Seconda: una convenzione organizzativa per cui qualunque modifica a server gestiti da Ansible va prima nel playbook e poi applicata, mai direttamente. La convenzione è enforceable solo sulla disciplina del team, ma quando viene rispettata elimina completamente la drift e mantiene il codice come unica fonte di verità.

Un altro pattern operativo importante è la gestione dei rollback. Se un playbook introduce una modifica che rompe qualcosa in produzione, il "rollback" in Ansible non è ovvio: non c'è un comando ansible rollback. La strategia che uso è di mantenere sempre il playbook sotto Git, e fare un rollback via git revert del commit problematico seguito da rerun del playbook. Questo funziona perché la maggior parte dei cambi Ansible sono idempotenti e reversibili (mettere un file con un certo contenuto, disabilitare un servizio, aprire una porta firewall). I cambi non reversibili - tipicamente sull'ordine delle migration del database o su cambi di schema applicativi - richiedono procedure dedicate al di fuori di Ansible, esattamente come descritto nel mio approfondimento sulle strategie di backup avanzate per VPS unmanaged su Hetzner, OVH, Contabo, Digital Ocean e Aruba.

Il ROI pratico misurato a 18 mesi

Il beneficio più ovvio dell'adozione di Ansible è la riduzione del tempo di provisioning - dalle tre ore manuali agli otto minuti di playbook, un fattore 22x. Ma il beneficio più importante e meno immediato è la riduzione del tempo di disaster recovery. Prima dell'adozione di Ansible, la ricostruzione di un server perso a causa di un guasto hardware richiedeva mezza giornata di lavoro e spesso produceva una configurazione leggermente diversa da quella originale (perché la checklist non era mai completa al 100%). Dopo l'adozione, la ricostruzione richiede 15-25 minuti dal momento in cui il nuovo VPS è disponibile, e la configurazione è bit-per-bit identica a quella precedente.

Sul cliente distribuzione industriale che ho menzionato in un altro articolo, nel 2025 abbiamo vissuto un caso reale: un failure hardware su un VPS Hetzner di produzione ha richiesto la migrazione su un nuovo server. Il sistema è stato ricostruito in 18 minuti netti dal momento in cui il nuovo VPS aveva un IP assegnato, con zero differenze di configurazione rispetto al precedente. Nei 18 mesi successivi al rollout di Ansible sul perimetro di quel cliente, il tempo cumulativo risparmiato su provisioning, manutenzione drift-free, e rapid-recovery si è attestato a circa 40 giornate-uomo. Il costo di adozione iniziale - una settimana di lavoro mio per scrivere i role e i playbook, più tre giorni di formazione del team interno - è stato ammortizzato in circa due mesi.

Il pattern che emerge da questa e da altre adozioni è che Ansible non ha senso come "progetto di ottimizzazione" puro - ha senso come abilitatore di pratiche migliori. Chi adotta Ansible senza cambiare le abitudini operative (interventi manuali sui server, nessun disaster recovery plan, configurazioni divergenti fra server) ottiene poco beneficio. Chi adotta Ansible come parte di una disciplina più ampia di infrastructure-as-code, con documentazione in Git, review delle modifiche e esecuzione periodica di controllo drift, trasforma radicalmente la gestione della propria infrastruttura. Se gestisci una PMI con più di 3-5 VPS in produzione e senza un DevOps dedicato, e ti trovi a ripetere operazioni simili mese dopo mese su server diversi, oppure hai subito almeno un incidente dove la ricostruzione di un sistema perso ti ha portato via una giornata intera di lavoro, contattami per una valutazione: in una settimana di lavoro imposto la struttura Ansible calibrata sul tuo stack, formo il tuo team interno sulla sua manutenzione autonoma, e ti consegno i playbook pronti all'uso per i primi cinque server del tuo perimetro - con la certezza che da quel momento in poi il provisioning e la ricostruzione saranno quello che dovrebbero essere: operazioni prevedibili, veloci, e noiose.

Ultima modifica: