Los playbooks cortos que instalan un paquete son un buen comienzo, pero la infraestructura real necesita una estructura que pueda crecer: variables compartidas, plantillas, handlers que reinician servicios solo cuando hace falta y despliegues que nunca tiran todos los servidores a la vez. En este tutorial crearás un proyecto de Ansible pequeño pero completo para servidores Ubuntu 24.04 con tres playbooks: una base de seguridad, la configuración de un servidor web Nginx y un despliegue escalonado del contenido del sitio, todo unido por un único site.yml.

Requisitos previos

Para seguir esta guía necesitas:

  • Un nodo de control con Ansible instalado y acceso SSH con clave a tus servidores, como se explica en la guía Cómo instalar y usar Ansible en Ubuntu 24.04.
  • Dos servidores gestionados con Ubuntu 24.04, por ejemplo VPS de CubePath, llamados web1 (web1_server_ip) y web2 (web2_server_ip).
  • Un usuario no root con privilegios sudo en ambos servidores (your_user) que ya pueda iniciar sesión con clave SSH.
  • Un nombre de dominio, your_domain, si quieres acceder al sitio por nombre. Para las pruebas basta con la dirección IP.

Paso 1: Crear la estructura del proyecto

Una estructura predecible hace que el proyecto sea fácil de recorrer y permite que Ansible encuentre variables y plantillas automáticamente. Ansible carga group_vars/<grupo>.yml para cada host de ese grupo, y busca los orígenes de template y copy en templates/ y files/, junto al playbook.

Crea los directorios:

mkdir -p ~/ansible-web/{group_vars,templates,files/site}
cd ~/ansible-web

El proyecto terminado tendrá este aspecto:

RutaFunción
ansible.cfgValores por defecto del proyecto
inventory.iniServidores y grupos
group_vars/all.ymlVariables para todos los servidores
group_vars/webservers.ymlVariables para los servidores web
templates/nginx-site.conf.j2Plantilla del virtual host de Nginx
files/site/Contenido estático del sitio que se despliega
baseline.yml, webserver.yml, deploy.ymlLos tres playbooks
site.ymlPunto de entrada que los ejecuta todos

Crea el inventario:

nano inventory.ini
[webservers]
web1 ansible_host=web1_server_ip
web2 ansible_host=web2_server_ip

[all:vars]
ansible_user=your_user

Crea el archivo de configuración:

nano ansible.cfg
[defaults]
inventory = inventory.ini
interpreter_python = auto_silent

[ssh_connection]
pipelining = True

Comprueba que los dos servidores responden:

ansible all -m ping

Ambos hosts deben devolver "ping": "pong".

Paso 2: Definir variables compartidas

Las variables sacan de los playbooks los valores que cambian entre proyectos. Crea las variables que se aplican a todos los servidores:

nano group_vars/all.yml
---
server_timezone: Europe/Madrid

base_packages:
  - curl
  - htop
  - vim
  - unattended-upgrades

Después, las variables de los servidores web:

nano group_vars/webservers.yml
---
server_name: your_domain
web_root: /var/www/your_domain

Confirma que Ansible combina ambos archivos para web1:

ansible-inventory --host web1
{
    "ansible_host": "web1_server_ip",
    "ansible_user": "your_user",
    "base_packages": [
        "curl",
        "htop",
        "vim",
        "unattended-upgrades"
    ],
    "server_name": "your_domain",
    "server_timezone": "Europe/Madrid",
    "web_root": "/var/www/your_domain"
}

Paso 3: Escribir un playbook de base de seguridad

El playbook base aplica los ajustes que debe tener cualquier servidor: paquetes comunes, zona horaria, bastionado de SSH y cortafuegos. Introduce dos funciones importantes: validate, que prueba un archivo de configuración antes de ponerlo en su sitio, y los handlers, que reinician un servicio solo cuando su configuración ha cambiado de verdad.

nano baseline.yml
---
- name: Aplicar la configuración base
  hosts: all
  become: true

  tasks:
    - name: Instalar los paquetes base
      ansible.builtin.apt:
        name: "{{ base_packages }}"
        state: present
        update_cache: true
        cache_valid_time: 3600

    - name: Configurar la zona horaria
      community.general.timezone:
        name: "{{ server_timezone }}"

    - name: Bastionar el demonio SSH
      ansible.builtin.copy:
        dest: /etc/ssh/sshd_config.d/10-hardening.conf
        content: |
          PasswordAuthentication no
          KbdInteractiveAuthentication no
          PermitRootLogin prohibit-password
        owner: root
        group: root
        mode: "0644"
        validate: /usr/sbin/sshd -t -f %s
      notify: Reiniciar SSH

    - name: Permitir SSH en UFW
      community.general.ufw:
        rule: allow
        name: OpenSSH

    - name: Activar UFW denegando el tráfico entrante por defecto
      community.general.ufw:
        state: enabled
        policy: deny
        direction: incoming

  handlers:
    - name: Reiniciar SSH
      ansible.builtin.systemd_service:
        name: ssh
        state: restarted

