Scope. This article covers cloud-init 26.2 on Ubuntu 24.04 LTS and ansible-core 2.21. It draws the boundary between what cloud-init should handle at first boot and what belongs to Ansible. It does not cover Heat orchestration, Ansible playbook development, or multi-cloud datasource configuration.
The Problem: Two Tools, No Boundary
You build a cloud image once. Every instance boots from a copy of that image. In the next sixty seconds, cloud-init runs, reads its instructions, and configures the instance. Then Ansible connects over SSH and applies the configuration you wrote in your playbooks.
Both tools can install packages. Both can create users. Both can write files and run commands. Without an explicit boundary, you end up with two tools doing overlapping work, and neither of them owns the result. A package installed by cloud-init drifts because Ansible does not know about it. A user created by Ansible fails because cloud-init already created one with different parameters.
The boundary is simple in principle: cloud-init handles identity, Ansible handles configuration. Cloud-init makes the instance reachable. Ansible makes it useful. This article explains where that line falls in practice, and what goes wrong when you cross it.
The Four Boot Stages of Cloud-Init
Cloud-init does not run as a single script. It executes in four distinct stages during the boot sequence, each with a different scope and different constraints.
Stage 1: init-local. Runs before the network is up. Cloud-init reads its configuration from local sources only — a mounted disk, a kernel command-line parameter, or a config drive attached to the instance. No network request is possible. This is where the datasource is identified.
Stage 2: init-network. Runs after networking is configured. Cloud-init contacts the metadata service (or reads from NoCloud) to retrieve the full instance configuration. SSH host keys are generated. The instance identity is established.
Stage 3: config. Runs the configuration modules: package installation, user creation, file writing, and other declarative directives from the cloud-config YAML. This is where most of the #cloud-config directives take effect.
Stage 4: final. Runs last, after all other stages. This is where runcmd commands execute. At this point, networking is up, packages are installed, and users exist. Scripts that depend on earlier stages belong here.
The practical consequence: a shell script passed as user-data runs at stage 4. A packages directive runs at stage 3. If your runcmd depends on a package that the same cloud-config installs, the ordering is safe — packages are installed before runcmd runs. But a shell script that assumes a network route exists at stage 1 will fail silently.
What Happens on the Second Boot
Cloud-init does not re-run everything on every boot. This is one of the most common sources of confusion.
Each module in cloud-init has a frequency: per-once, per-boot, or per-instance. Most modules default to per-instance, which means they run once on the first boot of a given instance and never again, even if the instance is rebooted. A few modules, like scripts-per-boot, run on every boot.
The runcmd module runs per-instance. If you place a command in runcmd that sets a sysctl value, it will apply on first boot. After a reboot, it will not run again. If the value is reset by the kernel, your instance now has a different configuration than the one you intended.
This is exactly why Ansible exists. Ansible is idempotent and runs whenever you invoke it. A sysctl value managed by Ansible is checked and enforced on every run. Cloud-init cannot do this — it was not designed to. Cloud-init initializes. Ansible maintains.
The Three Prerequisites Ansible Needs
Ansible connects to managed hosts over SSH. It requires exactly three things on the target instance:
- An SSH server accepting connections — present by default on Ubuntu cloud images.
- A Python interpreter — present by default on Ubuntu 24.04 (Python 3.12).
- A service account with an SSH public key and passwordless sudo.
The first two are already in the image. The third is cloud-init’s job. Cloud-init creates the service account, injects the SSH key, and grants sudo access. After that, Ansible can connect without any manual intervention.
Everything else — packages, configuration files, service states, firewall rules, monitoring agents — belongs to Ansible. The temptation to install packages in cloud-init is strong because it feels like saving time. But a package installed by cloud-init is invisible to Ansible. When Ansible runs, it does not know whether nginx was installed by cloud-init or by a previous Ansible run or by someone who logged in and ran apt install manually. The result is the same drift that Ansible was designed to prevent.
If your organization builds and maintains these automation workflows at scale, from provisioning templates to production playbooks, the work involved is substantial. We do this across OpenStack, Proxmox, and bare-metal environments as part of our automation and DevOps practice.
A Minimal Cloud-Config Template
The following cloud-config does exactly what cloud-init should do for Ansible, and nothing more:
#cloud-config
users:
- default
- name: ansible
gecos: Ansible Service Account
groups: [sudo]
sudo: "ALL=(ALL) NOPASSWD:ALL"
shell: /bin/bash
lock_passwd: true
ssh_authorized_keys:
- ssh-ed25519 AAAAC3Nza... ansible@control
package_update: false
package_upgrade: false
ssh_pwauth: false
final_message: "cloud-init finished in $UPTIME seconds"
What this template does:
- Creates a user named
ansiblewith passwordless sudo and a public SSH key. - Locks the password on that account — SSH key is the only authentication method.
- Disables SSH password authentication entirely.
- Preserves the default user from the cloud image.
- Does not update or upgrade packages — that is Ansible’s responsibility.
- Logs a final message with the boot time for verification.
The package_update: false line is deliberate. Updating packages at first boot adds minutes to instance startup, downloads content from repositories that may be unreachable in air-gapped environments, and introduces variability between instances launched at different times. Ansible’s apt module does this in a controlled, logged, and idempotent way.
Datasources: How Cloud-Init Finds Its Instructions
Cloud-init does not hard-code where its configuration comes from. It discovers its environment through datasources — plugins that know how to retrieve metadata and user-data from a specific platform.
On OpenStack, cloud-init contacts the metadata service at 169.254.169.254 over HTTP. Nova provides the instance name, network configuration, SSH keys, and any user-data the operator passed at creation time.
On AWS, the mechanism is the same address (169.254.169.254) but the API format differs. Azure uses a different endpoint (169.254.169.254 with a different path and headers). GCP uses yet another variation.
On Proxmox VE and KVM/libvirt, there is no metadata service. Cloud-init reads from a local disk instead. This is the NoCloud datasource, and it deserves its own section because it is how most on-premises environments work.
NoCloud: Provisioning Without a Metadata Service
The NoCloud datasource reads cloud-init configuration from a small disk image (typically an ISO) attached to the virtual machine. The disk contains two files: meta-data (instance identity) and user-data (the cloud-config or script).
On Proxmox VE, this mechanism is built into the interface. When you configure a Cloud-Init drive on a VM, Proxmox generates the ISO automatically from the parameters you set: user, SSH key, network configuration, DNS. The VM boots, cloud-init finds the attached ISO, reads it, and applies the configuration.
On raw KVM/libvirt, you build the ISO yourself:
genisoimage -output cidata.iso -volid cidata \ -joliet -rock meta-data user-data
The -volid cidata flag is not optional. Cloud-init identifies the NoCloud disk by its volume label. If the label is wrong, cloud-init will not find the configuration and the instance boots unconfigured.
NoCloud is also the mechanism to test cloud-init locally before deploying to a cloud environment. Create a VM, attach the ISO, boot, and verify. No metadata service required.
The Initialized Volume Trap
Cloud-init tracks whether it has already run on an instance by writing a marker to the filesystem. On subsequent boots, it checks for this marker and skips initialization if it finds one.
The trap occurs when you create a persistent volume from an instance that has already been initialized, then use that volume to launch a new instance. The new instance inherits the marker. Cloud-init sees it and concludes that initialization has already happened. The user-data you provided is ignored. The instance boots with the old configuration.
This is documented in the OpenStack training material as a note: set Create New Volume to No when launching an instance that needs cloud-init to execute at first boot. But the reason is rarely explained.
The fix is to clean the cloud-init state before capturing a volume or image:
sudo cloud-init clean --logs
This removes the marker, the logs, and the cached instance data. The next boot will be treated as a first boot, and cloud-init will process the user-data from scratch.
Verifying That Cloud-Init Ran
After an instance boots, the first check is the cloud-init status:
cloud-init status
Expected output: status: done. If the output is status: running, cloud-init has not finished yet. If the output is status: error, at least one module failed.
The detailed log is at /var/log/cloud-init.log. The output of all scripts and commands is at /var/log/cloud-init-output.log. The final line of the main log confirms completion:
Cloud-init v. 26.2 finished at Mon, 24 Aug 2026 10:15:42 +0000. Datasource DataSourceNoCloud [seed=/dev/sr0]. Up 38.71 seconds
This line tells you three things: the version of cloud-init that ran, the datasource it used, and how long the boot took. If this line is absent, cloud-init did not complete.
For the Ansible service account specifically, verify that the account exists and that SSH key authentication works:
grep ansible /etc/passwd ssh -i ~/.ssh/ansible_key ansible@instance-ip whoami
If both succeed, the instance is ready for Ansible. Run ansible -m ping against the instance to confirm end-to-end connectivity through the automation stack.
What This Article Does Not Cover
This article draws the boundary between cloud-init and Ansible. It does not cover Heat or any other orchestration layer that calls cloud-init as part of a stack deployment. It does not cover Ansible playbook structure, roles, or inventory organization — each of these is a separate subject. It does not cover the AWS, Azure, or GCP datasources beyond naming them. It does not cover Ansible Vault or secret management, which becomes necessary as soon as the cloud-config contains credentials. And it does not cover custom cloud-init modules or the cc_ plugin interface, which is an advanced topic with limited use outside distribution maintainers.
The cloud-init version used in this article is 26.2, released on 29 July 2026. The ansible-core version is 2.21, released on 10 August 2026. Both versions run on Ubuntu 24.04 LTS with Python 3.12.
References
Canonical. cloud-init 26.2 Documentation. docs.cloud-init.io, 2026.
Ansible Community. ansible-core 2.21 Documentation. docs.ansible.com, 2026.
Canonical. cloud-init DataSources Reference. docs.cloud-init.io/en/latest/reference/datasources.html, 2026.
Red Hat. Ansible Architecture and Agentless Model. docs.ansible.com/ansible/latest/getting_started, 2026.