🎨 Sysprovider Code
Sysprovider LogoWiki
🇪🇸Hosting español para ecommerce

Automatización de aprovisionamiento con Ansible y roles personalizados

Actualizado el 21 de abril de 2026

Introducción

En el ecosistema del hosting moderno y la administración de servidores, la repetición manual de tareas de aprovisionamiento es el mayor enemigo de la consistencia, la seguridad y la escalabilidad. Cada servidor configurado "a mano" introduce una deriva de configuración (configuration drift) que, a la larga, se traduce en fallos difíciles de depurar y vulnerabilidades de seguridad.

Ansible se ha consolidado como la herramienta de automatización de facto para SysAdmins y DevOps por su simplicidad (sin agentes, basado en YAML y SSH) y su potencia. Sin embargo, el verdadero salto de calidad no está en ejecutar playbooks planos, sino en la creación y gestión de roles personalizados. Un rol es la unidad de reutilización y organización. Este artículo desglosa el flujo de trabajo profesional para aprovisionar un servidor de hosting (LAMP/LEMP) utilizando Ansible y roles personalizados, explicando el "por qué" de cada decisión.

Arquitectura de Automatización con Roles

Antes de escribir una línea de código, debemos entender la estructura. Un rol Ansible no es solo una carpeta; es una convención que separa responsabilidades: variables, tareas, handlers, templates y defaults.

Nota clave: La deriva de configuración se mitiga no solo automatizando, sino haciendo que la automatización sea idempotente. Un rol bien diseñado debe poder ejecutarse 100 veces sobre el mismo servidor sin causar cambios no deseados después de la primera ejecución.

Estructura de Directorios Recomendada

ansible-hosting/
├── ansible.cfg
├── inventory/
│   ├── production/
│   │   ├── hosts.ini
│   │   └── group_vars/
│   │       └── webservers.yml
│   └── staging/
│       └── hosts.ini
├── playbooks/
│   ├── site.yml
│   └── setup-webserver.yml
└── roles/
    ├── common/
    │   ├── tasks/
    │   ├── handlers/
    │   └── defaults/
    ├── nginx/
    │   ├── tasks/
    │   ├── templates/
    │   ├── vars/
    │   └── handlers/
    ├── php-fpm/
    ├── mariadb/
    └── deploy-app/

El uso de ansible.cfg nos permite definir comportamientos globales:

# ansible.cfg
[defaults]
inventory = ./inventory/production/hosts.ini
host_key_checking = False
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_cache
roles_path = ./roles

¿Por qué gathering = smart? Porque solo recopila hechos (facts) si no están cacheados, acelerando ejecuciones subsecuentes. El fact caching es crucial en infraestructuras con decenas de servidores.

Fase 1: Aprovisionamiento Base (Rol common)

El primer rol que cualquier servidor debe ejecutar es el de hardening y configuración base. No tiene sentido instalar Nginx en un sistema sin firewall ni repositorios actualizados.

roles/common/tasks/main.yml

---
- name: Actualizar cache de apt (Debian/Ubuntu)
  apt:
    update_cache: yes
    cache_valid_time: 3600
  when: ansible_os_family == "Debian"

- name: Instalar paquetes esenciales
  apt:
    name: "{{ common_packages }}"
    state: present
  vars:
    common_packages:
      - curl
      - wget
      - git
      - ufw
      - fail2ban
      - unattended-upgrades

- name: Configurar UFW - Políticas por defecto
  ufw:
    direction: "{{ item.direction }}"
    policy: "{{ item.policy }}"
  loop:
    - { direction: 'incoming', policy: 'deny' }
    - { direction: 'outgoing', policy: 'allow' }

- name: Permitir SSH y HTTP/HTTPS
  ufw:
    rule: allow
    port: "{{ item }}"
    proto: tcp
  loop:
    - "22"
    - "80"
    - "443"

- name: Habilitar UFW
  ufw:
    state: enabled

¿Por qué cache_valid_time? Evita forzar una actualización de repositorios en cada ejecución. En producción, actualizar cada 3600 segundos (1 hora) es un balance óptimo entre seguridad y rendimiento.

Fase 2: Instalación y Configuración de Nginx (Rol nginx)

Aquí es donde la personalización y el uso de templates marcan la diferencia. No copiamos archivos de configuración estáticos; los generamos dinámicamente.

Variables del Rol (roles/nginx/vars/main.yml)

---
nginx_user: www-data
nginx_worker_processes: "{{ ansible_processor_cores }}"
nginx_worker_connections: 1024
nginx_sendfile: "on"
nginx_tcp_nopush: "on"
nginx_keepalive_timeout: 65
nginx_client_max_body_size: 64M

Nota importante: El uso de ansible_processor_cores es un fact dinámico. Si migras el servidor a un hardware con más núcleos, Ansible ajustará automáticamente el número de workers de Nginx en el próximo playbook run.