Algunos detalles que conviene conocer:

  • Pasar una lista a name en el módulo apt instala todos los paquetes en una sola transacción, mucho más rápido que un bucle.
  • Los ajustes de SSH van en un archivo en /etc/ssh/sshd_config.d/. sshd usa el primer valor que lee para cada opción, y los archivos se leen en orden alfabético, así que 10-hardening.conf se impone al 50-cloud-init.conf que algunas imágenes traen con PasswordAuthentication yes.
  • validate ejecuta sshd -t contra el archivo nuevo antes de sustituir el real. Si la sintaxis es incorrecta, la tarea falla y la configuración anterior se mantiene.

Ejecútalo. -K pide tu contraseña de sudo:

ansible-playbook baseline.yml -K

En la primera ejecución, el handler se ejecuta después de todas las tareas, una vez por host:

RUNNING HANDLER [Reiniciar SSH] ************************************************
changed: [web1]
changed: [web2]

PLAY RECAP *********************************************************************
web1    : ok=7    changed=5    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
web2    : ok=7    changed=5    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Comprueba que el acceso con contraseña está desactivado en un servidor:

ssh -t your_user@web1_server_ip "sudo sshd -T | grep -i passwordauthentication"
passwordauthentication no

Ejecuta el playbook una segunda vez. Todas las tareas informan de ok y el handler no se ejecuta, porque no ha cambiado nada.

Paso 4: Configurar Nginx con una plantilla y handlers

Las plantillas permiten que un mismo archivo sirva para todos los servidores: Ansible sustituye los marcadores Jinja2 con las variables de cada host. Crea la plantilla del virtual host:

nano templates/nginx-site.conf.j2
# Gestionado por Ansible. Los cambios locales se sobrescribirán.
server {
    listen 80;
    listen [::]:80;
    server_name {{ server_name }};

    root {{ web_root }};
    index index.html;

    access_log /var/log/nginx/{{ server_name }}.access.log;
    error_log  /var/log/nginx/{{ server_name }}.error.log;

    location / {
        try_files $uri $uri/ =404;
    }
}

Ahora crea el playbook del servidor web:

nano webserver.yml
---
- name: Configurar los servidores web Nginx
  hosts: webservers
  become: true

  tasks:
    - name: Instalar Nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true
        cache_valid_time: 3600

    - name: Crear la raíz web
      ansible.builtin.file:
        path: "{{ web_root }}"
        state: directory
        owner: www-data
        group: www-data
        mode: "0755"

    - name: Desplegar el virtual host
      ansible.builtin.template:
        src: nginx-site.conf.j2
        dest: "/etc/nginx/sites-available/{{ server_name }}.conf"
        owner: root
        group: root
        mode: "0644"
      notify: Recargar Nginx

    - name: Activar el virtual host
      ansible.builtin.file:
        src: "/etc/nginx/sites-available/{{ server_name }}.conf"
        dest: "/etc/nginx/sites-enabled/{{ server_name }}.conf"
        state: link
      notify: Recargar Nginx

    - name: Desactivar el sitio por defecto
      ansible.builtin.file:
        path: /etc/nginx/sites-enabled/default
        state: absent
      notify: Recargar Nginx

    - name: Permitir HTTP y HTTPS en UFW
      community.general.ufw:
        rule: allow
        port: "{{ item }}"
        proto: tcp
      loop:
        - "80"
        - "443"

    - name: Arrancar Nginx y activarlo en el arranque
      ansible.builtin.systemd_service:
        name: nginx
        state: started
        enabled: true

  handlers:
    - name: Probar la configuración de Nginx
      ansible.builtin.command: nginx -t
      changed_when: false
      listen: Recargar Nginx

    - name: Recargar el servicio Nginx
      ansible.builtin.systemd_service:
        name: nginx
        state: reloaded
      listen: Recargar Nginx

Tres tareas notifican Recargar Nginx, pero los handlers se ejecutan una sola vez por host al final del play. Los dos handlers escuchan el mismo tema y se ejecutan en el orden en que están definidos, así que nginx -t comprueba primero la configuración completa. Si la prueba falla, el play falla en ese host y la recarga no llega a ocurrir, de modo que una plantilla rota no puede tirar el sitio en marcha.

Ejecuta el playbook:

ansible-playbook webserver.yml -K
RUNNING HANDLER [Probar la configuración de Nginx] *****************************
ok: [web1]
ok: [web2]

RUNNING HANDLER [Recargar el servicio Nginx] ***********************************
changed: [web1]
changed: [web2]

Revisa el archivo generado en uno de los servidores:

ansible web1 -b -K -m ansible.builtin.command -a "cat /etc/nginx/sites-enabled/your_domain.conf"

La salida muestra la plantilla con server_name y root ya rellenados.

Paso 5: Publicar el contenido del sitio sin cortes

Si despliegas en todos los servidores a la vez, una versión defectuosa rompe todos los servidores en el mismo momento. La palabra clave serial hace que Ansible ejecute el play completo en un lote de hosts antes de pasar al siguiente. Combinada con una comprobación de salud y max_fail_percentage: 0, el despliegue se detiene en el primer servidor que falla y el resto sigue sirviendo la versión anterior.

