SSD Nodes Learn
Guide Matt ConnorDi Matt Connor · Aggiornato 2026-07-24

Guida Ansible: primo playbook su VPS

Installa Ansible con pipx su Ubuntu 24.04. Crea un inventory e un playbook per il hardening di un VPS risolvendo gli errori di Permission denied e sudo.

Cosa stai costruendo

Una macchina di controllo con Ansible installato e uno o più VPS Ubuntu 24.04 nuovi con solo l'immagine stock. Alla fine avrai un file di inventory con i nomi dei tuoi server, un ping ad-hoc che conferma il corretto funzionamento dell'autenticazione end-to-end e un playbook che esegue l'intero checklist per i nuovi VPS come codice: un utente di deploy con la tua chiave SSH, sshd configurato per la sicurezza, fail2ban, unattended upgrades e un firewall che permette OpenSSH prima di bloccare tutto il resto. Puoi usarlo su un solo server o su venti. Eseguilo due volte e la seconda esecuzione non cambierà nulla: questo è l'obiettivo principale.

Dopo quindici anni di provisioning di VPS, posso dirti qual è il pattern comune: tutti configurano i primi cinque server manualmente, poi perdono un intero weekend sul sesto perché nessuno ricorda cosa è stato fatto sui primi cinque. Questa guida approfondisce l'analisi su gestione di più server Linux — leggila il giorno in cui ti accorgerai di digitare lo stesso apt install in tre terminali diversi.

Cos'è Ansible in un paragrafo

Ansible è agentless. Non è necessario installare alcun daemon sui server gestiti: la macchina di controllo si connette tramite SSH standard, copia un piccolo modulo Python sul target, lo esegue, legge l'output JSON e lo elimina. L'unico requisito per il target è python3, già presente in ogni immagine Ubuntu standard. Il concetto fondamentale è l'idempotenza: un task descrive uno stato, non un'azione. state: present per un pacchetto significa "assicurati che sia installato", non "avvia l'installer". Se lo stato è già quello desiderato, Ansible non modifica nulla e lo segnala come ok invece di changed. Questa proprietà è il cuore del prodotto: è ciò che rende sicuro l'esecuzione ripetuta di un playbook, e la sicurezza nelle esecuzioni ripetute è ciò che trasforma uno shell script in infrastruttura.

Prerequisites, and the gotchas up front

  • Una macchina di controllo: il tuo laptop o un piccolo VPS. Assumo l'uso di Ubuntu 24.04; macOS funziona in modo identico dopo l'installazione di pipx tramite Homebrew.
  • Uno o più VPS target con Ubuntu 24.04 su KVM, raggiungibili come root. Non verrà installato nulla su di essi.
  • Autenticazione tramite chiave SSH per ogni target. Ansible utilizza lo stesso metodo di autenticazione del comando ssh — se ssh root@host richiede una password, Ansible fallisce.
  • Su Ubuntu 24.04, pip install ansible fallisce con error: externally-managed-environment. Si tratta di una scelta deliberata della distribuzione, non di un malfunzionamento. Usa pipx.
  • Gli spazi in YAML sono parte della sintassi. Un'indentazione errata produce mapping values are not allowed in this context, e un carattere tab è fatale.
  • Mantieni una sessione SSH attiva su ogni target mentre il playbook configura la hardening di sshd. Ogni caso di lockout che ho risolto per un cliente è avvenuto chiudendo l'ultima sessione "per testare da una sessione pulita".

Step 1: install Ansible on the control machine with pipx, not pip

L'istinto classico è usare pip3 install ansible. Su un'immagine 24.04 completamente nuova, l'installazione fallisce precocemente — Command 'pip3' not found, but can be installed with: sudo apt install python3-pip — e l'uso di pip crea un conflitto critico:

pip3 install ansible
error: externally-managed-environment

× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
    python3-xyz, where xyz is the package you are trying to
    install.

