cloud-init es el servicio que configura un servidor Linux en su primer arranque: crea usuarios, instala claves SSH, fija el nombre de host, instala paquetes y ejecuta comandos a partir de un archivo que recibe como user-data. Viene preinstalado en las imágenes cloud de Ubuntu, Debian, Rocky Linux y la mayoría de distribuciones, y es lo que usan casi todos los proveedores para preparar cada servidor nuevo. En este tutorial escribirás un archivo cloud-config que deja listo un servidor web con Nginx, un usuario administrador y un cortafuegos, lo validarás y lo probarás en un contenedor LXD en Ubuntu 24.04 antes de usarlo en un servidor real.

Requisitos previos

Para seguir esta guía necesitas:

  • Un servidor o equipo con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con un usuario no root con privilegios sudo. Se usará para validar el archivo y lanzar contenedores de prueba.
  • Una clave SSH pública (por ejemplo, el contenido de ~/.ssh/id_ed25519.pub de tu equipo). Si no tienes una, créala en tu equipo con ssh-keygen -t ed25519.
  • Unos 5 GB libres en disco para la imagen de prueba de LXD.

Cómo funciona cloud-init

En cada arranque, cloud-init busca una fuente de datos (datasource): el servicio de metadatos del proveedor, un CD con etiqueta cidata, la configuración de LXD, etc. De ella obtiene los metadatos de la instancia y el user-data. Si el identificador de instancia es nuevo, ejecuta sus módulos en cuatro etapas:

EtapaQué hace
init-localLocaliza la fuente de datos y aplica la configuración de red
initConfigura el nombre de host, las claves SSH de host, usuarios y grupos
configEjecuta módulos de configuración como write_files o timezone
finalInstala paquetes, ejecuta runcmd y scripts de usuario

La mayoría de módulos se ejecutan una sola vez por instancia. Por eso, si cambias el user-data de un servidor ya creado, cloud-init no lo vuelve a aplicar: está pensado para el primer arranque, no para gestionar la configuración a lo largo del tiempo.

