SSD Nodes Learn Hosting plans →
Guías Matt ConnorPor Matt Connor · Actualizado 2026-08-07

Tutorial de Ansible: tu primer playbook para un VPS

Instala Ansible con pipx en Ubuntu 24.04, crea el inventario y un playbook para proteger un VPS nuevo. Incluye soluciones para Permission denied y errores de sudo.

Lo que va a crear

Una máquina de control con Ansible instalado y uno o varios VPS nuevos con Ubuntu 24.04 que sólo contienen la imagen estándar. Al final tendrá un archivo de inventario que identifica los servidores, un ping ad hoc que demuestra que la autenticación funciona de extremo a extremo y un playbook que ejecuta como código toda la lista de comprobación para un VPS nuevo: un usuario de despliegue con su clave SSH, sshd reforzado, fail2ban, actualizaciones desatendidas y un firewall que permite OpenSSH antes de denegar todo lo demás. Puede ejecutarlo contra un servidor o contra veinte. Ejecútelo dos veces. En la segunda ejecución no cambiará nada. Ese es el objetivo.

Después de quince años aprovisionando VPS, puedo describir el patrón real: todo el mundo configura manualmente los primeros cinco servidores y después pierde un fin de semana con el sexto porque nadie recuerda qué hizo en los cinco primeros. Esta guía amplía la información sobre la administración de varios servidores Linux. Continúe cuando se descubra escribiendo el mismo apt install en tres terminales.

Qué es realmente Ansible, en un párrafo

Ansible no utiliza agentes. No hay ningún daemon que instalar en los servidores que administra: la máquina de control se conecta mediante SSH estándar, copia un pequeño módulo de Python en el destino, lo ejecuta, lee el JSON que imprime y lo elimina. Lo único que necesita un destino es python3, que ya está incluido en todas las imágenes estándar de Ubuntu. El término importante es idempotente, y significa algo sencillo: una tarea describe un estado, no una acción. state: present para un paquete significa «asegurarse de que esté instalado», no «ejecutar el instalador». Si el estado ya se cumple, Ansible no modifica nada e informa de ello como ok en lugar de changed. Esa propiedad es el producto completo, permite volver a ejecutar un playbook de forma segura, y las ejecuciones seguras son las que convierten un script de shell en infraestructura.

Requisitos previos y problemas habituales desde el principio

  • Una máquina de control: su portátil o una VPS pequeña. Se presupone Ubuntu 24.04; macOS funciona de forma idéntica después de instalar pipx mediante Homebrew.
  • Una o varias VPS de destino con Ubuntu 24.04 en KVM, accesibles como root. No se instala nada en ellas.
  • Autenticación mediante clave SSH en todos los destinos. Ansible utiliza exactamente el mismo método de autenticación que su comando ssh; si ssh root@host solicita una contraseña, Ansible falla.
  • En Ubuntu 24.04, pip install ansible falla con error: externally-managed-environment. Es una política deliberada de la distribución, no un error. Use pipx.
  • Los espacios en blanco de YAML forman parte de la sintaxis. Una sangría incorrecta produce mapping values are not allowed in this context, y cualquier carácter de tabulación provoca un error irrecuperable.
  • Mantenga abierta una sesión SSH funcional en cada destino mientras el playbook refuerza la configuración de sshd. Todos los bloqueos que he ayudado a resolver a clientes se produjeron después de cerrar la última sesión «para probar desde una sesión limpia».

Paso 1: instalar Ansible en la máquina de control con pipx, no con pip

El procedimiento habitual es pip3 install ansible. En una imagen 24.04 completamente nueva, ese comando falla un paso antes, en Command 'pip3' not found, but can be installed with: sudo apt install python3-pip, y la instalación de pip sólo lleva al siguiente bloqueo:

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 marca el Python del sistema como gestionado externamente (PEP 668), por lo que pip no puede competir con apt por los mismos archivos. No use --break-system-packages; el nombre de la opción describe exactamente su efecto. La solución correcta es pipx, que proporciona a Ansible su propio virtualenv aislado y coloca los binarios en PATH:

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

Abra un shell nuevo después de pipx ensurepath para que se aplique el cambio de PATH. --include-deps no es decorativo: el paquete ansible no proporciona scripts de consola propios, ansible, ansible-playbook y el resto son entry points de su dependencia ansible-core, por lo que, sin esa opción, pipx rechaza la instalación con No apps associated with package ansible or its dependencies. Instale el paquete ansible, no ansible-core sin más: el paquete completo incluye las colecciones de la comunidad, y este playbook usa módulos de dos de ellas (ansible.posix y community.general).

