Introduction

cloud-init is the industry standard for configuring cloud instances at first boot. Every major cloud provider (AWS, Azure, GCP) and virtualization platform (Proxmox, VMware, OpenStack) supports it. Ansible integrates with cloud-init in two powerful patterns: generating cloud-init user-data to bootstrap VMs, and using cloud-init to bootstrap Ansible (pull mode) for ongoing configuration. This guide covers both approaches.

How cloud-init Works

┌─────────────────┐     ┌──────────────────┐     ┌──────────────────┐
│  Cloud Provider  │────►│  Cloud Image     │────►│  cloud-init      │
│  (user-data)    │     │  (Ubuntu/RHEL)   │     │  runs at boot    │
│                 │     │                   │     │  → sets hostname  │
│  metadata       │     │                   │     │  → creates users  │
│  network-config │     │                   │     │  → installs pkgs  │
└─────────────────┘     └──────────────────┘     │  → runs commands  │
                                                  └──────────────────┘

cloud-init runs in stages:

  1. Network — configure networking
  2. Config — set hostname, timezone, SSH keys, users
  3. Final — install packages, run commands, execute scripts

Pattern 1: Ansible Generates cloud-init User-Data

Template user-data with Ansible

---
- name: Provision VMs with cloud-init user-data
  hosts: localhost
  gather_facts: false
  vars:
    vms:
      - name: web01
        ip: 10.0.1.10
        role: webserver
      - name: web02
        ip: 10.0.1.11
        role: webserver
      - name: db01
        ip: 10.0.2.10
        role: database
  tasks:
    - name: Generate cloud-init user-data for each VM
      ansible.builtin.template:
        src: cloud-init-userdata.yml.j2
        dest: "/tmp/cloud-init/{{ item.name }}-userdata.yml"
      loop: "{{ vms }}"

    - name: Create VMs with cloud-init (Proxmox example)
      community.general.proxmox_kvm:
        api_host: proxmox.example.com
        api_user: root@pam
        api_token_id: ansible
        api_token_secret: "{{ vault_proxmox_token }}"
        node: pve1
        name: "{{ item.name }}"
        clone: ubuntu-2404-template
        full: true
        cicustom: "user=local:snippets/{{ item.name }}-userdata.yml"
        ipconfig0: "ip={{ item.ip }}/24,gw=10.0.1.1"
        nameservers: "10.0.1.1"
        cores: 2
        memory: 4096
        state: present
      loop: "{{ vms }}"

cloud-init User-Data Template

# templates/cloud-init-userdata.yml.j2
#cloud-config
hostname: {{ item.name }}
fqdn: {{ item.name }}.example.com
manage_etc_hosts: true

# Users
users:
  - name: ansible
    groups: sudo
    shell: /bin/bash
    sudo: ALL=(ALL) NOPASSWD:ALL
    ssh_authorized_keys:
      - {{ lookup('file', '~/.ssh/id_ed25519.pub') }}

# Packages
package_update: true
package_upgrade: true
packages:
  - python3
  - python3-pip
  - qemu-guest-agent
{% if item.role == 'webserver' %}
  - nginx
{% elif item.role == 'database' %}
  - postgresql
  - postgresql-contrib
{% endif %}

# Timezone
timezone: UTC

# NTP
ntp:
  enabled: true

# SSH hardening
ssh_pwauth: false
disable_root: true

# Write files
write_files:
  - path: /etc/ansible/facts.d/role.fact
    content: |
      [general]
      role={{ item.role }}
    permissions: '0644'

# Run commands at first boot
runcmd:
  - systemctl enable --now qemu-guest-agent
{% if item.role == 'webserver' %}
  - systemctl enable --now nginx
{% endif %}
  # Signal that cloud-init is complete
  - touch /var/lib/cloud/instance/boot-finished

# Final message
final_message: "cloud-init complete for {{ item.name }} after $UPTIME seconds"

Pattern 2: cloud-init Bootstraps Ansible Pull Mode

The most powerful pattern — cloud-init installs Ansible and runs ansible-pull at first boot:

# cloud-init-ansible-pull.yml
#cloud-config
hostname: ${hostname}

users:
  - name: ansible
    groups: sudo
    shell: /bin/bash
    sudo: ALL=(ALL) NOPASSWD:ALL
    ssh_authorized_keys:
      - ssh-ed25519 AAAA... admin@company.com

package_update: true
packages:
  - python3
  - python3-pip
  - git

runcmd:
  # Install Ansible
  - pip3 install ansible

  # Run ansible-pull to configure the host
  - >-
    ansible-pull
    --url https://github.com/myorg/ansible-infra.git
    --checkout main
    --inventory localhost,
    --extra-vars "role=${role} environment=${environment}"
    --accept-host-key
    local.yml

  # Set up recurring ansible-pull via cron
  - |
    cat > /etc/cron.d/ansible-pull << 'EOF'
    */30 * * * * ansible ansible-pull --url https://github.com/myorg/ansible-infra.git --checkout main --inventory localhost, local.yml >> /var/log/ansible-pull.log 2>&1
    EOF

The Pull Playbook (local.yml)

# local.yml (in the Git repo)
---
- name: Configure host based on role
  hosts: localhost
  connection: local
  become: true
  vars:
    role: "{{ role | default('base') }}"
  roles:
    - base
    - "{{ role }}"