El user-data puede ser un script (empieza por #!) o, lo más habitual, un documento YAML cloud-config, que debe empezar exactamente por la línea #cloud-config.

Paso 1: Escribir el archivo cloud-config

Crea un directorio de trabajo y el archivo:

mkdir -p ~/cloud-init && cd ~/cloud-init
nano user-data.yaml

Pega el siguiente contenido y sustituye la clave SSH por la tuya:

#cloud-config
hostname: web01
timezone: Europe/Madrid

users:
  - name: deploy
    gecos: Usuario de despliegue
    groups: [sudo]
    shell: /bin/bash
    sudo: "ALL=(ALL) NOPASSWD:ALL"
    lock_passwd: true
    ssh_authorized_keys:
      - ssh-ed25519 AAAA... tu_usuario@tu_equipo

ssh_pwauth: false
disable_root: true

package_update: true
package_upgrade: true
packages:
  - nginx
  - ufw
  - fail2ban

write_files:
  - path: /etc/ssh/sshd_config.d/10-hardening.conf
    permissions: "0644"
    content: |
      PermitRootLogin no
      PasswordAuthentication no
      KbdInteractiveAuthentication no
  - path: /var/www/html/index.html
    permissions: "0644"
    defer: true
    content: |
      <h1>web01 aprovisionado con cloud-init</h1>

runcmd:
  - ufw allow OpenSSH
  - ufw allow "Nginx Full"
  - ufw --force enable
  - systemctl enable --now nginx fail2ban
  - systemctl try-reload-or-restart ssh

final_message: "cloud-init terminó en $UPTIME segundos"

Qué hace cada bloque:

  • hostname y timezone: nombre de host y zona horaria.
  • users: crea deploy con sudo sin contraseña y acceso solo por clave. Al definir users, el usuario por defecto de la imagen (ubuntu) ya no se crea; si lo quieres también, añade - default como primer elemento de la lista.
  • ssh_pwauth y disable_root: desactivan el acceso SSH por contraseña y el login como root.
  • package_update, package_upgrade y packages: ejecutan apt update, actualizan el sistema e instalan los paquetes indicados.
  • write_files: crea archivos con su contenido y permisos. defer: true retrasa la escritura hasta la etapa final, después de instalar paquetes; sin eso, el paquete nginx sobrescribiría el index.html.
  • runcmd: comandos que se ejecutan una vez, al final, como root. Cada elemento se ejecuta con sh, así que escribe comandos simples o usa listas (- [systemctl, restart, nginx]) para evitar problemas de comillas.
  • final_message: aparece en el log cuando todo ha terminado.

Paso 2: Validar el archivo

Un error de indentación en YAML hace que cloud-init ignore partes del archivo sin detener el arranque, así que valídalo siempre antes de usarlo. Ubuntu 24.04 incluye cloud-init con el comando schema:

cloud-init schema --config-file user-data.yaml --annotate
Valid schema user-data.yaml

Si hay un error, --annotate lo marca junto a la línea afectada. Por ejemplo, con package_upgrade: si en lugar de un booleano:

package_upgrade: si		# E1
...
# Errors: -------------
# E1: 'si' is not of type 'boolean'

Paso 3: Preparar LXD para las pruebas

LXD puede crear contenedores de Ubuntu con cloud-init en segundos y pasarles tu user-data, así que es la forma más rápida de probar sin crear servidores reales. Instálalo con snap y aplica la configuración por defecto:

sudo snap install lxd
sudo usermod -aG lxd "$USER"
newgrp lxd
lxd init --auto

Comprueba que funciona:

lxc list
+------+-------+------+------+------+-----------+
| NAME | STATE | IPV4 | IPV6 | TYPE | SNAPSHOTS |
+------+-------+------+------+------+-----------+

Paso 4: Lanzar un contenedor con el user-data

Crea un contenedor Ubuntu 24.04 y pásale el archivo con la clave cloud-init.user-data:

lxc launch ubuntu:24.04 web01 --config=cloud-init.user-data="$(cat user-data.yaml)"

Espera a que cloud-init termine. El comando se queda bloqueado hasta entonces:

lxc exec web01 -- cloud-init status --wait --long
.....................
status: done
extended_status: done
boot_status_code: enabled-by-generator
last_update: Thu, 25 Sep 2026 10:42:18 +0000
detail: DataSourceLXD
errors: []
recoverable_errors: {}

status: done con errors: [] indica que todos los módulos terminaron bien. Si ves status: error, pasa a la sección de depuración.

Paso 5: Verificar el resultado

Comprueba cada parte de la configuración dentro del contenedor.

Nombre de host, zona horaria y usuario:

lxc exec web01 -- hostname
lxc exec web01 -- timedatectl show -p Timezone --value
lxc exec web01 -- id deploy
web01
Europe/Madrid
uid=1000(deploy) gid=1000(deploy) groups=1000(deploy),27(sudo)

Cortafuegos y servicios:

lxc exec web01 -- ufw status
lxc exec web01 -- systemctl is-active nginx fail2ban
Status: active

To                         Action      From
--                         ------      ----
OpenSSH                    ALLOW       Anywhere
Nginx Full                 ALLOW       Anywhere
...
active
active

Página web desde el host. Obtén la IP del contenedor y haz una petición:

IP=$(lxc list web01 -c 4 --format csv | cut -d' ' -f1)
curl -s "http://$IP"
<h1>web01 aprovisionado con cloud-init</h1>

Por último, entra por SSH con tu clave como deploy. Desde el propio host, si la clave privada está ahí, o desde tu equipo si el host enruta hacia el contenedor:

ssh deploy@"$IP"

Paso 6: Depurar cloud-init

Cuando algo no se aplica, estas son las fuentes de información, de más a menos útil:

lxc exec web01 -- cat /var/log/cloud-init-output.log

cloud-init-output.log contiene la salida de apt, de runcmd y de los scripts. Es donde aparecen los errores de tus comandos.

lxc exec web01 -- grep -E "WARNING|ERROR|Traceback" /var/log/cloud-init.log

cloud-init.log es el log interno de cloud-init: qué módulos se ejecutaron, cuáles fallaron y por qué.

Para ver el user-data que realmente recibió la instancia (útil si sospechas que la fuente de datos no lo entregó):

lxc exec web01 -- cloud-init query userdata

Y para saber cuánto tardó cada etapa y módulo:

lxc exec web01 -- cloud-init analyze blame
-- Boot Record 01 --
     41.20300s (modules-final/config-package_update_upgrade_install)
     03.11200s (modules-final/config-scripts_user)
     ...

Volver a ejecutar cloud-init

Mientras ajustas el archivo, lo más rápido es borrar el contenedor y crear otro:

lxc delete --force web01

En un servidor real puedes forzar que cloud-init se ejecute de nuevo como si fuera el primer arranque. Esto vuelve a aplicar todos los módulos con el user-data actual del proveedor y reinicia el servidor, así que úsalo solo en servidores de prueba:

sudo cloud-init clean --logs --reboot

Paso 7: Usar el archivo en un servidor real

El mismo archivo sirve para cualquier plataforma que admita user-data:

  • Proveedores cloud: pégalo en el campo de user-data o datos de usuario al crear el servidor, si el proveedor lo ofrece.
  • Terraform y Pulumi: la mayoría de proveedores tienen un argumento user_data (por ejemplo, user_data = file("user-data.yaml")).
  • Máquinas virtuales locales o Proxmox: genera un disco NoCloud con cloud-localds seed.iso user-data.yaml (paquete cloud-image-utils) y adjúntalo como CD-ROM a una cloud image.

Antes de desplegarlo, recuerda dos límites:

  • Muchos proveedores limitan el tamaño del user-data (16 KB es habitual). Si crece mucho, deja que cloud-init solo prepare el acceso y lance una herramienta de configuración como Ansible.
  • El user-data suele ser legible desde dentro del servidor a través del servicio de metadatos. No incluyas contraseñas ni tokens en él.

Solución de problemas

El archivo se ignora por completo: la primera línea no es exactamente #cloud-config (sin espacios delante ni después de #). Compruébalo con cloud-init query userdata.

Algunos módulos no se aplican y no hay error visible: suele ser una clave mal escrita o mal indentada. Ejecuta cloud-init schema --config-file user-data.yaml --annotate y, dentro de la instancia, sudo cloud-init schema --system --annotate para validar lo que recibió.

El index.html es el de Nginx por defecto: falta defer: true en write_files, y el paquete sobrescribió el archivo.

No puedes entrar por SSH con el usuario ubuntu: al definir users sin - default, ese usuario no se crea. Usa deploy o añade - default a la lista.

Conclusión

Has escrito un archivo cloud-config que deja un servidor listo con usuario administrador, SSH endurecido, paquetes actualizados, Nginx y cortafuegos, lo has validado con cloud-init schema y lo has probado en LXD en menos de un minuto por iteración. Como siguientes pasos, pasa el mismo archivo como user_data desde Terraform, combina cloud-init para el primer arranque con Ansible para la configuración continua, y usa plantillas Jinja (primera línea ## template: jinja) para adaptar el archivo a los metadatos de cada instancia.