Seguridad en Ansible: Vault, SSH y credenciales
Gestión profesional de secretos: contraseñas, claves API y certificados cifrados.
El problema: secretos en texto plano
Imaginemos que subimos nuestro playbook a Git con las contraseñas de producción en texto claro. Cualquiera que tenga acceso al repositorio (o que hackee nuestra cuenta) tiene las llaves de nuestra infraestructura.
💣 La analogía: la llave bajo el felpudo
Dejar contraseñas en texto plano es como dejar la llave de nuestra casa bajo el felpudo. Todo el mundo sabe que está ahí.
# ❌ NUNCA hagas esto (secretos visibles en Git)
db_password: "SuperSecreto123"
api_key: "sk-1234567890abcdef"
ssl_private_key: "-----BEGIN RSA PRIVATE KEY-----..."
Ansible Vault es la caja fuerte donde guardamos esas llaves. Solo quien tiene la combinación puede abrirla.
Conceptos básicos
Ansible Vault cifra archivos o variables usando AES-256, uno de los estándares de cifrado más robustos que existen.
¿Qué podemos cifrar?
- Archivos completos:
group_vars/all/vault.yml - Variables individuales: Una sola variable dentro de un archivo YAML
- Archivos estáticos: Certificados SSL, claves privadas, configuraciones sensibles
Flujo de trabajo
| Partimos de... | Comando | Resultado |
|---|---|---|
| Secreto en texto plano | ansible-vault encrypt | Archivo cifrado AES-256 |
| Archivo cifrado | ansible-vault view | Lo vemos sin modificarlo |
| Archivo cifrado | ansible-vault edit | Lo editamos y se vuelve a cifrar |
| Archivo cifrado | ansible-vault decrypt | Vuelve a texto plano (con cuidado) |
| Archivo cifrado | ansible-playbook --ask-vault-pass | Ansible lo descifra en memoria al ejecutar |
Comandos esenciales
Crear un archivo cifrado desde cero
ansible-vault create group_vars/all/vault.yml
Se abre nuestro editor ($EDITOR) y al guardar, el archivo queda cifrado automáticamente.
Cifrar un archivo existente
# Cifrar un archivo que ya existe
ansible-vault encrypt group_vars/production/secrets.yml
# Cifrar múltiples archivos a la vez
ansible-vault encrypt group_vars/*/vault.yml
Ver un archivo cifrado (sin modificar)
ansible-vault view group_vars/all/vault.yml
Editar un archivo cifrado
ansible-vault edit group_vars/all/vault.yml
Se descifra en un archivo temporal, abre nuestro editor, y al guardar lo vuelve a cifrar y borra el temporal.
Descifrar un archivo
# Descifrar (vuelve a texto plano)
ansible-vault decrypt group_vars/all/vault.yml
# ⚠️ Cuidado: ¡No hagas commit después de descifrar!
Cambiar la contraseña (rekey)
# Cambiar la contraseña del Vault
ansible-vault rekey group_vars/all/vault.yml
# Cambiar en múltiples archivos
ansible-vault rekey group_vars/*/vault.yml
El patrón profesional: variables separadas
Esta es la práctica recomendada por Red Hat y la que veremos en entornos profesionales.
Estructura de archivos
proyecto/
├── group_vars/
│ └── all/
│ ├── vars.yml # Variables PÚBLICAS (visible en Git)
│ └── vault.yml # Variables SECRETAS (cifrado con Vault)
Paso 1: Archivo público (vars.yml)
# group_vars/all/vars.yml
# Visible en Git, fácil de auditar
# Base de datos
db_host: "db.ejemplo.com"
db_port: 5432
db_name: "mi_aplicacion"
db_user: "app_user"
db_password: "{{ vault_db_password }}" # Referencia al secreto
# API externa
api_endpoint: "https://api.ejemplo.com/v2"
api_key: "{{ vault_api_key }}" # Referencia al secreto
# SSL
ssl_cert_path: /etc/ssl/certs/app.crt
ssl_key_path: /etc/ssl/private/app.key
ssl_key_content: "{{ vault_ssl_key }}" # Referencia al secreto
Paso 2: Archivo cifrado (vault.yml)
# Crear el Vault; Ansible pedirá la contraseña dos veces
ansible-vault create group_vars/all/vault.yml
# group_vars/all/vault.yml (cifrado)
# Solo contiene los valores sensibles con prefijo vault_
vault_db_password: "P@ssw0rd_Pr0duct10n_2026!"
vault_api_key: "sk-abc123def456ghi789"
vault_ssl_key: |
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA7...
-----END RSA PRIVATE KEY-----
Ahora ya podríamos ejecutar nuestro playbook normalmente, y Ansible se encargará de descifrar vault.yml en memoria para resolver las referencias en vars.yml.
Para abrir este Vault posteriormente:
ansible-vault view group_vars/all/vault.yml
Ansible pedirá la contraseña de forma interactiva cada vez que necesite abrir el Vault. En este ejemplo todavía no usamos identidades ni ficheros de contraseña.
En CI/CD, en lugar del flag, podemos exportar ANSIBLE_VAULT_PASSWORD_FILE apuntando a un fichero que el pipeline crea a partir de un secreto del propio CI (lo vemos en el capítulo de CI/CD).
¿Por qué este patrón?
| Aspecto | Todo cifrado | Patrón separado |
|---|---|---|
| Git diffs | Inútiles (todo cifrado) | Claros para variables públicas |
| Auditoría | Necesitamos descifrar para ver | Vemos qué variables existen sin descifrar |
| Búsqueda | grep no funciona | grep db_password vars.yml funciona |
| Revisión de código | Imposible sin contraseña | Se revisa normalmente |
Ejemplo: desplegar PostgreSQL con Docker
Podemos reutilizar las variables públicas db_name y db_user, además de db_password, que apunta al secreto vault_db_password. Esta task crea y arranca un contenedor PostgreSQL:
- name: Desplegar aplicación como contenedor con secretos
hosts: target1
become: true
tasks:
- name: Desplegar contenedor quotes con credenciales inyectadas como env vars
community.docker.docker_container:
name: "{{ app_name }}"
image: "{{ app_image }}"
state: started
restart_policy: unless-stopped
pull: true
ports:
- "{{ app_port }}:8000"
env:
POSTGRES_HOST: "{{ postgres_host }}"
POSTGRES_USER: "{{ postgres_user }}"
POSTGRES_PASSWORD: "{{ postgres_password }}"
POSTGRES_DB: "{{ postgres_db }}"
no_log: true # Evitar registar la salida de la tasks
- name: Mostrar configuración (sin secretos)
debug:
msg: |
App: {{ app_name }}
Puerto: {{ app_port }}
Postgres Host: {{ postgres_host }}
Postgres Password: ******** (oculto por seguridad)
no_log: true evita que la contraseña aparezca en la salida de Ansible. La task es idempotente: si el contenedor ya existe con esa configuración, Ansible no lo crea de nuevo.
Vault IDs: múltiples contraseñas
En proyectos grandes, necesitamos diferentes contraseñas para diferentes entornos. No queremos que el equipo de desarrollo pueda descifrar los secretos de producción.
Configuración
# Crear primero una contraseña diferente para cada entorno
openssl rand -base64 32 > ~/.vault_pass_barcelona
openssl rand -base64 32 > ~/.vault_pass_madrid
# Proteger los ficheros de contraseña (IMPORTANTE)
chmod 600 ~/.vault_pass_barcelona ~/.vault_pass_madrid
# Crear cada Vault relacionándolo con su identidad y su fichero de contraseña
ansible-vault create \
--vault-id barcelona@~/.vault_pass_barcelona \
group_vars/barcelona/vault.yml
ansible-vault create \
--vault-id madrid@~/.vault_pass_madrid \
group_vars/madrid/vault.yml
# Ejecutar playbook con múltiples Vault IDs
ansible-playbook site.yml \
--vault-id barcelona@~/.vault_pass_barcelona \
--vault-id madrid@~/.vault_pass_madrid
Archivos de contraseña por entorno
Los comandos anteriores crean un fichero no vacío para cada identidad. Si alguno se borra o se sobrescribe, ya no podremos descifrar los Vaults creados con su contraseña original. Estos ficheros no se suben al repositorio.
Configurar en ansible.cfg
# ansible.cfg
[defaults]
vault_identity_list = barcelona@~/.vault_pass_barcelona, madrid@~/.vault_pass_madrid
Con esto ya no necesitamos pasar --vault-id en cada ejecución de Madrid o Barcelona. Solo quien tenga el fichero de contraseña correspondiente podrá lanzar cada entorno.
El nombre del Vault ID (madrid, barcelona) es solo una etiqueta: Ansible prueba todas las contraseñas que le damos contra cualquier archivo cifrado. La separación real entre entornos la da quién tiene cada fichero de contraseña, no el label. Por eso solo compartimos las identidades necesarias y comprobamos que todos los ficheros existan antes de empezar.
Integración con sistemas externos
En entornos enterprise, los secretos suelen estar en sistemas dedicados como HashiCorp Vault, AWS Secrets Manager o Azure Key Vault. Ansible Vault puede trabajar con ellos.
Script de contraseña personalizado
En lugar de un archivo de texto con la contraseña, podemos usar un script que la obtenga de donde sea.
#!/bin/bash
# vault-password-client.sh
# Obtener la contraseña del Vault desde un gestor de secretos externo
# Ejemplo con HashiCorp Vault
vault kv get -field=ansible_vault_password secret/ansible/vault-pass
# Ejemplo con AWS Secrets Manager
# aws secretsmanager get-secret-value --secret-id ansible-vault-pass --query SecretString --output text
# Ejemplo con macOS Keychain
# security find-generic-password -a ansible -s vault-pass -w
# Hacer el script ejecutable
chmod +x vault-password-client.sh
# Usarlo con Ansible
ansible-playbook site.yml --vault-password-file ./vault-password-client.sh
Gestión segura de claves SSH
Ansible se conecta a los hosts por SSH, así que nuestras claves son el primer eslabón de la cadena. Si alguien consigue nuestra clave privada, da igual lo bien cifrado que esté el Vault: ya tiene la puerta abierta.
Reglas básicas
- Nada de contraseñas SSH: autenticación por clave pública siempre.
- Una clave por identidad: humanos, CI/CD y bastion con claves distintas. Si cae la del CI, no caen todas.
ed25519por defecto: más corta, más rápida y más segura que RSA.- Passphrase en las claves de humanos. Las de CI pueden no llevarla, pero entonces las rotamos a menudo.
- La clave SSH de acceso no va al repositorio, ni siquiera cifrada: quien tenga la contraseña del Vault entraría en todos los hosts. Otra cosa son los secretos que Ansible despliega (como la clave del certificado SSL de antes), que sí viven cifrados en
vault.yml.
ssh-agent: la passphrase una sola vez
Tenemos tres formas de pasarle la clave a Ansible:
| Mecanismo | Cuándo usarlo |
|---|---|
--private-key=ruta/clave | Pruebas locales rápidas |
ansible_ssh_private_key_file en group_vars | Una clave distinta por entorno, definida en el inventario |
ssh-agent | Lo recomendado: desbloqueamos la clave una vez y el agente la guarda en memoria |
ssh-keygen -t ed25519 -C "deploy@ansible" -f ~/.ssh/deploy_ed25519 # con passphrase
eval $(ssh-agent)
ssh-add -t 4h ~/.ssh/deploy_ed25519 # pide la passphrase una vez; la olvida en 4 horas
ansible-playbook -i inventory/barcelona.ini playbooks/deploy-app.yml
No desactivemos la verificación de host
En el laboratorio del curso usamos host_key_checking = False porque los contenedores se destruyen y recrean con claves nuevas constantemente. En producción no: esa comprobación es lo que evita que un atacante se haga pasar por nuestro servidor (un man-in-the-middle) y reciba credenciales, secretos del Vault y todo lo que despleguemos.
Lo correcto es registrar las huellas de los hosts en known_hosts, verificándolas antes contra una fuente fiable (la consola del proveedor cloud, por ejemplo):
- name: Registrar la huella SSH de los hosts gestionados
ansible.builtin.known_hosts:
name: "{{ item }}"
key: "{{ lookup('file', 'known_hosts/' + item) }}" # huellas verificadas y versionadas
path: ~/.ssh/known_hosts
loop: "{{ groups['barcelona'] }}"
delegate_to: localhost
run_once: true
Bastion o jump host
Cuando los hosts de producción no tienen IP pública, entramos a través de un bastion. Basta con configurarlo en ~/.ssh/config y Ansible lo respeta sin tocar nada más:
# ~/.ssh/config
Host bastion
HostName bastion.ejemplo.com
User jump
IdentityFile ~/.ssh/deploy_ed25519
Host 10.0.*.*
User deploy
ProxyJump bastion
IdentityFile ~/.ssh/deploy_ed25519
Rotación de claves
Una clave que nunca rota es una clave comprometida que aún no sabemos que lo está. El patrón es añadir la nueva, comprobar que entramos con ella y solo entonces retirar la antigua:
- name: Añadir clave nueva
ansible.posix.authorized_key:
user: deploy
key: "{{ lookup('file', 'keys/deploy_2026.pub') }}"
state: present
- name: Retirar clave antigua (tras validar la nueva)
ansible.posix.authorized_key:
user: deploy
key: "{{ lookup('file', 'keys/deploy_2025.pub') }}"
state: absent
🎯 Buena práctica: rotamos las claves de CI cada 90 días y siempre que alguien con acceso deja el equipo.
Variables sensibles y no_log
Vault protege los secretos en reposo, en el repositorio. Pero si una tarea imprime la contraseña al ejecutarse, acaba en los logs del CI y en el historial de la terminal, venga o no de Vault.
- name: Crear usuario con contraseña
ansible.builtin.user:
name: app
password: "{{ vault_app_password | password_hash('sha512', vault_app_password_salt) }}"
no_log: true # sin esto, con -v la contraseña sale en pantalla
Con no_log: true los parámetros y el resultado de la tarea (también lo que guardemos con register) aparecen como censored due to no_log. Ojo: si la tarea falla, el error también se censura, así que lo usamos solo donde hay secretos.
💡 El salt fijo (también en el Vault) no es capricho: sin él,
password_hashgenera un hash distinto en cada ejecución y la tarea salechangedsiempre, rompiendo la idempotencia que vimos en el capítulo 4.
¿Qué cuenta como variable sensible?
No solo las contraseñas de aplicación. Todo esto va al vault.yml:
- Credenciales de aplicación: bases de datos, APIs, tokens, claves de certificados.
- La contraseña de
sudo: si el usuario remoto la necesita,ansible_become_password: "{{ vault_become_password }}"en vez de teclearla con-Ken cada ejecución o, peor, dejarla en el inventario. - Credenciales de conexión que no sean claves SSH, como
ansible_passworden hosts Windows (WinRM).
Otras vías de fuga
no_log no es la única puerta. Estas también dejan secretos a la vista:
--diff: muestra el contenido de las plantillas que cambian. Si untemplategenera un.envcon contraseñas, las veremos en el diff. Solución:diff: falseen esa tarea.- Ficheros generados en el host: un
.envo unconfig.inicon secretos debe desplegarse conmode: "0600"y elownerde la aplicación, no con los permisos por defecto. - Fact cache: si guardamos un secreto con
set_factycacheable: truey tenemos activado un fact cache persistente (JSON, Redis), acaba escrito en disco en texto plano.
Anti-patterns que conviene evitar
| Anti-pattern | Por qué es malo | Alternativa |
|---|---|---|
debug: var=vault_db_password para depurar | Imprime el secreto en pantalla y en logs | Un assert que compruebe el valor sin mostrarlo |
Pasar secretos con -e password=... | Quedan en el historial de la shell y en los logs del CI | Vault o variables de entorno |
command: "mysql -p{{ vault_db_password }} ..." | El secreto se ve en la lista de procesos con ps | El módulo nativo (community.mysql.mysql_user) |
| Misma clave SSH para humanos y CI | Si cae el CI, caen todos | Una clave por identidad |
host_key_checking = False en producción | Abre la puerta a un man-in-the-middle | Huellas verificadas en known_hosts |
Buenas prácticas de Vault
Usamos el prefijo vault_ para secretos
# ✅ BIEN: prefijo claro
vault_db_password: "secreto"
vault_api_key: "clave"
# ❌ MAL: sin prefijo, confuso
db_password: "secreto" # ¿Es la variable real o la cifrada?
Nunca descifremos en el repositorio
# ❌ MAL: Descifrar archivo (queda en texto plano)
ansible-vault decrypt secrets.yml
git add . && git commit -m "fix" # ¡Acabas de subir los secretos!
# ✅ BIEN: Solo editar o ver
ansible-vault edit secrets.yml
ansible-vault view secrets.yml
Protegemos el archivo de contraseña
# Permisos restrictivos
chmod 600 ~/.vault_pass
# Añadir a .gitignore
echo ".vault_pass" >> .gitignore
echo "*.vault_pass" >> .gitignore
Un hook que nos salve de nosotros mismos
Como red de seguridad, un hook de Git que rechace el commit si algún vault.yml no empieza por la cabecera de Vault:
#!/bin/sh
# .git/hooks/pre-commit (chmod +x)
for f in $(git diff --cached --name-only | grep 'vault\.yml$'); do
if ! head -n1 "$f" | grep -q '^\$ANSIBLE_VAULT;'; then
echo "❌ $f no está cifrado. Ejecuta: ansible-vault encrypt $f"
exit 1
fi
done
Rotamos secretos periódicamente
Rotar un secreto son dos pasos: cambiar el valor con ansible-vault edit y volver a lanzar el playbook que lo despliega. Y cada cierto tiempo (y siempre que alguien con acceso deja el equipo) cambiamos también la contraseña maestra:
ansible-vault rekey --vault-id barcelona@~/.vault_pass_barcelona \
--new-vault-id barcelona@~/.vault_pass_barcelona group_vars/barcelona/vault.yml
⚠️
rekeyno borra el historial de Git: las versiones antiguas del archivo siguen cifradas con la contraseña vieja. Si la contraseña se ha filtrado, hay que rotar los secretos, no solo la contraseña maestra.
Ejecución
# Madrid (usa su identidad automáticamente)
ansible-playbook modulo8/deploy-vault-app.yml --limit madrid
# Barcelona (solo quien tiene ~/.vault_pass_barcelona)
ansible-playbook modulo8/deploy-vault-app.yml --limit barcelona \
--vault-id barcelona@~/.vault_pass_barcelona
--limitEl playbook usa hosts: all. Si el inventario tiene hosts de los dos grupos y no limitamos la ejecución, Ansible intentará cargar los dos vault.yml. Con solo la identidad de Madrid, fallará antes de ejecutar ninguna tarea:
[ERROR]: Decryption failed (no vault secrets were found that could decrypt).
Con --limit solo se cargan las variables de los hosts que participan, y por tanto solo hace falta la contraseña de ese entorno.
📝 Resumen del capítulo
En este capítulo hemos aprendido:## Práctica: proyecto completo con Vault 🔒
Vamos a crear un proyecto desde cero con gestión profesional de secretos.
Estructura del proyecto
vault-demo/
├── ansible.cfg
├── group_vars/
│ ├── all/
│ │ └── vars.yml # Común y público
│ ├── madrid/
│ │ ├── vars.yml
│ │ └── vault.yml # Cifrado con la contraseña de Madrid
│ └── barcelona/
│ ├── vars.yml
│ └── vault.yml # Cifrado con la contraseña de Barcelona
├── playbooks/
│ └── deploy-app.yml
└── .gitignore
Cada grupo tiene su propio vault.yml. Si los secretos de Madrid estuvieran en all/, desplegar en Barcelona exigiría también la contraseña de Madrid, y al revés.
Limpieza previa
Para no tener conflictos con el vault que hemos creado al principio del capítulo, podemos eliminar los vauls anteriores y los ficheros de contraseña:
rm -f group_vars/*/vault.yml ~/.vault_pass_*
Variables públicas
# group_vars/all/vars.yml
app_name: quotes
app_image: pabpereza/quotes:latest
app_port: 8000
postgres_user: quotes
postgres_db: quotes
postgres_password: "{{ vault_db_password }}" # el único secreto
# group_vars/madrid/vars.yml
postgres_host: postgres
# group_vars/barcelona/vars.yml
postgres_host: postgres
En este ejemplo quotes y PostgreSQL se despliegan juntos en cada host y comparten una red Docker. Por eso ambos entornos usan postgres como hostname: Docker resuelve ese nombre hacia el contenedor de la base de datos. El usuario y el nombre de la base de datos tampoco son secretos: los dejamos a la vista para que se puedan revisar y buscar con grep.
Crear los Vaults
# Generar primero las contraseñas de cada identidad
openssl rand -base64 32 > ~/.vault_pass_madrid
openssl rand -base64 32 > ~/.vault_pass_barcelona
chmod 600 ~/.vault_pass_madrid ~/.vault_pass_barcelona
# Crear el Vault de Madrid usando explícitamente su identidad
ansible-vault create \
--vault-id madrid@~/.vault_pass_madrid \
--encrypt-vault-id madrid \
group_vars/madrid/vault.yml
# Contenido: vault_db_password: "madrid_password_123"
# Crear el Vault de Barcelona usando explícitamente su identidad
ansible-vault create \
--vault-id barcelona@~/.vault_pass_barcelona \
--encrypt-vault-id barcelona \
group_vars/barcelona/vault.yml
# Contenido: vault_db_password: "Barcelona_S3cur3_P@ss!"
ansible.cfg
vault_identity_list = madrid@~/.vault_pass_madrid
Una vez creados los Vaults, esta configuración permite que las ejecuciones posteriores encuentren automáticamente la identidad de Madrid. madrid es solo la etiqueta y ~/.vault_pass_madrid es el fichero que contiene la contraseña.
modulo8/deploy-vault-app.yml
- name: Desplegar aplicación como contenedor con secretos
hosts: all
become: true
tasks:
- name: Crear red Docker para quotes y PostgreSQL
community.docker.docker_network:
name: quotes_network
state: present
- name: Desplegar contenedor PostgreSQL
community.docker.docker_container:
name: postgres
image: postgres:16-alpine
state: started
restart_policy: unless-stopped
env:
POSTGRES_USER: "{{ postgres_user }}"
POSTGRES_PASSWORD: "{{ postgres_password }}"
POSTGRES_DB: "{{ postgres_db }}"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- name: quotes_network
healthcheck:
test:
- CMD-SHELL
- "pg_isready -U {{ postgres_user }} -d {{ postgres_db }}"
interval: 10s
timeout: 5s
retries: 5
no_log: true
- name: Desplegar contenedor quotes conectado a PostgreSQL
community.docker.docker_container:
name: "{{ app_name }}"
image: "{{ app_image }}"
state: started
restart_policy: unless-stopped
pull: true
ports:
- "{{ app_port }}:8000"
networks:
- name: quotes_network
env:
POSTGRES_HOST: "{{ postgres_host }}"
POSTGRES_USER: "{{ postgres_user }}"
POSTGRES_PASSWORD: "{{ postgres_password }}"
POSTGRES_DB: "{{ postgres_db }}"
no_log: true
- name: Mostrar configuración (sin secretos)
debug:
msg: |
App: {{ app_name }}
Puerto: {{ app_port }}
Postgres Host: {{ postgres_host }}
Postgres Password: ******** (oculto por seguridad)
Ahora podemos desplegar la aplicación
Hay que tener en cuenta que, si desplegamos en Madrid o Barcelona, y Ansible pedirá la contraseña de Vault correspondiente a cada entorno:
ansible-playbook modulo8/deploy-vault-app.yml --vault-id @~/.vault_pass_barcelona
El fichero de identidad de madrid se puede omitir porque ya está en ansible.cfg. Si no tenemos la contraseña de Barcelona, la ejecución fallará al intentar descifrar su Vault, por eso pasamos la relación con su fichero de contraseña explícitamente usando --vault-id barcelona@~/.vault_pass_barcelona.
.gitignore (crear en la raíz del workspace)
.vault_pass*
*.retry
*.pyc
__pycache__/
✅ **Cifrado AES-256**: Proteger archivos y variables con Ansible Vault
✅ **Comandos esenciales**: create, encrypt, decrypt, edit, view, rekey
✅ **Patrón profesional**: Separar variables públicas y secretas con prefijo `vault_`
✅ **Variables inline**: Cifrar valores individuales con `encrypt_string`
✅ **Vault IDs**: Usar diferentes contraseñas por entorno
✅ **Integración externa**: Conectar con HashiCorp Vault, AWS Secrets Manager
✅ **Claves SSH**: ed25519, ssh-agent, bastion, `known_hosts` y rotación
✅ **Variables sensibles**: `no_log`, contraseña de `sudo` en Vault, `--diff` y permisos de ficheros generados
✅ **Anti-patterns**: Errores típicos al manejar credenciales en Ansible
✅ **Buenas prácticas**: Rotación, permisos, `.gitignore` y hook de pre-commit
**Próximo paso:** [Errores y depuración](109.Errores_depuracion.md), para entender qué falla cuando un playbook se rompe y cómo encontrarlo 🐛