Crea una página sencilla para desplegar:

nano files/site/index.html
<!DOCTYPE html>
<html>
  <head><title>your_domain</title></head>
  <body><h1>Versión 1</h1></body>
</html>

Crea el playbook de despliegue:

nano deploy.yml
---
- name: Desplegar el contenido del sitio servidor a servidor
  hosts: webservers
  become: true
  serial: 1
  max_fail_percentage: 0

  tasks:
    - name: Copiar los archivos del sitio
      ansible.builtin.copy:
        src: site/
        dest: "{{ web_root }}/"
        owner: www-data
        group: www-data
        mode: u=rwX,g=rX,o=rX

    - name: Comprobar que el sitio responde con HTTP 200
      ansible.builtin.uri:
        url: http://127.0.0.1/
        headers:
          Host: "{{ server_name }}"
        status_code: 200
      register: health
      until: health.status == 200
      retries: 5
      delay: 3
  • src: site/ se resuelve dentro de files/, y la barra final copia el contenido de la carpeta, no la carpeta en sí.
  • mode: u=rwX,g=rX,o=rX da 0644 a los archivos y 0755 a los directorios.
  • La tarea uri pide el sitio desde el propio servidor con la cabecera Host correcta y lo reintenta hasta cinco veces antes de fallar.

Si los servidores están detrás de un balanceador de carga, este es también el sitio donde sacarías cada servidor del pool antes de copiar y lo volverías a añadir después de la comprobación de salud.

Ejecuta el despliegue:

ansible-playbook deploy.yml -K

La salida muestra dos plays separados, uno por servidor:

PLAY [Desplegar el contenido del sitio servidor a servidor] ********************

TASK [Copiar los archivos del sitio] *******************************************
changed: [web1]

TASK [Comprobar que el sitio responde con HTTP 200] ****************************
ok: [web1]

PLAY [Desplegar el contenido del sitio servidor a servidor] ********************

TASK [Copiar los archivos del sitio] *******************************************
changed: [web2]
...

Compruébalo desde el nodo de control:

curl -H "Host: your_domain" http://web2_server_ip/
...
  <body><h1>Versión 1</h1></body>
...

Para publicar una versión nueva, edita files/site/index.html y vuelve a ejecutar deploy.yml.

Paso 6: Unirlo todo con site.yml

Un único punto de entrada facilita montar un servidor nuevo desde cero o volver a aplicarlo todo. Crea site.yml:

nano site.yml
---
- name: Base
  ansible.builtin.import_playbook: baseline.yml
  tags: baseline

- name: Servidores web
  ansible.builtin.import_playbook: webserver.yml
  tags: web

- name: Despliegue
  ansible.builtin.import_playbook: deploy.yml
  tags: deploy

Las etiquetas de import_playbook se aplican a todas las tareas de ese playbook, así que puedes ejecutar solo una parte del proyecto. Algunas invocaciones habituales:

ansible-playbook site.yml -K
ansible-playbook site.yml -K --tags web
ansible-playbook site.yml -K --limit web2

La primera lo ejecuta todo, la segunda solo la configuración de Nginx y la tercera lo aplica todo únicamente a web2, por ejemplo después de añadir un servidor nuevo al inventario.

Antes de tocar producción, previsualiza el efecto con el modo check y los diffs:

ansible-playbook site.yml -K --check --diff

--diff muestra las líneas exactas que cambiarían en los archivos generados con plantillas o copiados.

Solución de problemas

Could not find or access 'nginx-site.conf.j2': Ansible busca las plantillas en un directorio templates/ junto al playbook. Ejecuta el comando desde la raíz del proyecto y mantén los playbooks y templates/ en la misma carpeta.

El handler de SSH se ejecutó pero el acceso con contraseña sigue funcionando: otro archivo de /etc/ssh/sshd_config.d/ va antes que 10-hardening.conf en orden alfabético, o /etc/ssh/sshd_config define la opción antes de su línea Include. Revisa el valor efectivo con sudo sshd -T | grep -i passwordauthentication.

Falla Probar la configuración de Nginx: ejecuta sudo nginx -t en el servidor para ver la línea exacta, corrige la plantilla y vuelve a ejecutar el playbook. La configuración anterior sigue activa porque la recarga no se hizo.

La comprobación de salud falla con Status code was 404: la cabecera Host no coincide con server_name y Nginx sirve otro sitio, o web_root no tiene index.html. Revisa group_vars/webservers.yml y el contenido de files/site/.

Conclusión

Has creado un proyecto de Ansible con variables compartidas, un playbook base que valida sus cambios, un playbook de Nginx basado en una plantilla y handlers, y un despliegue escalonado que se detiene en la primera comprobación de salud fallida. Cada playbook es idempotente y se puede ejecutar por separado o mediante site.yml.

Como siguientes pasos, convierte cada playbook en un rol con ansible-galaxy role init, guarda secretos como tokens de API con ansible-vault y añade HTTPS con Certbot cuando tu dominio apunte a los servidores.