
🌐 También en: English · Deutsch · Français
Esta es la primera parte de una pequeña serie que recorre el código de Ansible que hay detrás de este blog y de dos servidores Nextcloud – no variable por variable, sino al nivel de «qué pasa, en qué orden y por qué está construido así». El código es público: github.com/aptupgrademe/www_k3s. Toda la serie describe la versión v3.1.0 (octubre de 2026), y cada enlace a un archivo apunta a esa versión – los roles seguirán cambiando, los enlaces seguirán mostrando el código del que habla el texto.
Hace unos meses publiqué el stack como código abierto, y desde entonces el repositorio ha madurado: una ronda de calidad hizo idempotente cada rol, se añadieron nueve capas de defensa, y cada rol empieza ahora con un comentario que lo explica a quien nunca lo ha visto. Lo que faltaba era la visión de conjunto. Quien abre hoy el repositorio encuentra 34 roles, cuatro playbooks, unas 10.000 líneas de tareas y otras 12.000 de plantillas. ¿Por dónde se empieza?
Por aquí. Este artículo es el mapa. Explica cómo está organizado el repositorio, cómo un mismo conjunto de roles sirve a dos aplicaciones muy distintas, qué pasa de verdad cuando escribo ansible-playbook y qué redes de seguridad atrapan mis errores antes de que los vea un servidor. Las partes siguientes acercan el zoom, de lo general a lo concreto – y dos de ellas atraviesan todo para un único tema: la seguridad y la monitorización.
🗺️ La visita de un vistazo
| Parte | Tema | Roles |
|---|---|---|
| 1 | El armazón – repositorio, inventario, playbooks, una ejecución de principio a fin (este artículo) | playbooks, common_version_check, los roles de verificación |
| 2 | La plataforma – elegir el servidor, sistema base y Kubernetes, comunes a ambos stacks | *_packages, common_sysctl, common_msmtp, common_dnf_automatic, common_logrotate, common_k3s, common_calico |
| 3 | El blog – WordPress en K3s | blog_k3s_deploy, blog_wordpress, blog_visitor_stats, blog_verify |
| 4 | Nextcloud – Nextcloud, Collabora, base de datos y caché | next_mariadb, next_redis, next_k3s_deploy, next_config, nextcloud_verify |
| 5 | Seguridad informática – solo los roles de seguridad, para ambos stacks | common_ssh, common_firewall, common_cis_hardening, common_selinux, common_auditd, common_aide_refresh, common_rkhunter, common_clamav, common_fail2ban |
| 6 | Monitorización – métricas, paneles, alarmas, perro guardián | los exporters, common_prometheus, common_grafana, common_monit |
Así que, te interese la aplicación que te interese, lees como mucho cinco artículos: este, la plataforma, tu aplicación, la seguridad y la monitorización.
El objetivo de la serie es práctico: alguien con conocimientos básicos de informática que la lea con atención debería entender lo suficiente para operar un entorno así de forma segura – no solo cómo funciona el código, sino por qué está cada pieza, dónde mirar y qué hacer cuando algo falla. Por eso cada parte termina con una sección «En operación»: los comandos, la rutina, los correos de alarma y los primeros pasos cuando algo se rompe.
🧩 Un repositorio, dos aplicaciones
Desde este repositorio se gestionan tres servidores: el blog (WordPress) y dos instancias de Nextcloud. A primera vista tienen poco en común – un blog en PHP con MariaDB en un contenedor, y una nube de archivos con suite ofimática, su base de datos en el host y unos cientos de gigabytes de datos de usuarios. Por debajo son casi idénticos: el mismo AlmaLinux 9, el mismo SSH endurecido, el mismo cortafuegos, el mismo K3s de un solo nodo con el mismo ingress, la misma monitorización, los mismos escaneos nocturnos.
Así es exactamente como se reparten los roles. El prefijo de un rol dice quién lo usa:
| Prefijo | Lo usa | Cantidad | Ejemplos |
|---|---|---|---|
common_* | ambos stacks | 23 | common_ssh, common_firewall, common_k3s, common_grafana |
blog_* | solo el blog | 5 | blog_k3s_deploy, blog_wordpress |
next_* / nextcloud_verify | solo Nextcloud | 6 | next_mariadb, next_config |
Dos tercios del código son compartidos. Una corrección en el cortafuegos o en el endurecimiento de SSH llega a los tres servidores en la siguiente ejecución, y lo aprendido en un Nextcloud protege también al blog. El precio: un rol compartido tiene que saber dónde se ejecuta – cómo lo averigua es una de las ideas centrales del repositorio y tiene su propia sección más abajo.
📁 Cómo está organizado el repositorio
www_k3s/
├── blog.yml full run for the blog
├── blog-update.yml updates only (plugins, core, rebuilt images)
├── nextcloud-k3s.yml full run for Nextcloud
├── nextcloud-update.yml updates only (rebuilt images, occ upgrade, apps)
├── ansible.cfg how Ansible connects and behaves
├── requirements.yml Ansible collections (kubernetes.core, ...)
├── inventory/
│ ├── hosts.yml which host belongs to which group
│ ├── group_vars/all/ values for every host (main.yml, versions.yml)
│ └── host_vars/<host>/ values for one host (vars.yml, vault.yml)
├── roles/<role>/
│ ├── tasks/ what the role does
│ ├── handlers/ what runs once, at the end, if something changed
│ ├── templates/ Jinja templates (config files, manifests)
│ ├── files/ files copied as they are (scripts)
│ ├── defaults/ the role's own default values
│ └── meta/ dependencies + argument_specs (declared inputs)
├── scripts/ drift check, backup and restore helpers
├── tests/ K8s apply test, update scenario test
└── docs/ operations manuals (DE/EN/FR)Nada exótico – es la estructura estándar de Ansible. Conviene conocer dos puntos, porque el resto de la serie se apoya en ellos. Primero, los playbooks son delgados: casi no contienen tareas, solo la lista de roles en el orden correcto. Todo lo que ocurre vive en un rol. Segundo, un rol nunca contiene valores propios de un host. Ninguna dirección IP, ningún dominio, ninguna contraseña aparece en roles/. Todo eso viene del inventario.
📒 El inventario: el cuadro de mandos
Si los roles son la maquinaria, el inventario es el cuadro de mandos que decide qué hace la maquinaria en cada servidor. Responde a tres preguntas: qué servidores existen, qué valores se aplican a todos y cuáles solo a uno. No voy a repasar cada variable – hay unos cientos –, pero merece la pena entender la estructura, porque explica cómo un solo repositorio puede describir tres servidores distintos.
Quién va dónde: hosts.yml
hosts.yml no contiene más que la topología – qué host pertenece a qué grupo:
all:
children:
blog: # WordPress servers
hosts:
www.example.de: {}
test: {}
nextcloud: # Nextcloud servers
hosts:
instance1: {}
instance2: {}
test: {} # shared with the blog test serverEl grupo es la característica más importante de un host. blog.yml empieza con hosts: blog, nextcloud-k3s.yml con hosts: nextcloud – el playbook elige sus servidores por grupo, y dentro de un rol compartido el grupo decide qué se genera (enseguida volvemos a ello). Fíjate en que ni siquiera la dirección IP está aquí: vive en las variables propias del host, así que existe en un único lugar.
Lo que vale para todos: group_vars/all/
Los valores que son iguales en todos los servidores están en group_vars/all/ – la red de pods de K3s, las rutas de los directorios de datos, los límites de recursos por defecto. El archivo más importante es versions.yml: cada versión que los playbooks instalan o despliegan, en una sola tabla. K3s, Helm, Calico, los charts de Helm, cada imagen de contenedor, los binarios de monitorización:
# --- Kubernetes platform ---------------------------------------
k3s_version: "v1.xx.x+k3s1" # github.com/k3s-io/k3s/releases
helm_version: "v4.x.x" # github.com/helm/helm/releases
calico_version: "v3.xx.x" # (policy-only, see notes)
# --- WordPress containers --------------------------------------
blog_image_wordpress: "wordpress:x.y.z-php8.4-fpm"
blog_image_mariadb: "mariadb:10.11.xx" # 10.11 LTSTodo está fijado con exactitud – nunca latest, nunca una etiqueta flotante. No es manía: K3s solo descarga una imagen si todavía no está en el nodo, así que una etiqueta flotante se queda en silencio en la compilación que esté en caché. Con una versión exacta, «qué versión corre dónde» tiene una sola respuesta, y una actualización es un cambio de una línea que aparece en git log. Un bot (Renovate) vigila las publicaciones de los proyectos y propone esos cambios de una línea como pull requests; yo los reviso y los despliego con el playbook.
Lo que vale para un servidor: host_vars/<host>/
Cada servidor tiene un directorio con dos archivos. vars.yml guarda los valores normales por host: direcciones, nombres de host, dimensionamiento (los dos servidores Nextcloud tienen hardware muy distinto), interruptores como el filtro de SSH por país. vault.yml guarda los secretos – contraseñas de bases de datos, cuentas de administración, credenciales de correo – y está cifrado con ansible-vault. Ansible lo descifra en memoria durante la ejecución; en el disco y en cada copia de seguridad es texto cifrado.
Ninguno de los dos archivos está en el repositorio público. Lo público son los archivos .example que hay al lado: la misma estructura con valores de ejemplo, para que cualquiera vea qué claves necesita un host sin ver las mías. El ejemplo del vault se ve así, sin los comentarios:
email: ...
mysql_root_password: ...
nextcloud_admin_user: ...
nextcloud_admin_pass: ...
blog_db_pass: ...
blog_admin_pass: ...Quién gana: el orden de precedencia
La misma variable puede estar definida en varios sitios. Ansible lo resuelve con un orden de precedencia fijo; para este repositorio importan cuatro niveles, del más débil al más fuerte:
- Role defaults (
roles/<role>/defaults/main.yml) – el punto de partida razonable del rol. - group_vars – lo que comparten todos los hosts.
- host_vars – lo que un host necesita de otra forma.
- Facts definidos durante la ejecución (
set_fact) – valores que calcula el propio rol, por ejemplo después de preguntar algo al servidor.
Un ejemplo concreto: el filtro de SSH por país. Su valor por defecto en common_firewall es una lista vacía – desactivado. Un servidor que solo debe aceptar SSH desde Alemania pone fw_ssh_geo_countries: [DE] en su vars.yml, y eso gana. El rol en sí no cambia nunca; el interruptor está en el inventario. Ese es el patrón detrás de la mayoría de las diferencias entre hosts de este repositorio.
El rol comprueba sus entradas: argument_specs
Un valor que faltaba en el inventario se notaba antes por las malas: a mitad de la ejecución, como un error críptico de plantilla. Hoy, 23 roles declaran sus entradas en meta/argument_specs.yml – nombre, tipo, si es obligatorio y si es secreto. Ansible las comprueba antes de la primera tarea del rol. Un server_ipv4 olvidado detiene ahora la ejecución al principio del rol del cortafuegos con «required option missing», no después de haber escrito medio conjunto de reglas, y un valor marcado con no_log nunca aparece en la salida.
🔀 Un rol, dos stacks: tres patrones
Este es el corazón de los roles compartidos. Un rol common_* se ejecuta en el blog y en Nextcloud, pero los dos necesitan cosas un poco distintas: Nextcloud tiene MariaDB y Redis en el host, el blog los tiene en contenedores; el blog escanea los archivos subidos, Nextcloud los datos de los usuarios. Tres patrones resuelven esto, y una vez que los conoces los encuentras por todas partes.
1. Preguntar al grupo
Cada host conoce los grupos a los que pertenece (group_names). Una plantilla o una tarea pueden preguntarlo sin más. El conjunto de reglas del cortafuegos, por ejemplo, solo abre los puertos de base de datos del host a la red de pods en los servidores Nextcloud, porque solo allí los pods necesitan llegar a una base de datos en el host:
{% if 'nextcloud' in group_names %}
# Allow K3s pods to reach MariaDB on the host
ip saddr {{ k3s_pod_cidr }} tcp dport 3306 accept
# Allow K3s pods to reach Redis on the host
ip saddr {{ k3s_pod_cidr }} tcp dport 6379 accept
{% endif %}En el blog, esas líneas simplemente no existen en el archivo generado. El mismo rol, la misma plantilla, dos resultados distintos – decididos por el inventario.
2. Pasar un perfil desde el playbook
Algunos roles hacen el mismo tipo de trabajo en ambos stacks, solo con otras rutas. Reciben un nombre de perfil como parámetro del rol, directamente en el playbook:
# blog.yml
- { role: common_selinux, selinux_target: wordpress }
- { role: common_clamav, clamav_target: wordpress }
# nextcloud-k3s.yml
- { role: common_selinux, selinux_target: nextcloud }
- { role: common_clamav, clamav_target: nextcloud }Dentro del rol, el perfil selecciona un bloque de ajustes de sus valores por defecto – para SELinux, los directorios que se montan en los pods y deben llevar la etiqueta de contenedores:
selinux_profiles:
wordpress:
- { label: WordPress www dir, path: "{{ blog_k3s_www_dir }}", owner: 33, group: 33 }
- { label: MariaDB data dir, path: "{{ blog_k3s_db_dir }}", owner: 999, group: 999 }
nextcloud:
- { label: Nextcloud www dir, path: "{{ nextcloud_k3s_www_dir }}", owner: 33, group: 33 }
- { label: Nextcloud data, path: "{{ nextcloud_data_dir }}", owner: 33, group: 33 }Las tareas del rol recorren entonces «los directorios de mi perfil» sin preocuparse de qué stack es. Añadir un tercer tipo de aplicación significaría añadir un perfil, no un rol.
3. Detectar en lugar de declarar
El tercer patrón prescinde por completo de la variable: el rol pregunta al servidor. El cortafuegos necesita saber si el servidor recibe su dirección por DHCP – si es así y faltan las reglas DHCP, la dirección se pierde en la siguiente renovación de la concesión. Podría ser un interruptor en el inventario, pero un interruptor puede estar mal o quedarse anticuado cuando un proveedor cambia algo. Así que el rol pregunta a NetworkManager al principio de cada ejecución:
- name: Firewall | Detect whether IPv4/IPv6 are configured by DHCP
ansible.builtin.shell: |
dev=$(ip -4 route show default | awk '{print $5; exit}')
con=$(nmcli -g GENERAL.CONNECTION device show "$dev")
nmcli -g ipv4.method,ipv6.method connection show "$con"
register: _fw_ipmethods
changed_when: false
check_mode: false
- name: Firewall | Remember the address configuration for the template
ansible.builtin.set_fact:
fw_dhcp4: "{{ ... == 'auto' }}"La respuesta va a un fact, y la plantilla solo genera las reglas DHCP donde hacen falta. En mis servidores el resultado es distinto – un host recibe IPv4 por DHCP, los demás son completamente estáticos –, y nadie tiene que acordarse de cuál es cuál. La regla general de este repositorio: lo que el servidor nos puede decir, se lo preguntamos al servidor; el inventario es para decisiones, no para hechos.
📜 Los cuatro playbooks
Hay dos parejas: un playbook completo y un playbook de actualización por aplicación.
| Playbook | Qué hace | Cuándo lo ejecuto |
|---|---|---|
blog.yml / nextcloud-k3s.yml | El servidor entero, desde los paquetes hasta los ajustes de la aplicación – cada rol, en orden. Seguro en cualquier momento: en un servidor correcto no cambia nada. | Tras un cambio en el repositorio, un salto de versión en versions.yml, o simplemente para comprobar. |
blog-update.yml / nextcloud-update.yml | Solo la aplicación: imágenes de contenedor reconstruidas, actualizaciones de plugins y apps, la actualización de la base de datos de Nextcloud. Nunca toca el host (SSH, cortafuegos, paquetes). | Cuando hay actualizaciones – el trabajo rutinario. |
El playbook completo: el orden importa
Un playbook completo es una lista de unos 25 roles. El orden no es alfabético, es una cadena de dependencias. Este es el del blog, abreviado:
roles:
- common_version_check # report: what is installed, pinned, available?
- blog_packages # repositories, system update, base packages
- common_ssh # SSH on its new port, key-only
- common_firewall # nftables
- common_sysctl # kernel parameters
- common_cis_hardening # CIS benchmark items
- common_selinux # label the data dirs <- before the deploy!
- common_k3s # K3s, Helm, cert-manager, ingress
- common_calico # NetworkPolicy enforcement
- blog_k3s_deploy # all Kubernetes objects of WordPress
# host-side services
- common_node_exporter
- common_prometheus
- common_grafana
- common_fail2ban
- ... # mail, updates, scanners, audit, watchdog
- blog_wordpress # configure WordPress inside the running pod
- blog_verify # smoke tests
- common_aide_refresh # always lastAlgunas de esas posiciones tienen un motivo que conviene conocer:
- SELinux antes del despliegue. Los pods montan directorios del host. Si arrancan antes de que esos directorios lleven la etiqueta de contenedores, SELinux bloquea cada escritura y MariaDB no arranca.
- Monitorización y protección después del despliegue. Son servicios del host, pero varios de ellos vigilan lo que el despliegue acaba de crear: Prometheus recoge los exporters de los pods de la aplicación, fail2ban lee los logs del ingress, Monit comprueba que los pods estén corriendo.
- La configuración de WordPress hacia el final.
blog_wordpresshabla con WordPress dentro de su pod, así que necesita un pod en marcha. El rol de despliegue se encarga de ello: espera a que MariaDB, Redis y WordPress estén listos antes de terminar. - AIDE siempre al final. AIDE compara cada noche los archivos del sistema con una referencia. Una ejecución del playbook cambia a propósito archivos vigilados, así que el último rol renueva la referencia – si, y solo si, esta ejecución ha cambiado algo. Si no, la noche siguiente informaría de mis propios cambios como una intrusión.
Las etiquetas: un rol en lugar de todos
Cada rol del playbook lleva una etiqueta con su propio nombre. Eso convierte un playbook completo en una herramienta precisa:
# only the host package updates (e.g. a new Grafana package)
ansible-playbook blog.yml --limit <host> --tags blog_packages
# only the Kubernetes objects (e.g. after an image version bump)
ansible-playbook blog.yml --limit <host> --tags blog_k3s_deployPor eso los playbooks de actualización tampoco instalan paquetes en el host: están limitados a propósito a la aplicación. Un nuevo paquete de Grafana llega con la siguiente ejecución completa, o al momento con --tags blog_packages – y las actualizaciones de seguridad no esperan a ninguna de las dos, las instala dnf-automatic cada noche (parte 2).
Los playbooks de actualización: primero las barreras
Los playbooks de actualización reinician la aplicación, así que están escritos a la defensiva. El de Nextcloud, en orden:
- Negarse a ejecutarse sin
--limit. Si no,hosts: nextcloudactualizaría todas las instancias a la vez. Las actualizaciones son deliberadas, un host tras otro. - Negarse a actualizar una instancia que no está sana. Si Nextcloud ya está en modo de mantenimiento o espera una actualización de la base de datos, una actualización enterraría el problema original. Esa barrera tiene su propia historia.
- Hacer el trabajo dentro de
block/rescue. Encontrar las imágenes reconstruidas en origen, reiniciar solo esos despliegues, ejecutar la actualización de la base de datos, actualizar las apps. Si falla un paso, la parterescuemuestra el estado, los pods, los eventos y las últimas líneas del log, sale del modo de mantenimiento solo donde es seguro y se detiene – un camino de fallo definido en lugar de una instancia a medio actualizar.
La versión del blog sigue la misma idea con las particularidades de WordPress: lanza ahora mismo la tarea de actualización nocturna, con un volcado previo de la base de datos, y reinicia solo lo que de verdad ha cambiado. La estrategia de actualización por capas que hay detrás está descrita en un artículo anterior; la parte 3 muestra el código actual.
⚙️ Qué pasa durante una ejecución
Ahora la mecánica: lo que hace Ansible entre pulsar Intro y el resumen final. Entender esto explica casi cada decisión de diseño de los roles.
Conectarse y recoger facts
Ansible se conecta por SSH como root, con una clave – SSH no acepta otra cosa. Las conexiones se reutilizan entre tareas, los scripts pasan por la conexión existente en lugar de copiarse primero, y Ansible no pide terminal. Este último detalle importa para la alarma de inicio de sesión SSH: sin terminal, una ejecución del playbook no deja rastro en los registros de inicio de sesión, así que la alarma solo salta con sesiones interactivas reales.
Después Ansible recoge facts sobre el servidor – pero solo el conjunto mínimo. Los roles usan unos pocos (distribución, kernel, nombre de host, fecha); el conjunto completo costaría tiempo en interfaces y detalles de hardware que nadie lee.
Un informe antes de cambiar nada
El primer rol, common_version_check, no cambia nada. Para K3s, Helm, el ingress y cert-manager compara tres cosas: lo que está instalado en el servidor, lo que fija versions.yml y cuál es la última versión publicada en GitHub. El resultado es una pequeña tabla en color – antes de cada ejecución veo si una versión fijada está anticuada o si el servidor va por detrás del repositorio. Lo interesante es cómo funciona: dos pasos de set_fact, el primero lleva todos los formatos de versión distintos a uno solo, el segundo los compara. Recoger, preparar, juzgar – el mismo patrón de tres pasos vuelve al final en los roles de verificación.
Tarea a tarea, host a host
Un play se ejecuta tarea a tarea: la tarea 1 en cada host del play, luego la tarea 2 en cada host, y así sucesivamente. Cuando ejecuto el playbook de Nextcloud sin --limit, las dos instancias se configuran en paralelo, paso a paso. Cada tarea informa de uno de tres estados, y todo el repositorio está construido para que ese informe sea honesto:
| Estado | Significado | Qué exige del código |
|---|---|---|
| ok | Ya era correcto, no se ha hecho nada. | La tarea debe reconocer el estado correcto. Módulos como copy o template lo hacen por sí mismos (comparan sumas de comprobación). |
| changed | Algo ha cambiado. | Con command y shell Ansible no puede saberlo – el rol tiene que decirlo, con changed_when. Una simple consulta es changed_when: false. |
| failed | Algo ha ido mal; el host se detiene. | Los códigos de salida distintos de cero esperados deben declararse con failed_when; si no, un «no encontrado» inofensivo interrumpe la ejecución. |
El objetivo se llama idempotencia: una segunda ejecución justo después de la primera informa de changed=0. Parece un detalle cosmético, pero es lo que convierte el playbook en una herramienta de comprobación. Si un servidor correcto informa de cero cambios, cada «changed» en una ejecución posterior es una desviación real – alguien editó un archivo a mano, un paquete cambió un valor por defecto, un valor se ha desviado. La ronda de calidad consistió sobre todo en llegar hasta ahí.
Por eso una forma muy habitual en los roles es esta (simplificada) – leer el estado, cambiar solo si difiere:
- name: Read the current value
ansible.builtin.command: occ config:system:get trusted_domains
register: current
changed_when: false # reading changes nothing
failed_when: false # "not set yet" is not an error
check_mode: false # also run in --check, it is read-only
- name: Set the value only if it differs
ansible.builtin.command: occ config:system:set ...
when: current.stdout != wanted
changed_when: true # if it runs, it changed somethingLos handlers: una vez, al final, solo si hace falta
Muchos cambios necesitan una acción posterior: un nuevo sshd_config necesita reiniciar sshd, un nuevo conjunto de reglas del cortafuegos una recarga. Hacerlo en la propia tarea reiniciaría sshd cinco veces si cambian cinco archivos. La respuesta de Ansible es el handler: una tarea notifica a un handler por su nombre, y el handler se ejecuta una vez, después de todas las tareas del play – y solo si al menos una tarea que lo notificó informó de verdad «changed».
# task
- name: Firewall | Upload nftables ruleset
ansible.builtin.template:
src: fw.nft.j2
dest: /etc/sysconfig/nftables.conf
validate: nft -c -f %s
notify: Reload nftables
# handlers/main.yml
- name: Reload nftables
ansible.builtin.service:
name: nftables
state: reloaded
- name: Re-apply fail2ban bans after the nftables reload
ansible.builtin.command: systemctl try-restart fail2ban
listen: Reload nftablesDos detalles de este ejemplo se usan en todo el repositorio. listen permite que un segundo handler se suscriba a la misma notificación: una recarga del cortafuegos vacía las listas de bloqueo, así que fail2ban tiene que volver a poner sus bloqueos – y los handlers se ejecutan en el orden en que están escritos, así que eso ocurre después de la recarga. Y cuando una acción posterior no puede esperar al final – Redis tiene que estar funcionando con su nueva contraseña antes de que el siguiente rol se conecte –, el rol llama a meta: flush_handlers, que ejecuta en ese mismo punto todos los handlers pendientes.
La ejecución en seco: –check –diff
Cada playbook puede ejecutarse en modo check: Ansible recorre todas las tareas, pero no cambia nada y muestra las diferencias que haría. Para que sea útil, los roles cuidan dos cosas. Las simples consultas se ejecutan igualmente (check_mode: false); si no, las tareas posteriores compararían con nada. Y los pasos que dependen de algo que no ha ocurrido de verdad – descomprimir un archivo que no se ha descargado de verdad – se omiten en modo check en lugar de fallar.
Sobre el modo check hay un pequeño script, scripts/check-drift.sh. Ejecuta un playbook con --check --diff y muestra solo las tareas que cambiarían algo, con su diff. El código de salida 0 significa: el servidor es exactamente lo que describe el código. Lo ejecuto antes de cada ejecución real, y justo después de una ejecución real no debe mostrar nada – todo lo que liste entonces es una tarea que todavía no es idempotente.
scripts/check-drift.sh nextcloud-k3s.yml <host>
scripts/check-drift.sh blog.yml <host> --tags blog_k3s_deployEl veredicto: los roles de verificación
Una ejecución que termina con «ok» solo significa que cada tarea hizo lo que se le pidió. No significa que la web funcione. Por eso cada playbook termina con un rol de verificación – blog_verify y nextcloud_verify, ambos construidos sobre comprobaciones comunes en common_verify. Prueban de fuera hacia dentro: servicios en marcha, pods listos, HTTPS que responde con un certificado válido, cabeceras de seguridad presentes, rutas bloqueadas que de verdad lo están, base de datos y caché accesibles.
Siguen a propósito el patrón recoger–preparar–juzgar de la comprobación de versiones. Todas las comprobaciones se ejecutan primero y solo guardan su resultado (failed_when: false). Luego set_fact convierte los resultados en verdadero/falso. Luego el rol muestra un informe y lo envía por correo. Solo al final del todo un assert hace fallar la ejecución – si una comprobación crítica está en rojo. La ventaja frente a parar en el primer problema: una ejecución muestra todos los problemas de una vez, no uno por intento.
🧪 Las redes de seguridad alrededor del código
Los servidores son producción – los archivos de otras personas, un blog con lectores. Así que un cambio tiene que pasar varias redes antes de llegar a ellos, de lo rápido y local a lo lento y real:
| Red | Qué atrapa | Cuándo |
|---|---|---|
| Hooks de pre-commit | Secretos en un commit (gitleaks), errores de YAML, avisos de ansible-lint, restos de conflictos de fusión | Antes de cada commit, en mi máquina |
| CI en GitHub | El mismo lint más una búsqueda de secretos en todo el historial – también para un commit hecho sin los hooks | En cada push |
Prueba de aplicación K8s (tests/k8s) | Roles de Kubernetes que no son idempotentes, un reinicio de pod que nadie pidió, un salto de versión que --check no notaría | Antes de cambiar los roles de Kubernetes – se ejecuta contra un servidor de API de K3s desechable en podman, sin servidor real |
Prueba de escenarios de actualización (tests/update) | Los playbooks de actualización contra servidores simulados: instancia sana, imagen reconstruida, fallo en cada paso – ¿deja el rescue un estado limpio? | Antes de cambiar los playbooks de actualización |
| Comprobación de deriva | Cualquier diferencia entre el código y el servidor | Antes y después de cada ejecución real |
| Renovate | Nuevas versiones de todo lo que hay en versions.yml | De forma continua; llega como pull request |
Ninguna de ellas necesita que alguien se acuerde de nada – de eso se trata. La única regla que cumplo a mano es la última: las ejecuciones reales contra los servidores las lanzo yo, un host tras otro, después de que la ejecución en seco salga bien.
🛠️ En operación: cómo llega un cambio a un servidor
Esta es la rutina para cada cambio, desde un salto de versión de una línea hasta un rol nuevo. Son siempre los mismos siete pasos, y el orden es lo importante:
- Cambiar el rol, la plantilla o
versions.yml. - Lint – se ejecuta de todos modos al hacer commit, pero ver los avisos ahora es más rápido.
- Probar sin servidor donde haya una prueba:
tests/k8s/run.shpara los roles de Kubernetes,tests/update/run.shpara los playbooks de actualización. - Ejecución en seco:
scripts/check-drift.sh <playbook> <host>. Lee la lista de cambios y pregúntate: ¿es exactamente lo que quería? Todo lo demás de la lista es una sorpresa que hay que entender primero. - Ejecución real en un servidor, limitada con
--tagsa lo que toca el cambio. - Otra vez la comprobación de deriva. Ahora tiene que decir «no drift». Lo que siga en la lista es una tarea que no es idempotente – corrígela antes de que oculte un cambio real en la siguiente ejecución.
- Commit y push. Los hooks vuelven a comprobar el lint y los secretos, la CI todo el historial.
Dos hábitos lo completan. Los secretos solo se editan con ansible-vault edit inventory/host_vars/<host>/vault.yml – descifra en un archivo temporal para el editor, vuelve a cifrar al guardar y borra la copia temporal. Lo que no debes hacer es descifrar el archivo en su sitio con ansible-vault decrypt y olvidarlo: entonces cada contraseña queda en texto plano en el disco y acaba en cada copia de seguridad de esa máquina. (En este repositorio, los archivos vault reales además se mantienen fuera de git con .gitignore, y la búsqueda de secretos de los hooks es una segunda red.) Y una actualización de versión empieza por las notas de la versión del componente, no por el playbook: el pull request del bot dice que hay una versión nueva, no si es seguro adoptarla.
📚 El vocabulario en un solo lugar
Las partes siguientes usan estos términos sin volver a explicarlos:
| Término | En pocas palabras |
|---|---|
| rol | Una unidad de trabajo autónoma con una estructura fija (tareas, handlers, plantillas, valores por defecto). |
| inventario / grupo / host_vars | Qué servidores existen, en qué grupo están, qué valores se aplican a todos o a uno. |
fact / set_fact | Un valor que Ansible conoce sobre un host – recogido al principio, o calculado por un rol durante la ejecución. |
register | Guardar el resultado de una tarea (salida, código de salida, «changed») para tareas posteriores. |
changed_when / failed_when | Decirle a Ansible cuándo una tarea ha cambiado algo de verdad o ha fallado de verdad. |
handler / notify / listen | Acciones posteriores que se ejecutan una vez, al final, solo tras un cambio. |
flush_handlers | Ejecutar los handlers pendientes ahora en lugar de al final. |
| idempotente | Una segunda ejecución no cambia nada; cada «changed» posterior es una deriva real. |
| modo check | --check --diff: mostrar lo que cambiaría, no cambiar nada. |
| etiqueta | Ejecutar solo algunos roles de un playbook. |
| perfil | Un parámetro de rol que selecciona un bloque de ajustes por stack. |
| vault | Archivo de variables cifrado para los secretos. |
| argument_specs | Las entradas declaradas de un rol, comprobadas antes de que arranque. |
🔎 Dónde ver cada funcionalidad de Ansible
Las partes siguientes explican cada rol por completo – no solo sus tareas, sino también sus handlers, las entradas que declara en meta/, los interruptores de sus valores por defecto, la lógica de sus plantillas y los scripts que trae. Algunas funcionalidades de Ansible aparecen en muchos roles, pero cada una tiene un sitio donde es más fácil de entender. Si quieres aprender una funcionalidad concreta, empieza por ahí:
| Funcionalidad | Para qué sirve | Donde mejor se ve |
|---|---|---|
handlers, notify, listen | Acciones posteriores una sola vez, al final, en un orden fijo | common_firewall (parte 5) – recarga, y después fail2ban vuelve a aplicar sus bloqueos |
meta: flush_handlers | Ejecutar los handlers pendientes ahora, no al final | next_redis (parte 4), common_k3s (parte 2) |
meta/argument_specs.yml | Entradas declaradas y comprobadas de un rol | todos los roles; explicado en detalle con common_firewall (parte 5) |
defaults/ + sobrescritura en el inventario | Un interruptor apagado en el rol y encendido por host | el filtro de SSH por país, common_firewall (parte 5) |
| parámetros de rol / perfiles | Un rol, ajustes distintos por stack | common_selinux, common_clamav (parte 5) |
template + validate | Escribir un archivo de configuración solo si pasa una comprobación de sintaxis | common_firewall (nft -c), common_cis_hardening (visudo -cf, parte 5) |
Jinja {% if %} / group_names | Una plantilla, archivos distintos por host | fw.nft.j2 (parte 5) |
register → when | Leer el estado, cambiar solo lo que difiere | next_config (parte 4), blog_wordpress (parte 3) |
Cadenas de set_fact | Recoger, preparar, juzgar | common_version_check, los roles de verificación (este artículo), partes 3 y 4 |
changed_when / failed_when | Un estado honesto para command y shell | en todas partes; los casos más delicados en common_aide_refresh (parte 5) |
check_mode: false / ansible_check_mode | Hacer que la ejecución en seco tenga sentido | los exporters y common_k3s (partes 2 y 6) |
block / rescue | Un camino de fallo definido | los playbooks de actualización (partes 3 y 4) |
assert, run_once | Negarse a arrancar en un estado incorrecto | los playbooks de actualización, next_redis (parte 4) |
import_tasks vs. include_tasks | Dividir un rol en archivos – de forma estática o en tiempo de ejecución | next_k3s_deploy (parte 4) vs. el reinicio de la primera ejecución en common_firewall (parte 5) |
async / poll | Lanzar algo que corta la propia conexión | common_ssh al reiniciar sshd (parte 5) |
until / retries | Esperar a un servicio en lugar de dormir | common_k3s (parte 2), common_grafana (parte 6) |
loop con zip / selectattr | Combinar y filtrar listas | common_monit (parte 6) |
lookup('template') + from_yaml_all + kubernetes.core.k8s | El patrón de aplicación K8s | common_calico (parte 2), los roles de despliegue (partes 3 y 4) |
kubernetes.core.helm | Releases de Helm con un diff de verdad | common_k3s (parte 2) |
uri | Hablar con una API HTTP, mirar antes de crear | common_grafana (parte 6) |
script | Ejecutar un script local en el host, usar su salida JSON | common_image_refresh (partes 3 y 4) |
creates: | Ejecutar un comando una sola vez | common_clamav (parte 5), blog_visitor_stats (parte 3) |
no_log | Mantener los secretos fuera de la salida | common_msmtp (parte 2), next_mariadb (parte 4) |
🧭 Cómo sigue la visita
La parte 2 – la plataforma empieza desde abajo: cómo un AlmaLinux recién instalado se convierte en un sistema base, cómo se instalan K3s, Helm, cert-manager y el ingress con versiones exactas, y por qué Calico corre junto a Flannel sin encargarse él mismo de la red. Las partes 3 y 4 ponen después las aplicaciones encima – WordPress y Nextcloud, cada una en su propio artículo. La parte 5 reúne todos los roles de seguridad en un solo lugar, desde SSH hasta los escaneos nocturnos, como complemento detallado de Defense in Depth. La parte 6 hace lo mismo con la monitorización.
Cada una sigue la misma plantilla para cada rol: qué pasa, en qué orden – por qué está construido así – y qué salió mal por el camino. Este último punto suele ser el más útil. La mayoría de los comentarios de este repositorio empiezan con una fecha y las palabras «found on» (encontrado el).




