Cloud-init: Provisioning Automation

Cloud-init: Provisioning Automation

Bitnesia Aug 29, 2026 1 ID

Chapter 36 closed with an important note that Ansible excels at configuring servers that are already powered on and reachable via SSH. However, there is one gap left untouched: how the server is initially provisioned before Ansible ever gets a chance to log in. Every time we created a new VM using virt-install in Chapter 28, the post-installation steps were repetitive: creating a user, copying an SSH key, and installing a few base packages manually before the server was truly ready for use. This process feels manageable for one or two experimental VMs, but becomes a real burden as soon as we need to provision dozens of instances simultaneously, especially in cloud environments where VMs can be created and destroyed within minutes. Cloud-init bridges this gap by automating the entire initial provisioning process right from the very first second the VM boots, without requiring a single manual touch or an external SSH connection.

This scenario is most evident for Sysadmins working in cloud environments or those who routinely generate new VMs for testing and staging needs. A developer requests five identical VMs to test a new version of an application prior to release, complete with their respective user access and several supporting packages pre-installed from the start. Without cloud-init, this request implies five identical manual installation processes. Cloud-init enables all five VMs to be born from a single cloud image, differentiated only by a small configuration file injected during the first boot, ensuring that the base image itself never needs to be modified or manually populated with packages one by one.

This chapter starts with the core concepts of cloud-init and datasources, proceeds to the structure of the user-data file that serves as the core of its configuration, and then walks through full provisioning—creating users, embedding SSH keys, and installing initial packages—on a KVM VM using skills mastered in Chapter 28. The chapter concludes with an overview of cloud-init usage outside local environments, including its relationship with real cloud providers, which will be practiced in greater detail in Chapter 41.

37.1 Cloud-init Concepts and Use Cases

37.1.1 What Are Cloud-init and Datasources

Cloud-init is an open source tool that has become the de facto standard for cross-platform instance initialization, running from inside the guest operating system itself upon its first boot. Unlike Ansible in Chapter 36, which operates externally over SSH against a running and reachable server, cloud-init operates internally as part of the VM boot process, long before the SSH service is ready to accept external connections. Almost all official Ubuntu cloud images, including those used by AWS, Azure, GCP, and OpenStack, come with cloud-init pre-installed and enabled out of the box, making it a universal bridge between a generic image and the specific configuration required by each instance.

To operate across diverse platforms, cloud-init uses the concept of a datasource, which is the mechanism cloud-init uses to identify the environment it is running in and locate its configuration data sources. This detection process is handled automatically by an internal component named ds-identify during the earliest boot stage. Once the datasource is located, cloud-init typically receives data in the form of two files: user-data, which contains the complete instance configuration definition, and meta-data, which contains instance-specific details such as instance-id and hostname (generally provided automatically by the underlying platform). This chapter uses a datasource named NoCloud, allowing us to inject user-data and meta-data manually via local media labeled cidata, making it ideal for a local KVM environment that lacks a dedicated metadata service found in true cloud environments.

37.1.2 Use Cases: When to Use Cloud-init

Cloud-init provides the most value in the following scenarios commonly encountered by Sysadmins in the field:

  • Mass deployments of identical instances at a cloud provider (e.g., launching ten VMs at once for an autoscaling group behind a load balancer, as covered conceptually in Chapter 16) without logging into each instance individually for initial setup.
  • The golden image concept, where one tested and validated base cloud image is reused across different environments simply by altering the content of user-data, eliminating the need to build separate images for every configuration variant.
  • Setting up testing and staging environments that need to be recreated from scratch quickly and consistently—a process that is far more repetitive and error-prone when executed manually via console as done in Section 28.3.3.
  • Serving as a bridge to Ansible from Chapter 36. Cloud-init handles the baseline foundation, such as user creation and SSH access setup; once the instance is reachable, Ansible takes over for advanced configurations and ongoing changes outside the scope of cloud-init.

This final point deserves emphasis. Cloud-init is designed for initial boot provisioning, not as a replacement for configuration management tools meant for routine day-to-day changes. Re-running the entire cloud-init cycle on an instance that has been running for a long time is not a standard pattern, unlike Ansible playbooks which are designed to be run repeatedly throughout a server's lifecycle.

37.1.3 Cloud-init Boot Stages and Status on Installed Servers

Cloud-init does not run as a single process; instead, it is divided into several sequential boot stages, each mapped to a specific systemd unit.

