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) yweb2(web2_server_ip). - Un usuario no root con privilegios
sudoen 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:
| Ruta | Función |
|---|---|
ansible.cfg | Valores por defecto del proyecto |
inventory.ini | Servidores y grupos |
group_vars/all.yml | Variables para todos los servidores |
group_vars/webservers.yml | Variables para los servidores web |
templates/nginx-site.conf.j2 | Plantilla del virtual host de Nginx |
files/site/ | Contenido estático del sitio que se despliega |
baseline.yml, webserver.yml, deploy.yml | Los tres playbooks |
site.yml | Punto 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
nameen el móduloaptinstala 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í que10-hardening.confse impone al50-cloud-init.confque algunas imágenes traen conPasswordAuthentication yes. validateejecutasshd -tcontra el archivo nuevo antes de sustituir el real. Si la sintaxis es incorrecta, la tarea falla y la configuración anterior se mantiene.
AdvertenciaEste playbook desactiva el inicio de sesión SSH con contraseña. Asegúrate de que tu clave SSH funciona para todos los usuarios que necesiten acceso antes de ejecutarlo.
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 defiles/, y la barra final copia el contenido de la carpeta, no la carpeta en sí.mode: u=rwX,g=rX,o=rXda0644a los archivos y0755a los directorios.- La tarea
uripide el sitio desde el propio servidor con la cabeceraHostcorrecta 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.