AWS EC2 with cloud-init

---
- name: Launch EC2 with cloud-init
  hosts: localhost
  gather_facts: false
  tasks:
    - name: Launch instance
      amazon.aws.ec2_instance:
        name: "web01"
        instance_type: t3.medium
        image_id: ami-0123456789abcdef0  # Ubuntu 24.04
        key_name: my-key
        security_groups:
          - web-sg
        subnet_id: subnet-12345
        user_data: "{{ lookup('template', 'cloud-init-userdata.yml.j2') }}"
        tags:
          Role: webserver
          Environment: production
        state: running
      register: ec2

    - name: Wait for cloud-init to complete
      ansible.builtin.wait_for:
        host: "{{ ec2.instances[0].public_ip_address }}"
        port: 22
        delay: 30
        timeout: 600

    - name: Wait for cloud-init signal
      ansible.builtin.command: >
        ssh -o StrictHostKeyChecking=no ansible@{{ ec2.instances[0].public_ip_address }}
        cloud-init status --wait
      register: cloud_init_status
      until: "'done' in cloud_init_status.stdout"
      retries: 30
      delay: 10

Azure VM with cloud-init

- name: Create Azure VM with cloud-init
  azure.azcollection.azure_rm_virtualmachine:
    resource_group: myResourceGroup
    name: web01
    vm_size: Standard_B2s
    admin_username: ansible
    ssh_password_enabled: false
    ssh_public_keys:
      - path: /home/ansible/.ssh/authorized_keys
        key_data: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
    image:
      offer: 0001-com-ubuntu-server-noble
      publisher: Canonical
      sku: "24_04-lts"
      version: latest
    custom_data: "{{ lookup('template', 'cloud-init-userdata.yml.j2') | b64encode }}"

Proxmox with cloud-init

- name: Create Proxmox VM with cloud-init
  community.general.proxmox_kvm:
    api_host: proxmox.example.com
    api_user: root@pam
    api_token_id: ansible
    api_token_secret: "{{ vault_proxmox_token }}"
    node: pve1
    name: web01
    clone: ubuntu-2404-cloud
    full: true
    ciuser: ansible
    cipassword: "{{ vault_ci_password }}"
    sshkeys: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
    ipconfig0: "ip=10.0.1.10/24,gw=10.0.1.1"
    nameservers: "10.0.1.1"
    searchdomains: "example.com"
    cicustom: "user=local:snippets/web01-userdata.yml"
    cores: 2
    memory: 4096
    scsihw: virtio-scsi-single
    state: present

Validate cloud-init Config

- name: Validate cloud-init user-data
  hosts: localhost
  tasks:
    - name: Generate user-data
      ansible.builtin.template:
        src: cloud-init-userdata.yml.j2
        dest: /tmp/userdata.yml

    - name: Validate with cloud-init
      ansible.builtin.command: cloud-init schema --config-file /tmp/userdata.yml
      register: validation
      changed_when: false

    - name: Show validation result
      ansible.builtin.debug:
        var: validation.stdout

Wait for cloud-init Completion

- name: Wait for cloud-init on new VMs
  hosts: new_vms
  gather_facts: false
  tasks:
    - name: Wait for SSH
      ansible.builtin.wait_for_connection:
        timeout: 600

    - name: Wait for cloud-init to finish
      ansible.builtin.command: cloud-init status --wait
      register: ci_status
      changed_when: false
      timeout: 300

    - name: Verify cloud-init succeeded
      ansible.builtin.assert:
        that:
          - "'done' in ci_status.stdout"
          - "'error' not in ci_status.stdout"
        fail_msg: "cloud-init failed: {{ ci_status.stdout }}"

    - name: Now run full Ansible configuration
      ansible.builtin.include_role:
        name: "{{ item }}"
      loop:
        - base
        - monitoring
        - "{{ host_role }}"

Troubleshooting

Check cloud-init Logs

- name: Debug cloud-init issues
  ansible.builtin.command: "{{ item }}"
  loop:
    - cloud-init status --long
    - cat /var/log/cloud-init-output.log
    - cat /var/log/cloud-init.log
  register: debug_output
  changed_when: false

- ansible.builtin.debug:
    msg: "{{ debug_output.results | map(attribute='stdout_lines') | list }}"

cloud-init Won't Re-Run

# Force cloud-init to re-run (clears instance state)
sudo cloud-init clean --logs
sudo reboot

Best Practices

  1. Keep cloud-init minimal — bootstrap users, SSH keys, and Ansible; let Ansible do the rest
  2. Use pull mode for auto-scaling — instances configure themselves from Git
  3. Validate user-data — run cloud-init schema --config-file before deploying
  4. Wait for completion — always cloud-init status --wait before running Ansible
  5. Idempotent user-data — design for re-runs (cloud-init can re-run on reboot)
  6. Template per role — different user-data for web, database, cache servers

Conclusion

cloud-init and Ansible are complementary — cloud-init handles first-boot essentials (users, SSH keys, network, basic packages) while Ansible handles the full configuration. Use Ansible to template cloud-init user-data for consistent VM provisioning across AWS, Azure, Proxmox, and any cloud. For auto-scaling fleets, embed ansible-pull in cloud-init so every new instance configures itself from Git with zero manual intervention.