ansible --version

El resultado correcto comienza con una línea similar a ansible [core 2.19.x] e indica el Python con el que se ejecuta; cualquier versión actual del núcleo es válida para todo lo descrito aquí. ansible: command not found significa que ~/.local/bin todavía no está en PATH: abra un shell nuevo o ejecute source ~/.bashrc.

Eso completa toda la instalación. Los equipos de destino no reciben nada.

Paso 2: Acceso mediante clave SSH a cada destino

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

Después, compruébelo una vez por host:

ssh root@10.0.0.10 true && echo ok

Esa línea cumple dos funciones: confirma que la autenticación mediante clave funciona sin contraseña y registra la clave de host en known_hosts. Hágalo ahora, porque Ansible muestra una clave de host no registrada como una solicitud interactiva en mitad de una ejecución, lo que parece exactamente un bloqueo.

Paso 3: el inventario, primero INI y YAML cuando crezca

El inventario es un archivo de texto que enumera las máquinas que Ansible puede administrar. Cree inventory.ini en un directorio de proyecto nuevo:

[vps]
web1 ansible_host=10.0.0.10
web2 ansible_host=10.0.0.20

[vps:vars]
ansible_user=root

web1 es un alias que elige usted. Es lo que aparece en la salida y lo que usa como destino con --limit web1. ansible_host es la dirección real. [vps] es un grupo y [vps:vars] establece variables para todos los hosts que contiene; ansible_user es el usuario con el que Ansible inicia sesión. Añada junto a él un ansible.cfg para no tener que escribir -i otra vez:

[defaults]
inventory = inventory.ini

Ansible lee ansible.cfg desde el directorio actual. El mismo inventario en YAML, guardado como inventory.yml y especificado mediante ansible.cfg en su lugar, será la opción preferible cuando cada host tenga varias variables:

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

Son equivalentes. INI es más fácil de revisar de un vistazo con dos servidores; YAML escala mejor con veinte. Elija uno y deje de preocuparse por ello.

Paso 4: comandos ad hoc, el pong verde que confirma que todo funciona

ansible all -m ping

Esto no es ICMP. El módulo ping es una prueba completa: inicio de sesión por SSH, copia del módulo, ejecución de Python en el destino y limpieza. El resultado correcto es verde, con un bloque por host:

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

El resultado verde de SUCCESS indica que la autenticación, el intérprete de Python y el transporte funcionan. El playbook también funcionará. El resultado rojo de UNREACHABLE! indica que el transporte falló antes de ejecutar cualquier módulo. La cadena exacta y la solución se describen en la sección de modos de fallo siguiente. Conviene conocer otros dos comandos ad hoc:

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

Los comandos ad hoc sirven para acciones puntuales y comprobaciones. Todo lo que vaya a ejecutar dos veces debe estar en un playbook.

Paso 5: el primer playbook, la lista de comprobación de un VPS nuevo como código

Esto es todo lo que haría manualmente durante los primeros diez minutos en un servidor nuevo. Guárdelo como 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

Estas son las líneas que conviene entender en lugar de copiarlas sin más:

Las variables se definen en vars: y se referencian con "{{ deploy_user }}". Entrecomille la expresión completa cuando un valor empiece por una llave; de lo contrario, el analizador YAML puede interpretarla mal. lookup('file', ...) lee la clave pública de la máquina de control durante la ejecución, por lo que el playbook no contiene material de clave.

El bucle. loop: "{{ baseline_services }}" ejecuta la tarea del servicio una vez por elemento, y la salida muestra cada elemento en su propia línea. Tenga en cuenta que la tarea apt recibe toda la lista de paquetes de una vez. Una única transacción de apt es más rápida y es el patrón recomendado para los paquetes; los bucles se usan con módulos que realmente actúan sobre un elemento cada vez.

El handler es el concepto que debe asimilar. notify: Restart ssh no significa «reiniciar ssh ahora». Pone el handler en cola, y este se ejecuta una vez al final del play, pero solo si la tarea que lo notifica ha informado realmente de changed. Si vuelve a ejecutar el playbook mañana, el archivo drop-in ya será correcto, la tarea de copia informará de ok y sshd no se reiniciará. La línea validate: es la protección del activador: sshd comprueba el archivo antes de sustituir el anterior, por lo que un error tipográfico hace que falle la tarea en lugar de romper el daemon.