Plantilla (roles/nginx/templates/nginx.conf.j2)

{% raw %}
user {{ nginx_user }};
worker_processes {{ nginx_worker_processes }};
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections {{ nginx_worker_connections }};
    multi_accept on;
    use epoll;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';

    access_log /var/log/nginx/access.log main;

    sendfile {{ nginx_sendfile }};
    tcp_nopush {{ nginx_tcp_nopush }};
    keepalive_timeout {{ nginx_keepalive_timeout }};
    client_max_body_size {{ nginx_client_max_body_size }};

    # Gzip
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;
}
{% endraw %}

¿Por qué gzip_types explícito? Porque por defecto Nginx solo comprime texto plano. Para un servidor de hosting que sirve APIs JSON o CSS/JS, es obligatorio definir los tipos MIME.

Tasks del Rol (roles/nginx/tasks/main.yml)

---
- name: Añadir repositorio oficial de Nginx
  apt_repository:
    repo: "deb http://nginx.org/packages/ubuntu {{ ansible_distribution_release }} nginx"
    state: present
    update_cache: yes
  when: ansible_distribution == "Ubuntu"

- name: Instalar Nginx
  apt:
    name: nginx
    state: latest

- name: Renderizar configuración principal
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    owner: root
    group: root
    mode: '0644'
  notify: recargar nginx

- name: Eliminar sitio por defecto
  file:
    path: /etc/nginx/sites-enabled/default
    state: absent
  notify: recargar nginx

- name: Asegurar que Nginx está iniciado y habilitado
  systemd:
    name: nginx
    state: started
    enabled: yes

¿Por qué state: latest para Nginx? En un servidor de hosting expuesto a internet, las vulnerabilidades en el servidor web son críticas. Usar latest asegura que siempre tengamos el último parche de seguridad, asumiendo que el repositorio oficial está configurado.

Handler (roles/nginx/handlers/main.yml)

---
- name: recargar nginx
  systemd:
    name: nginx
    state: reloaded

El handler solo se ejecuta si la tarea lo notifica (cuando el archivo de configuración cambia). Esto evita recargas innecesarias y downtime.

Fase 3: PHP-FPM con Roles Personalizados y Pooles

Un servidor de hosting moderno rara vez ejecuta un solo sitio. La gestión de múltiples versiones de PHP y pooles aislados es un caso de uso perfecto para roles parametrizados.

Estructura del Rol php-fpm

roles/php-fpm/
├── defaults/
│   └── main.yml
├── tasks/
│   └── main.yml
├── templates/
│   └── pool.conf.j2
└── vars/
    └── main.yml

roles/php-fpm/defaults/main.yml

---
php_version: "8.1"
php_packages:
  - "php{{ php_version }}-fpm"
  - "php{{ php_version }}-cli"
  - "php{{ php_version }}-mysql"
  - "php{{ php_version }}-curl"
  - "php{{ php_version }}-xml"
  - "php{{ php_version }}-mbstring"
php_memory_limit: "256M"
php_max_execution_time: 300
php_upload_max_filesize: "64M"
php_post_max_size: "68M"
php_pools: []

¿Por qué php_pools: []? Este rol está diseñado para ser invocado múltiples veces con diferentes variables. La lista de pooles se pasará desde el playbook o desde variables de grupo.

Template del Pool (templates/pool.conf.j2)

{% raw %}
[{{ pool_name }}]
user = {{ pool_user }}
group = {{ pool_group }}
listen = /var/run/php/php{{ php_version }}-fpm-{{ pool_name }}.sock
listen.owner = {{ nginx_user }}
listen.group = {{ nginx_user }}
listen.mode = 0660

pm = dynamic
pm.max_children = {{ pool_max_children | default(5) }}
pm.start_servers = {{ pool_start_servers | default(2) }}
pm.min_spare_servers = {{ pool_min_spare | default(1) }}
pm.max_spare_servers = {{ pool_max_spare | default(3) }}
pm.max_requests = 500

security.limit_extensions = .php .phar

env[APP_ENV] = {{ pool_app_env | default('production') }}
{% endraw %}

¿Por qué listen.mode = 0660? El socket de PHP debe ser legible y escribible por Nginx (www-data) pero no por otros usuarios del sistema. Esto es crítico en entornos multi-tenant para evitar que un sitio comprometido acceda al socket de otro.

Tasks del Rol (tasks/main.yml)

---
- name: Añadir repositorio de Ondrej PHP (Ubuntu)
  apt_repository:
    repo: "ppa:ondrej/php"
    state: present
    update_cache: yes
  when: ansible_distribution == "Ubuntu"

- name: Instalar PHP {{ php_version }} y paquetes
  apt:
    name: "{{ php_packages }}"
    state: present