StageSystemd UnitMain Function
Generatorcloud-init-generatorDetermines whether cloud-init needs to run at all during this boot cycle
Localcloud-init-local.serviceLocates the earliest local datasource before networking is up
Networkcloud-init-network.serviceConfigures networking and fetches data from datasources requiring a connection
Configcloud-config.serviceExecutes main configuration modules, including user creation and package installations
Finalcloud-final.serviceExecutes late-stage modules, including custom commands specified via runcmd

Practical Steps

  1. Check the cloud-init status on the server installed manually back in Chapter 2:
    cloud-init status --long

Verification and Troubleshooting

  • Servers installed interactively via Subiquity starting in Chapter 2 will likely display a status of disabled rather than done. This does not indicate a system failure. ds-identify did not detect a valid datasource during such a manual installation and consequently wrote a marker file at /etc/cloud/cloud-init.disabled, preventing the above units from executing on subsequent boots. This behavior confirms that cloud-init is intended for environments that supply a proper datasource, such as the one we will build ourselves in Section 37.3.
  • On instances where the datasource is detected correctly, a healthy status displays status: done along with execution time details for each stage. The cloud-init analyze blame command is useful for identifying which modules took the longest to execute if the boot process feels slow.

37.2 The user-data File Structure

37.2.1 The #cloud-config Format and Section Roles

The user-data file supports several content formats, but the most common and recommended format for daily use is cloud-config, written in YAML syntax and strictly required to begin with the literal header #cloud-config on its very first line. This line is not a simple YAML comment; it is a mandatory marker read by cloud-init to identify the file content type before processing it. Without this header, cloud-init will ignore the entire file content, even if the internal YAML syntax is valid.

Several high-level keys frequently used and practiced in Section 37.3 include users (to define accounts along with their ssh_authorized_keys), package_update and package_upgrade (to refresh package lists and upgrade the system prior to installation), packages (to list packages to install), and runcmd (to execute custom shell commands during the Final stage). A subtle yet critical detail is the instance-id value in meta-data. Cloud-init tracks previously processed instance-ids, and single-run modules will not re-run if the instance-id matches one recorded earlier. This is a common pitfall when cloning VM disks without updating the instance-id.

37.2.2 Writing and Validating a Minimal user-data File

Practical Steps

  1. Create an experimental working directory on the same KVM host used in Chapter 28 to inspect its structure before crafting a full configuration:
    mkdir -p ~/cloud-init-lab
    cd ~/cloud-init-lab
  2. Create a test user-data file containing a minimal configuration:
    nano user-data
  3. Add the following two lines:
    #cloud-config
    hostname: cloud-init-test
  4. Validate the syntax and schema of the file without creating a VM:
    cloud-init schema --config-file user-data --annotate

Verification and Troubleshooting

  • A healthy output displays Valid schema user-data:user-data. If the #cloud-config line is intentionally omitted for testing, the same command will reject the file because it cannot identify the content type.
  • The cloud-init schema command validates key names and structures against each module's official schema; it does not guarantee logical correctness. For instance, a misspelled package name under the packages key will pass schema validation because its structural layout is correct, only to fail later when executed by apt inside the VM.
  • Common errors such as mapping values are not allowed in this context point to YAML indentation issues, identical to those discussed during Ansible playbook creation in Section 36.3.3.

37.3 Automated Provisioning: Users, SSH Keys, and Initial Packages

This section walks through a end-to-end scenario: going from a plain cloud image to a VM configured with a sysadmin user, SSH key-based authentication, and the nginx package installed—all without touching the VM console manually. We will rely on the KVM/QEMU/libvirt stack installed in Section 28.2, but this time we boot directly from a cloud image rather than a Subiquity installer ISO like vm-app01 in Section 28.3.

37.3.1 Preparing the Cloud Image and VM Disk

Ubuntu provides official cloud images: base OS images pre-packaged with an active cloud-init installation, ready for immediate use without going through the Subiquity installer process.

Practical Steps

  1. Download the official Ubuntu Server 26.04 LTS cloud image to the storage pool configured in Section 28.3.1:
    cd /var/lib/libvirt/images
    sudo wget https://cloud-images.ubuntu.com/releases/26.04/release/ubuntu-26.04-server-cloudimg-amd64.img
  2. Create a dedicated directory to store the user-data and meta-data files, separate from the VM disk file:
    sudo mkdir -p /var/lib/libvirt/images/cloud-init
  3. Create a new copy-on-write VM disk image utilizing the downloaded cloud image as a backing file, then extend its capacity to 10 GB:
    sudo qemu-img create -f qcow2 -F qcow2 \
      -b /var/lib/libvirt/images/ubuntu-26.04-server-cloudimg-amd64.img \
      /var/lib/libvirt/images/vm-cloudinit01.qcow2 10G

