Saltar al contenido principal

Seguridad en Ansible: Vault, SSH y credenciales

Gestión profesional de secretos: contraseñas, claves API y certificados cifrados.

Video pendiente de grabación

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...ComandoResultado
Secreto en texto planoansible-vault encryptArchivo cifrado AES-256
Archivo cifradoansible-vault viewLo vemos sin modificarlo
Archivo cifradoansible-vault editLo editamos y se vuelve a cifrar
Archivo cifradoansible-vault decryptVuelve a texto plano (con cuidado)
Archivo cifradoansible-playbook --ask-vault-passAnsible 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?​

AspectoTodo cifradoPatrón separado
Git diffsInútiles (todo cifrado)Claros para variables públicas
AuditoríaNecesitamos descifrar para verVemos qué variables existen sin descifrar
Búsquedagrep no funcionagrep db_password vars.yml funciona
Revisión de códigoImposible sin contraseñaSe 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 label no restringe nada

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​

  1. Nada de contraseñas SSH: autenticación por clave pública siempre.
  2. Una clave por identidad: humanos, CI/CD y bastion con claves distintas. Si cae la del CI, no caen todas.
  3. ed25519 por defecto: más corta, más rápida y más segura que RSA.
  4. Passphrase en las claves de humanos. Las de CI pueden no llevarla, pero entonces las rotamos a menudo.
  5. 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:

MecanismoCuándo usarlo
--private-key=ruta/clavePruebas locales rápidas
ansible_ssh_private_key_file en group_varsUna clave distinta por entorno, definida en el inventario
ssh-agentLo 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_hash genera un hash distinto en cada ejecución y la tarea sale changed siempre, 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 -K en cada ejecución o, peor, dejarla en el inventario.
  • Credenciales de conexión que no sean claves SSH, como ansible_password en 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 un template genera un .env con contraseñas, las veremos en el diff. Solución: diff: false en esa tarea.
  • Ficheros generados en el host: un .env o un config.ini con secretos debe desplegarse con mode: "0600" y el owner de la aplicación, no con los permisos por defecto.
  • Fact cache: si guardamos un secreto con set_fact y cacheable: true y tenemos activado un fact cache persistente (JSON, Redis), acaba escrito en disco en texto plano.

Anti-patterns que conviene evitar​

Anti-patternPor qué es maloAlternativa
debug: var=vault_db_password para depurarImprime el secreto en pantalla y en logsUn assert que compruebe el valor sin mostrarlo
Pasar secretos con -e password=...Quedan en el historial de la shell y en los logs del CIVault o variables de entorno
command: "mysql -p{{ vault_db_password }} ..."El secreto se ve en la lista de procesos con psEl módulo nativo (community.mysql.mysql_user)
Misma clave SSH para humanos y CISi cae el CI, caen todosUna clave por identidad
host_key_checking = False en producciónAbre la puerta a un man-in-the-middleHuellas 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

⚠️ rekey no 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
No olvides el --limit

El 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 🐛