- name: Configurar php.ini para producción
  lineinfile:
    path: "/etc/php/{{ php_version }}/fpm/php.ini"
    regexp: "^{{ item.key }}"
    line: "{{ item.key }} = {{ item.value }}"
  loop:
    - { key: 'memory_limit', value: "{{ php_memory_limit }}" }
    - { key: 'max_execution_time', value: "{{ php_max_execution_time }}" }
    - { key: 'upload_max_filesize', value: "{{ php_upload_max_filesize }}" }
    - { key: 'post_max_size', value: "{{ php_post_max_size }}" }
  notify: reiniciar php-fpm

- name: Renderizar pooles de PHP-FPM
  template:
    src: pool.conf.j2
    dest: "/etc/php/{{ php_version }}/fpm/pool.d/{{ item.pool_name }}.conf"
    owner: root
    group: root
    mode: '0644'
  loop: "{{ php_pools }}"
  notify: reiniciar php-fpm

Fase 4: Playbook de Orquestación y Variables por Entorno

Ahora unimos todo. El playbook principal (playbooks/site.yml) orquestará los roles en el orden correcto.

---
- hosts: all
  gather_facts: yes
  become: yes

  roles:
    - role: common
      tags: [common, base]

- hosts: webservers
  become: yes

  roles:
    - role: nginx
      tags: [nginx, web]

    - role: php-fpm
      tags: [php, web]
      vars:
        php_version: "8.2"
        php_pools:
          - pool_name: "cliente_a"
            pool_user: "clientea"
            pool_group: "clientea"
            pool_max_children: 10
            pool_app_env: "staging"
          - pool_name: "cliente_b"
            pool_user: "clienteb"
            pool_group: "clienteb"
            pool_max_children: 20

Variables de Grupo (inventory/production/group_vars/webservers.yml)

---
nginx_worker_connections: 2048
nginx_client_max_body_size: 128M

php_memory_limit: "512M"
php_pools:
  - pool_name: "cliente_c"
    pool_user: "clientec"
    pool_group: "clientec"
    pool_max_children: 50

Nota de diseño: Las variables definidas en el playbook tienen prioridad sobre las de grupo, y estas sobre las de rol. Esto permite un control granular: valores por defecto en el rol, valores por entorno en group_vars, y excepciones en el playbook.

Fase 5: Despliegue de Aplicación con Roles Compuestos

El rol deploy-app es un ejemplo de un rol que consume otros roles y ejecuta lógica de negocio. Aquí desplegamos una aplicación PHP simple.

roles/deploy-app/tasks/main.yml

---
- name: Crear usuario del sistema para el sitio
  user:
    name: "{{ app_user }}"
    shell: /bin/bash
    home: "{{ app_root }}"
    create_home: yes
    system: yes

- name: Crear estructura de directorios
  file:
    path: "{{ item }}"
    state: directory
    owner: "{{ app_user }}"
    group: "{{ app_user }}"
    mode: '0755'
  loop:
    - "{{ app_root }}"
    - "{{ app_root }}/public"
    - "{{ app_root }}/logs"

- name: Configurar virtualhost de Nginx
  template:
    src: vhost.conf.j2
    dest: "/etc/nginx/sites-available/{{ app_domain }}.conf"
    owner: root
    group: root
    mode: '0644'
  notify: recargar nginx

- name: Habilitar sitio
  file:
    src: "/etc/nginx/sites-available/{{ app_domain }}.conf"
    dest: "/etc/nginx/sites-enabled/{{ app_domain }}.conf"
    state: link
  notify: recargar nginx

- name: Clonar repositorio de la aplicación
  git:
    repo: "{{ app_repo }}"
    dest: "{{ app_root }}"
    version: "{{ app_version | default('main') }}"
    accept_hostkey: yes
  become_user: "{{ app_user }}"

- name: Configurar permisos de archivos
  file:
    path: "{{ app_root }}"
    state: directory
    recurse: yes
    owner: "{{ app_user }}"
    group: "{{ app_user }}"
    mode: '0755'

- name: Asegurar permisos de escritura para cache
  file:
    path: "{{ app_root }}/var/cache"
    state: directory
    mode: '0777'
  when: app_has_cache | default(false)

Template de VirtualHost (templates/vhost.conf.j2)

{% raw %}
server {
    listen 80;
    server_name {{ app_domain }} www.{{ app_domain }};
    root {{ app_root }}/public;

    index index.php index.html;

    access_log {{ app_root }}/logs/access.log;
    error_log {{ app_root }}/logs/error.log

¿Necesitas ayuda?Son dos de nuestros técnicos, Agustín y Mikel, y están disponibles para resolver cualquier problema.

Hablar con ellos ahora
Agustín y Mikel