Verification and Troubleshooting

  • Confirm that the new disk image correctly points to the backing file and reflects the expanded size:
    qemu-img info /var/lib/libvirt/images/vm-cloudinit01.qcow2
    The backing file field should point to the original cloud image, while virtual size should display 10 GiB, even though the actual file size on the host disk (disk size) remains small, as qcow2 format only allocates blocks that are actively written.
  • This backing file approach avoids duplicating the base cloud image file whenever a new VM is instantiated. The original cloud image stays intact as a shared base, while each individual VM retains its unique changes (deltas) in its assigned qcow2 file, saving storage compared to performing a full file copy.
  • Default cloud images are intentionally distributed with a small footprint (typically under 3 GB). They rely on the growpart and resizefs modules within cloud-init to expand the root partition and filesystem automatically on first boot up to the 10 GB limit set via qemu-img create. Both modules are enabled by default in official Ubuntu cloud images and do not require manual configuration in user-data.

37.3.2 Writing a Complete user-data File for Provisioning

Practical Steps

  1. Create a meta-data file defining the instance identity:
    sudo nano /var/lib/libvirt/images/cloud-init/meta-data
  2. Add the instance-id and local hostname:
    instance-id: vm-cloudinit01
    local-hostname: vm-cloudinit01
  3. Create the primary user-data configuration file:
    sudo nano /var/lib/libvirt/images/cloud-init/user-data
  4. Define user parameters, SSH key configuration, and package installations. Replace the placeholder string in ssh_authorized_keys with the actual contents of your public key located at ~/.ssh/id_ed25519.pub generated back in Section 3.2.1:
    #cloud-config
    users:
      - name: sysadmin
        groups: sudo
        shell: /bin/bash
        sudo: ALL=(ALL) NOPASSWD:ALL
        lock_passwd: true
        ssh_authorized_keys:
          - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGXampleKeyMaterialGantiDenganPublicKeyAsli
    
    package_update: true
    package_upgrade: true
    packages:
      - nginx
      - curl
    
    runcmd:
      - [ sh, -c, "echo Provisioned via cloud-init on $(date) > /var/www/html/provisioned.txt" ]
    The users section creates an account named sysadmin belonging to the sudo group. The sudo directive configures passwordless sudo access equivalent to an entry under /etc/sudoers.d/ as seen in Section 4.2.1. The lock_passwd: true directive explicitly disables password login so that access is restricted to SSH key authentication, aligning with system hardening guidelines in Chapter 30. Directives package_update and package_upgrade perform tasks equivalent to running apt update and apt upgrade prior to package installation via packages. Finally, runcmd executes commands during the Final boot stage, which here writes a verification file checked via curl to confirm provisioning completed.

Verification and Troubleshooting

  • Re-validate the configuration schema prior to booting the target VM:
    cloud-init schema --config-file /var/lib/libvirt/images/cloud-init/user-data --annotate
  • Because the users entry above is explicitly defined without including the keyword default in its list, cloud-init will suppress the creation of the cloud image's default user (typically named ubuntu). Only the sysadmin account will exist on the provisioned VM.
  • Ensure the entire ssh-ed25519 ... public key string is formatted on a single unbroken line within ssh_authorized_keys. Line breaks introduced during copy-paste operations are a frequent cause of SSH authentication failures in Section 37.3.4.

37.3.3 Booting the VM with virt-install and Seed Cloud-init

The --cloud-init flag in virt-install automatically packages the user-data and meta-data files into a seed media labeled cidata according to the NoCloud datasource specification, attaching it to the target VM as a virtual CD-ROM. This mirrors the behavior of using cloud-localds from the cloud-image-utils package manually. Using virt-install automates this step natively, though cloud-localds remains useful for standalone QEMU setups or hypervisors lacking equivalent flags.

Practical Steps

  1. Run virt-install targeting the disk created in Section 37.3.1, supplying the --import flag to boot directly from the existing OS disk without invoking an installer interface:
    virt-install \
      --name vm-cloudinit01 \
      --memory 2048 \
      --vcpus 2 \
      --disk path=/var/lib/libvirt/images/vm-cloudinit01.qcow2,bus=virtio \
      --os-variant detect=on,require=off \
      --network network=default,model=virtio \
      --graphics none \
      --import \
      --cloud-init user-data=/var/lib/libvirt/images/cloud-init/user-data,meta-data=/var/lib/libvirt/images/cloud-init/meta-data
  2. Allow time for the initial boot and provisioning routines to complete, then inspect the assigned IP address issued by the default network's DHCP service:
    virsh domifaddr vm-cloudinit01