Ubuntu 24.04 gestisce il Python di sistema come gestito esternamente (PEP 668) per evitare che pip entri in conflitto con apt sugli stessi file. Non usare --break-system-packages; il flag è esplicito. La soluzione corretta è pipx, che crea un virtualenv isolato per Ansible e aggiunge i binari al PATH:

sudo apt update && sudo apt install -y pipx
pipx ensurepath
pipx install --include-deps ansible

Apri un nuovo terminale dopo pipx ensurepath per aggiornare il PATH. --include-deps non è un dettaglio: il pacchetto ansible non contiene script per la console — ansible, ansible-playbook e gli altri sono entry point della dipendenza ansible-core — pertanto, senza il flag, pipx rifiuta l'installazione con No apps associated with package ansible or its dependencies. Installa il pacchetto ansible invece del semplice ansible-core: il pacchetto completo include le community collections, e questo playbook utilizza moduli appartenenti a due di esse (ansible.posix e community.general).

ansible --version

Il risultato corretto inizia con una riga simile a ansible [core 2.19.x] e indica la versione di Python utilizzata; qualsiasi versione core recente è sufficiente per questo procedimento. ansible: command not found indica invece che ~/.local/bin non è ancora nel PATH — apri un nuovo terminale o usa source ~/.bashrc.

L'installazione è completata. I target non ricevono alcun pacchetto.

Step 2: Accesso tramite chiave SSH a ogni target

ssh-keygen -t ed25519 -C "ansible control"
ssh-copy-id root@10.0.0.10
ssh-copy-id root@10.0.0.20

Successivamente, verifica l'accesso per ogni host:

ssh root@10.0.0.10 true && echo ok

Quella riga esegue due operazioni: conferma che l'autenticazione tramite chiave funzioni senza password e registra la host key in known_hosts. Esegui subito questo passaggio, poiché Ansible presenta una host key non registrata come un prompt interattivo durante l'esecuzione, il che sembra un blocco del sistema.

Step 3: l'inventory — INI per iniziare, YAML quando cresce

L'inventory è un file di testo che elenca le macchine che Ansible può gestire. Crea inventory.ini in una nuova directory di progetto:

[vps]
web1 ansible_host=10.0.0.10
web2 ansible_host=10.0.0.20

[vps:vars]
ansible_user=root

web1 è un alias scelto da te — è il nome che appare nell'output e il target per --limit web1. ansible_host è l'indirizzo reale. [vps] è un gruppo, e [vps:vars] definisce le variabili per ogni host al suo interno; ansible_user è l'utente con cui Ansible effettua il login. Accanto ad esso, un ansible.cfg per evitare di digitare nuovamente -i:

[defaults]
inventory = inventory.ini

Ansible legge ansible.cfg dalla directory corrente. L'inventory equivalente in YAML — salva il file come inventory.yml e punta ansible.cfg a quel nome — è la scelta preferibile quando ogni host possiede molteplici variabili:

vps:
  hosts:
    web1:
      ansible_host: 10.0.0.10
    web2:
      ansible_host: 10.0.0.20
  vars:
    ansible_user: root

I due formati sono equivalenti. INI è più semplice da consultare con due server; YAML è più scalabile con venti. Scegline uno e non pensarci più.

Step 4: comandi ad-hoc — il segnale verde che conferma il funzionamento

ansible all -m ping

Questo non è ICMP. Il modulo ping è una prova completa: login SSH, copia del modulo, esecuzione di Python sul target e pulizia. Il risultato corretto è verde, con un blocco per ogni host:

web1 | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
}

Il verde SUCCESS indica che l'autenticazione, l'interprete Python e il trasporto funzionano; anche il playbook funzionerà. Il rosso UNREACHABLE! indica che il trasporto è fallito prima dell'esecuzione di qualsiasi modulo; la stringa esatta e la relativa soluzione sono nella sezione failure modes qui sotto. Altri due comandi ad-hoc utili:

ansible all -a "uptime"
ansible all -m apt -a "update_cache=true upgrade=dist" --become

L'uso ad-hoc è indicato per operazioni singole e verifiche. Qualsiasi comando eseguito più di una volta deve essere inserito in un playbook.

Step 5: the first playbook — the new-VPS checklist as code

This is everything you would do by hand in the first ten minutes on a new server. Save it as site.yml:

---
- name: Baseline a fresh Ubuntu VPS
  hosts: vps
  become: true

  vars:
    deploy_user: deploy
    deploy_pubkey: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
    baseline_packages:
      - fail2ban
      - unattended-upgrades
      - ufw
    baseline_services:
      - fail2ban
      - unattended-upgrades

  tasks:
    - name: Create the deploy user
      ansible.builtin.user:
        name: "{{ deploy_user }}"
        groups: sudo
        append: true
        shell: /bin/bash

    - name: Install the deploy user's SSH key
      ansible.posix.authorized_key:
        user: "{{ deploy_user }}"
        key: "{{ deploy_pubkey }}"

    - name: Passwordless sudo for the deploy user
      ansible.builtin.copy:
        dest: /etc/sudoers.d/deploy
        content: "{{ deploy_user }} ALL=(ALL) NOPASSWD:ALL\n"
        mode: "0440"
        validate: /usr/sbin/visudo -cf %s

    - name: Install baseline packages
      ansible.builtin.apt:
        name: "{{ baseline_packages }}"
        state: present
        update_cache: true

    - name: Enable and start baseline services
      ansible.builtin.service:
        name: "{{ item }}"
        state: started
        enabled: true
      loop: "{{ baseline_services }}"

    - name: Harden sshd with a drop-in
      ansible.builtin.copy:
        dest: /etc/ssh/sshd_config.d/00-hardening.conf
        content: |
          PasswordAuthentication no
          KbdInteractiveAuthentication no
          PermitRootLogin prohibit-password
          X11Forwarding no
        mode: "0644"
        validate: /usr/sbin/sshd -t -f %s
      notify: Restart ssh

    - name: Allow OpenSSH through ufw
      community.general.ufw:
        rule: allow
        name: OpenSSH

    - name: Enable ufw with default deny
      community.general.ufw:
        state: enabled
        policy: deny

  handlers:
    - name: Restart ssh
      ansible.builtin.service:
        name: ssh
        state: restarted

The lines worth understanding rather than copying:

Variables live under vars: and are referenced with "{{ deploy_user }}" — quote the whole expression when a value starts with a brace, or the YAML parser misreads it. The lookup('file', ...) reads your public key off the control machine at runtime, so the playbook carries no key material.

The loop. loop: "{{ baseline_services }}" runs the service task once per item, and the output shows each item on its own line. Note that the apt task takes the whole package list in one go instead — one apt transaction is faster and is the preferred pattern for packages; loops are for modules that genuinely act on one thing at a time.

The handler is the concept to internalize. notify: Restart ssh does not mean "restart ssh now". It queues the handler, which runs once at the end of the play, and only if the notifying task actually reported changed. Rerun the playbook tomorrow: the drop-in file is already correct, the copy task reports ok, and sshd is never bounced. The validate: line is the safety on the trigger — sshd checks the file before it replaces the old one, so a typo fails the task instead of breaking the daemon.

PermitRootLogin prohibit-password, not no — deliberately. This playbook logs in as root with a key. prohibit-password shuts off password root logins while keeping yours alive. Once the deploy user is proven (ssh deploy@10.0.0.10 sudo true — the plain address, since web1 is an alias only Ansible knows), switch ansible_user=deploy in the inventory and tighten it to no in a later run. Harden in an order that cannot strand you.

The 00- prefix matters. For most keywords sshd honors the first occurrence it parses, and Ubuntu's sshd_config includes sshd_config.d/*.conf in lexical order before its own body. Ubuntu 24.04 cloud images already ship a 60-cloudimg-settings.conf in that directory, and providers that enable password logins through cloud-init add a 50-cloud-init.conf with PasswordAuthentication yes; naming ours 00-hardening.conf makes it sort first and win over both.