PermitRootLogin prohibit-password, no no, de forma deliberada. Este playbook inicia sesión como root con una clave. prohibit-password desactiva los inicios de sesión de root mediante contraseña y mantiene activo el suyo. Cuando haya verificado el usuario de despliegue (ssh deploy@10.0.0.10 sudo true, la dirección sin alias, ya que web1 es solo un alias que conoce Ansible), cambie ansible_user=deploy en el inventario y ajústelo a no en una ejecución posterior. Endurezca el sistema en un orden que no pueda dejarle sin acceso.

El prefijo 00- es importante. Para la mayoría de las palabras clave, sshd respeta la primera aparición que analiza, y el sshd_config de Ubuntu incluye sshd_config.d/*.conf en orden lexicográfico antes de su propio contenido. Las imágenes cloud de Ubuntu 24.04 ya incluyen un 60-cloudimg-settings.conf en ese directorio, y los proveedores que habilitan los inicios de sesión mediante contraseña con cloud-init añaden un 50-cloud-init.conf con PasswordAuthentication yes. Dar a nuestro archivo el nombre 00-hardening.conf hace que aparezca primero y prevalezca sobre ambos.

El orden de las tareas es la protección del firewall. Allow OpenSSH se ejecuta antes que Enable ufw con una política de denegación. Ansible ejecuta las tareas estrictamente en el orden en que aparecen, por lo que la excepción existe antes de que se aplique el bloqueo. fail2ban no necesita configuración para ser útil en este caso; sus valores predeterminados de Ubuntu supervisan sshd desde el principio. El funcionamiento de las jaulas y los ajustes recomendados se explican en la guía de fail2ban en Ubuntu 24.04.

Paso 6: ejecutar una prueba con --check y después ejecutarlo de verdad

ansible-playbook site.yml --check

El modo de comprobación se conecta, calcula lo que haría y no modifica nada. Lea el recuento de changed= en el PLAY RECAP de la parte inferior. Ese es el número de tareas que modificarían cada host. Hay una limitación importante: el modo de comprobación tiene un límite estructural cuando una tarea posterior depende de los cambios de una tarea anterior. La imagen de servidor estándar de Ubuntu ya incluye ufw, por lo que este playbook se ejecuta correctamente en modo de prueba. En una imagen mínima que no lo incluya, las tareas de ufw fallan en modo de comprobación porque este modo nunca instala realmente el paquete y el módulo no tiene nada que invocar. Es una limitación de las pruebas, no un error de su playbook. Cuando el plan sea correcto:

ansible-playbook site.yml

Cada tarea muestra una línea por host: changed en amarillo y ok en verde. El resumen debe mostrar:

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

Esos ok corresponden a la recopilación de datos más ocho tareas y el handler. Su changed puede diferir del mío en uno o dos elementos: la imagen estándar de Ubuntu ya incluye ufw y unattended-upgrades, y fail2ban se inicia en cuanto apt lo instala. Por eso, una tarea puede informar legítimamente ok en su primera ejecución: declara un estado que ya estaba aplicado. Los valores que deben ser cero son unreachable y failed. Sobre become: true: es una formalidad mientras se conecta como root, pero en cuanto cambie ansible_user a deploy, sudo será real. El archivo sudoers con NOPASSWD que instala este playbook es precisamente lo que evita que -K aparezca en la línea de comandos. Sin él obtendrá Missing sudo password, como se explica a continuación.

Paso 7: ejecútelo dos veces para ver la idempotencia

Vuelva a ejecutar inmediatamente el mismo comando:

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

changed=0 y ok disminuyeron en uno porque el controlador no notificado nunca se ejecutó. No se reinstaló nada, sshd no se reinició y ufw no se modificó. Esto hace que el playbook sea tanto una auditoría como un aprovisionador: añada web3 al inventario el próximo mes y vuelva a ejecutarlo; el equipo nuevo se configurará y los equipos existentes se verificarán. Un valor distinto de cero en changed en un equipo que no ha tocado indica una desviación de configuración. Esto significa que alguien modificó manualmente lo que debería haberse modificado en el playbook.

A partir de aquí, el patrón se amplía. El siguiente playbook que conviene escribir configura una VPN WireGuard en el mismo VPS y restringe la regla de ufw para que SSH responda sólo a través del túnel. Después, escriba otro que instale Docker y Compose en cada servidor de aplicaciones. Cuando site.yml supere tres pantallas, divídalo en roles, pero no antes.

Modos de fallo y mensajes que verá

UNREACHABLE con 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
}

El transporte SSH falló antes de ejecutar cualquier módulo: ansible_user es incorrecto, la clave nunca se copió en ese host o se está ofreciendo la clave equivocada. Reprodúzcalo con ssh root@10.0.0.10 sin opciones adicionales y use ssh -v para ver qué claves se ofrecieron. Si SSH con contraseña funciona pero Ansible no, omitió ssh-copy-id.

Falta la contraseña de sudo.

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

Estableció become: true, se conectó como un usuario que no es root y ese usuario necesita una contraseña para sudo. Añada -K (--ask-become-pass) a la línea de comandos o proporcione al usuario una entrada NOPASSWD en sudoers. Precisamente por eso el playbook instala una para deploy antes de cambiar a ese usuario.

error: externally-managed-environment. Ejecutó pip contra el Python del sistema en Ubuntu 24.04. Esto se explica en el paso 1: use pipx, no pip ni --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

Casi siempre se debe a la indentación: una clave está a una profundidad incorrecta o falta un espacio después de dos puntos. El número de línea indicado apunta cerca del error, no necesariamente al error. Revise también la línea anterior. Su mensaje relacionado, found character '\t' that cannot start any token, indica que se introdujo una tabulación; YAML las prohíbe. Convierta ansible-playbook site.yml --syntax-check en un paso habitual antes de cada ejecución y configure el editor para usar una indentación de dos espacios en YAML.

/usr/bin/python3: not found. Es poco habitual en imágenes estándar de Ubuntu 24.04, pero frecuente en imágenes mínimas o de netboot: la ejecución del módulo falla porque el destino no tiene Python. Instálelo con el módulo raw, el único módulo que no necesita nada en el sistema remoto: ansible all -m raw -a "apt-get update && apt-get install -y python3" --become. Después, vuelva a ejecutar el playbook.

FAQ

¿Necesito instalar Ansible en los servidores que administra?

No. Ansible no necesita agentes: la máquina de control envía pequeños módulos de Python mediante SSH, los ejecuta y los elimina. El destino sólo necesita python3 y acceso SSH, que las imágenes estándar de Ubuntu ya incluyen. La única instalación de toda esta guía se realiza en la máquina de control.

¿Por qué Ansible muestra "Permission denied (publickey)"?

El bloque UNREACHABLE! con Permission denied (publickey) indica que la autenticación SSH falló antes de que Ansible ejecutara nada. Compruebe que ansible_user del inventario coincida con la cuenta que configuró realmente, que haya ejecutado ssh-copy-id en ese host y que ssh user@host se conecte sin contraseña. Cualquier solución para el comando ssh normal también corrige Ansible, porque ambos usan el mismo transporte.

¿Qué significa idempotente en Ansible?

Una tarea declara un estado deseado, como "este paquete está presente" o "esta línea está en este archivo", en lugar de una acción que se debe realizar. Si el estado ya se cumple, Ansible no hace nada e informa de ok en lugar de changed. Por eso, al ejecutar un playbook dos veces, la segunda ejecución muestra changed=0, y una nueva ejecución es una auditoría segura, no una reinstalación arriesgada.

¿Debo usar pip o pipx para instalar Ansible en Ubuntu 24.04?

pipx. Ubuntu 24.04 marca el Python del sistema como administrado externamente, por lo que pip install ansible falla con error: externally-managed-environment de forma intencionada. pipx install --include-deps ansible instala Ansible en un virtualenv aislado y expone ansible, ansible-playbook y el resto de comandos en el PATH de forma limpia.

¿Cuál es la diferencia entre los paquetes ansible y ansible-core?

ansible-core es el motor e incluye sólo los módulos ansible.builtin. El paquete ansible incluye core y las colecciones comunitarias seleccionadas, entre ellas ansible.posix (el módulo authorized_key) y community.general (el módulo ufw), que se usan en esta guía. Empiece con el paquete completo y reduzca después a core más las colecciones seleccionadas manualmente sólo si tiene un motivo para hacerlo.