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:
- Network — configure networking
- Config — set hostname, timezone, SSH keys, users
- 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
- Keep cloud-init minimal — bootstrap users, SSH keys, and Ansible; let Ansible do the rest
- Use pull mode for auto-scaling — instances configure themselves from Git
- Validate user-data — run
cloud-init schema --config-filebefore deploying - Wait for completion — always
cloud-init status --waitbefore running Ansible - Idempotent user-data — design for re-runs (cloud-init can re-run on reboot)
- Template per role — different user-data for web, database, cache servers
Related Articles
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.