Task order is the firewall safety. Allow OpenSSH runs before Enable ufw with a deny policy — Ansible executes tasks strictly in the order listed, so the hole exists before the wall goes up. fail2ban needs no configuration to be useful here; its Ubuntu defaults watch sshd out of the box, and what the jails actually do — and what to tune — is covered in the fail2ban on Ubuntu 24.04 guide.

Step 6: dry run con --check, quindi esecuzione reale

ansible-playbook site.yml --check

La modalità check si connette, calcola le operazioni che verrebbero eseguite e non modifica nulla. Controlla il conteggio changed= nel PLAY RECAP in fondo: indica il numero di task che modificherebbero ogni host. Una precisazione: la modalità check ha un limite strutturale quando un task successivo dipende dalle modifiche apportate da un task precedente. L'immagine server standard di Ubuntu include già ufw, quindi questo playbook esegue il dry-run correttamente; su un'immagine minimale senza ufw, i task ufw falliscono in modalità check, poiché la modalità check non installa effettivamente il pacchetto e il modulo non trova nulla da chiamare. Questo è un limite dei dry run, non un bug del playbook. Quando il piano è corretto:

ansible-playbook site.yml

Ogni task stampa una riga per host — giallo changed, verde ok — e il riepilogo deve essere:

PLAY RECAP *********************************************************************
web1 : ok=10  changed=9  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0
web2 : ok=10  changed=9  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

Dieci ok corrispondono a fact-gathering, otto task e l'handler. Il tuo changed può differire dal mio di uno o due: l'immagine standard di Ubuntu include già ufw e unattended-upgrades, e fail2ban si avvia non appena apt lo installa, quindi un task può riportare correttamente ok alla prima esecuzione — lo stato dichiarato è già presente. I valori che devono essere pari a zero sono unreachable e failed. Una nota su become: true: è una formalità durante la connessione come root, ma nel momento in cui si imposta ansible_user su deploy, sudo diventa operativo — il file sudoers NOPASSWD installato da questo playbook serve proprio a evitare -K sulla riga di comando. Senza di esso si ottiene Missing sudo password, descritto sotto.

Step 7: eseguilo due volte — cos'è l'improducibilità

Esegui immediatamente lo stesso comando:

web1 : ok=9  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

changed=0 e ok diminuiscono di uno perché l'handler non notificato non è mai stato eseguito. Nulla è stato reinstallato, sshd non è stato riavviato e ufw non è stato toccato. Questo rende il playbook un sistema di audit oltre che di provisioning: aggiungi web3 all'inventory il mese prossimo ed esegui nuovamente il comando — il nuovo server viene configurato, i server esistenti vengono verificati. Un valore changed diverso da zero su un server che non hai toccato indica un drift, e segnala che qualcuno ha modificato manualmente ciò che doveva essere gestito tramite playbook.

Da questo punto in poi, il modello si espande. Il prossimo playbook utile consiste nell'installare WireGuard VPN sullo stesso VPS e nel restringere la regola ufw in modo che SSH risponda solo tramite il tunnel; successivamente, uno che installi Docker and Compose su ogni app server. Quando site.yml supera tre schermate, dividilo in roles — ma non prima.

Modalità di errore e stringhe visualizzate

UNREACHABLE with Permission denied.

web1 | UNREACHABLE! => {
    "changed": false,
    "msg": "Failed to connect to the host via ssh: root@10.0.0.10: Permission denied (publickey).",
    "unreachable": true
}

Il trasporto SSH è fallito prima dell'esecuzione di qualsiasi modulo: ansible_user è errato, la chiave non è stata copiata sull'host o viene offerta la chiave sbagliata. Riproduci l'errore con ssh root@10.0.0.10 semplice, quindi usa ssh -v per vedere quali chiavi sono state offerte. Se l'accesso SSH con password funziona ma Ansible no, hai saltato ssh-copy-id.