Verification and Troubleshooting

  • The --import flag differs from --location used in Section 28.3.2. While --location boots the Subiquity installer from an ISO, --import instructs virt-install to boot immediately from an OS-populated disk image, suitable for pre-installed cloud images.
  • The IP address reported by virsh domifaddr falls within the default 192.168.122.0/24 subnet configured for libvirt NAT networking in Section 28.2.4.
  • If virsh domifaddr returns no network details immediately, the target VM may still be executing early boot routines or waiting on a DHCP lease response. Re-query after a short delay, or monitor console output directly using virsh console vm-cloudinit01.

37.3.4 Verifying Provisioning Results

Practical Steps

  1. Initiate an SSH session to the VM using the IP assigned via domifaddr and the authorized SSH key defined in user-data:
    ssh [email protected]
  2. Upon successful login, confirm that all cloud-init stages have completed execution:
    cloud-init status --wait
  3. Verify that the nginx service is active and that the marker file created via runcmd exists:
    systemctl is-active nginx
    curl http://localhost/provisioned.txt

Verification and Troubleshooting

  • A successful SSH login does not automatically guarantee that all provisioning steps have finished executing. Modules handling ssh_authorized_keys execute early, while package management operations and runcmd routines run in later stages (Config and Final). Executing cloud-init status --wait ensures all tasks have completed before declaring the instance operational.
  • The command systemctl is-active nginx should return active, and provisioned.txt should contain the text Provisioned via cloud-init on alongside the boot timestamp, confirming that the runcmd directives were executed.
  • If errors occur during module execution, consult /var/log/cloud-init-output.log inside the VM for standard output and error details. Comprehensive execution details are written to /var/log/cloud-init.log for deeper troubleshooting.
  • Receiving a Permission denied (publickey) error during SSH connection attempts usually points to key truncation issues or invalid YAML indentation under the ssh_authorized_keys block within the user-data file.

37.4 Using Cloud-init in Local VMs and Cloud Environments

37.4.1 Alternative Datasources Beyond NoCloud: Comparison Summary

NoCloud is one of many datasources supported by cloud-init. Each major platform provides its own implementation mechanism to feed configuration parameters into instances, while the underlying user-data syntax remains portable across targets.

DatasourceCommon EnvironmentUser-Data Retrieval Method
NoCloudLocal KVM/QEMU, VirtualBox, custom imagesLocal media labeled cidata (ISO/vfat) attached directly to the VM
ConfigDriveOpenStack, select private cloud providersAttached block device structured with an openstack/ directory layout
EC2AWS EC2, EC2-compatible platformsInternal HTTP metadata service queried at 169.254.169.254
AzureMicrosoft AzureCombination of ovf-env.xml via virtual CD-ROM and Azure Instance Metadata Service
GCEGoogle Compute EngineInternal metadata server accessed via HTTP requests

Differences among datasources center on how configuration payloads are retrieved by cloud-init. Key names such as users, packages, and runcmd maintain identical behavior regardless of the underlying platform datasource.

37.4.2 Checking the Active Datasource Inside a VM

Practical Steps

  1. From within the active SSH session on vm-cloudinit01, query the runtime datasource detected by ds-identify during boot:
    cloud-id --long

Verification and Troubleshooting

  • The output should return nocloud, confirming that the seed media attached using the --cloud-init flag in Section 37.3.3 was processed via the NoCloud datasource mechanism.
  • The cloud-id command is a utility utility bundled with the cloud-init package that reports the active datasource without requiring manual parsing of underlying state JSON files.

37.4.3 Porting user-data to Public Cloud Providers

The user-data file authored in Section 37.3.2 can be repurposed when launching instances on public cloud platforms like AWS, Azure, or GCP. The file content can be pasted directly into the provider's launch console user data fields. Cloud platforms manage payload delivery natively through their metadata services without requiring manual ISO generation. Platform specifics—such as cloud security groups taking precedence over local UFW firewall configurations—are covered in Chapter 41.

The primary difference when deploying to public cloud instances lies in meta-data handling. Cloud platforms inject values like instance-id and hostname dynamically using internal metadata services, removing the need to supply a manual meta-data file as required in local NoCloud environments.

You now understand core datasource concepts, how to validate user-data files, and how to automate VM provisioning from a bare cloud image into a configured server equipped with users, SSH keys, and base packages upon initial boot. Cloud-init completes the automation toolset presented in Part IX: shell scripts in Chapter 35 handle routine tasks on individual servers, Ansible in Chapter 36 manages ongoing configuration across fleets, and cloud-init provisions unconfigured instances into accessible nodes. As rapid provisioning scales up node counts across environments, maintaining system visibility becomes essential—a topic explored in Part X through system monitoring concepts in Chapter 38.