Missing sudo password.

web1 | FAILED! => {
    "msg": "Missing sudo password"
}

Hai impostato become: true, ti sei connesso come utente non-root e l'utente richiede una password per sudo. Aggiungi -K (--ask-become-pass) alla riga di comando, oppure configura un'entry sudoers NOPASSWD per l'utente — questo è il motivo per cui il playbook ne installa una per deploy prima di effettuare il passaggio all'utente.

error: externally-managed-environment. Hai eseguito pip contro il Python di sistema su Ubuntu 24.04. Argomento trattato nello step 1: usa pipx invece di pip, e non --break-system-packages.

mapping values are not allowed in this context.

ERROR! Syntax Error while loading YAML.
  mapping values are not allowed in this context

Quasi sempre si tratta di indentazione: una chiave con un livello errato o uno spazio mancante dopo i due punti. Il numero di riga riportato indica l'area vicino all'errore, non l'errore esatto — controlla anche la riga precedente. Il relativo errore found character '\t' that cannot start any token indica la presenza di un tab; YAML non li ammette. Rendi l'uso di ansible-playbook site.yml --syntax-check un automatismo prima di ogni esecuzione e imposta l'editor con indentazione a due spazi per i file YAML.

/usr/bin/python3: not found. Raro sulle immagini standard di Ubuntu 24.04, comune sulle immagini minimal o netboot: l'esecuzione del modulo fallisce perché il target non ha Python. Installa Python tramite il modulo raw, l'unico modulo che non richiede nulla sul target: ansible all -m raw -a "apt-get update && apt-get install -y python3" --become, quindi riesegui il playbook.

FAQ

È necessario installare Ansible sui server gestiti?

No. Ansible è agentless: la macchina di controllo invia piccoli moduli Python tramite SSH, li esegue e li rimuove. Il target richiede solo python3 e l'accesso SSH, entrambi già presenti nelle immagini Ubuntu standard. L'unica installazione necessaria in questa guida avviene sulla macchina di controllo.

Perché Ansible restituisce "Permission denied (publickey)"?

Il blocco UNREACHABLE! con Permission denied (publickey) indica che l'autenticazione SSH è fallita prima dell'esecuzione di Ansible. Verifica che ansible_user nell'inventory corrisponda all'account configurato, che sia stato eseguito ssh-copy-id verso quell'host e che il comando ssh user@host effettui l'accesso senza password. Qualsiasi soluzione valida per il comando ssh standard risolve anche il problema di Ansible, poiché utilizzano lo stesso protocollo di trasporto.

Cosa significa "idempotent" in Ansible?

Un task definisce uno stato desiderato — ad esempio "questo pacchetto è presente" o "questa riga è presente in questo file" — invece di un'azione da eseguire. Se lo stato è già conforme, Ansible non esegue alcuna operazione e riporta ok invece di changed. Per questo motivo, l'esecuzione di un playbook per la seconda volta mostra changed=0 e perché un re-run è un audit sicuro e non una reinstallazione rischiosa.

Devo usare pip o pipx per installare Ansible su Ubuntu 24.04?

pipx. Ubuntu 24.04 gestisce il Python di sistema come gestito esternamente, quindi pip install ansible fallisce con error: externally-managed-environment per design. pipx install --include-deps ansible installa Ansible in un virtualenv isolato e espone ansible, ansible-playbook e gli altri comandi nel PATH in modo pulito.

Qual è la differenza tra i pacchetti ansible e ansible-core?

ansible-core include il motore e solo i moduli ansible.builtin. Il pacchetto ansible include il core insieme alle collection della community — inclusi ansible.posix (il modulo authorized_key) e community.general (il modulo ufw), entrambi utilizzati in questa guida. Inizia con il pacchetto completo; passa a core più collection selezionate solo se necessario.