Automating the Initial Setup with cloud-init
cloud-init performs the initial setup of a server on its first boot: host name, users, SSH keys, networking and package installation. It is built into the Ubuntu cloud images and is how servers are prepared in cloud and virtual environments.
The manual equivalents are covered in the other articles, such as Changing the Hostname and Creating Users and Granting sudo Privileges. This article shows how to have the same settings applied at boot instead.
The examples were captured on Ubuntu 26.04 LTS Server with cloud-init 26.1.
The Configuration You Provide (user-data)
The configuration handed to cloud-init is called user-data and is written in YAML beginning with #cloud-config. The server used in this article was started with the following.
#cloud-config
hostname: sv2
manage_etc_hosts: true
timezone: Asia/Tokyo
users:
- name: kazulog
groups: [sudo]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: false
ssh_authorized_keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... kazulog@client
chpasswd:
expire: false
users:
- name: kazulog
password: kazulog
type: text
package_update: true
packages:
- git
- nginx
write_files:
- path: /etc/motd
content: |
このサーバーは cloud-init で構成されています。
- path: /var/www/html/index.html
content: "<h1>provisioned by cloud-init</h1>\n"
runcmd:
- [systemctl, restart, nginx]
- [sh, -c, "echo provisioned at $(date -Is) >> /var/log/provision.log"]The main keys are:
| Key | Meaning |
|---|---|
hostname | The host name. With manage_etc_hosts: true, /etc/hosts is updated as well |
timezone | The time zone |
users | Users to create. groups with sudo grants administrative commands, sudo is the sudoers rule, and ssh_authorized_keys holds the public keys |
chpasswd | Sets passwords. Without expire: false the user must change the password at first login |
package_update | true runs the equivalent of apt update |
packages | Packages to install |
write_files | Files to create, given as path and content |
runcmd | Commands to run at the end. Written as lists, they run without a shell |
users is given, the image’s default user (ubuntu on the Ubuntu cloud images) is not created. Always provide either an SSH public key or a password, or you will end up with a server nobody can log in to.How the user-data Reaches the Server
cloud-init looks in a fixed place for the user-data when the machine boots. That place is the data source, and it differs by environment.
| Environment | How you provide it | Data source |
|---|---|---|
| Clouds (AWS, GCP, Azure) | Paste the #cloud-config into the “user data” field when creating the instance | The cloud’s metadata service |
| Virtualisation platforms (Proxmox, VMware, CML) | Paste it into the platform’s cloud-init form. The platform builds an ISO image and attaches it as a virtual CD-ROM | NoCloud |
| Your own machine | Build the ISO image (a seed ISO) yourself with cloud-localds and boot it alongside the cloud image | NoCloud |
The examples in this article use the third mechanism, which is why cloud-init query subplatform reports config-disk (/dev/sr0): the configuration came from that ISO image.
Trying It on Your Own Machine
Without a cloud or a virtualisation platform you need three things:
- An Ubuntu cloud image (a ready-made disk image, not the installer ISO)
- A seed ISO holding the user-data
- Virtualisation software to run them (qemu/KVM, libvirt and so on)
The seed ISO is built with cloud-localds from the cloud-image-utils package.
sudo apt install -y cloud-image-utilskazulog@sv1:~$ which cloud-localds qemu-system-x86_64
/usr/bin/cloud-localds
/usr/bin/qemu-system-x86_64Write the user-data to a file and hand it to cloud-localds.
cloud-localds [ISO] [USER-DATA]| Field | Value |
|---|---|
| [ISO] | Name of the ISO image to create |
| [USER-DATA] | The user-data file, starting with #cloud-config |
kazulog@sv1:~$ cat user-data.yaml | head -6
#cloud-config
hostname: web01
manage_etc_hosts: true
timezone: Asia/Tokyo
users:
- name: kazulog
kazulog@sv1:~$ cloud-localds seed.iso user-data.yaml
kazulog@sv1:~$ ls -lh seed.iso
-rw-rw-r-- 1 kazulog kazulog 366K Sep 10 21:19 seed.isoTo pass network settings as well, name a network-config file with -N.
cloud-localds -N [NETWORK-CONFIG] [ISO] [USER-DATA]kazulog@sv1:~$ cloud-localds -N network-config.yaml seed.iso user-data.yaml
kazulog@sv1:~$ sudo mkdir -p /mnt/seed && sudo mount -o loop,ro seed.iso /mnt/seed
kazulog@sv1:~$ ls -l /mnt/seed
total 0
-rw-rw-r-- 1 kazulog kazulog 33 Sep 10 21:20 meta-data
-rw-r--r-- 1 kazulog kazulog 48 Sep 10 21:20 network-config
-rw-r--r-- 1 kazulog kazulog 478 Sep 10 21:20 user-data
kazulog@sv1:~$ sudo umount /mnt/seedThe ISO holds user-data and meta-data, plus network-config when -N was given. cloud-localds writes the meta-data for you.
Now boot a virtual machine with the cloud image as one disk and the seed ISO as another. The Ubuntu 26.04 cloud image boots via UEFI, so point qemu at the OVMF firmware.
wget https://cloud-images.ubuntu.com/releases/26.04/release/ubuntu-26.04-server-cloudimg-amd64.imgqemu-system-x86_64 -enable-kvm -m 1024 -smp 1 -nographic \
-drive if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.fd \
-drive if=pflash,format=raw,file=OVMF_VARS.fd \
-drive file=ubuntu-26.04-server-cloudimg-amd64.img,format=qcow2,if=virtio \
-drive file=seed.iso,format=raw,if=virtio \
-net nic -net userCopy /usr/share/OVMF/OVMF_VARS_4M.fd to OVMF_VARS.fd first: it stores the UEFI settings and each virtual machine needs its own. Install the ovmf and qemu-system-x86 packages beforehand.
On boot, cloud-init reads the seed ISO and applies the user-data. The console output below shows that web01 from the user-data became the host name and that the seed ISO (/dev/vdb) was the data source.
Ubuntu 26.04 LTS web01 ttyS0
web01 login: [ 444.354111] sh[1072]: Completed socket interaction for boot stage config
[ 462.016133] cloud-init[943]: Cloud-init v. 26.1-0ubuntu3~26.04.1 running 'modules:final' at Thu, 10 Sep 2026 12:57:00 +0000. Up 461.65 seconds.[ 465.718178] cloud-init[943]: Cloud-init v. 26.1-0ubuntu3~26.04.1 finished at Thu, 10 Sep 2026 12:57:04 +0000. Datasource DataSourceNoCloud [seed=/dev/vdb]. Up 465.66 secondsqemu-img resize fails to boot as it is. The backup GPT header stays at its original position, so the /boot partition is not detected and the machine drops into emergency mode (confirmed in testing). To grow the disk, use a tool such as virt-resize that also fixes the partition table, or boot the image without enlarging it.Confirming That the Settings Were Applied
After boot, cloud-init status reports the result. --long adds the details.
cloud-init status --longkazulog@sv2:~$ cloud-init --version
/usr/bin/cloud-init 26.1-0ubuntu3~26.04.1
kazulog@sv2:~$ cloud-init status --long
status: done
extended_status: done
boot_status_code: enabled-by-generator
last_update: Thu, 01 Jan 1970 00:00:27 +0000
detail: DataSourceNoCloud [seed=/dev/sr0]
errors: []
recoverable_errors: {}
kazulog@sv2:~$| Field | Meaning |
|---|---|
status | done, running, error or disabled |
boot_status_code | Whether cloud-init is enabled |
detail | The data source that was used (see below) |
recoverable_errors | Problems that did not stop the run |
status stays done while extended_status becomes degraded done, and the details appear under recoverable_errors. Plain cloud-init status does not show this, so use --long.When a password was set, the second column of passwd -S shows P.
kazulog@sv2:~$ sudo passwd -S kazulog
kazulog P 2026-09-10 0 99999 7 -1Check the individual settings with their own commands.
kazulog@sv2:~$ hostname
sv2
kazulog@sv2:~$ id kazulog
uid=1000(kazulog) gid=1000(kazulog) groups=1000(kazulog),27(sudo)
kazulog@sv2:~$ cat /etc/motd
このサーバーは cloud-init で構成されています。
kazulog@sv2:~$ curl -s localhost | head -2
<h1>provisioned by cloud-init</h1>
kazulog@sv2:~$ cat /var/log/provision.log
provisioned at 2026-09-10T19:37:39+09:00
provisioned at 2026-09-10T19:42:01+09:00
kazulog@sv2:~$ dpkg -l git nginx | grep '^ii'
ii git 1:2.53.0-1ubuntu1 amd64 fast, scalable, distributed revision control system
ii nginx 1.28.3-2ubuntu1.10 amd64 small, powerful, scalable web/proxy server
kazulog@sv2:~$ timedatectl | head -3
Local time: Thu 2026-09-10 19:42:26 JST
Universal time: Thu 2026-09-10 10:42:26 UTC
RTC time: Thu 2026-09-10 10:42:26
kazulog@sv2:~$ ls -l ~/.ssh/authorized_keys
-rw------- 1 kazulog kazulog 110 Sep 10 19:41 /home/kazulog/.ssh/authorized_keysThe host name, the user and its groups, the files from write_files, the packages from packages, the result of runcmd, the time zone and the public key are all in place.
Checking the Data Source
The place cloud-init read the user-data from is called the data source. Clouds use a metadata service; virtual environments often use an ISO image (NoCloud).
cloud-init query platform
cloud-init query subplatformkazulog@sv2:~$ cloud-init query platform
nocloud
kazulog@sv2:~$ cloud-init query subplatform
config-disk (/dev/sr0)
kazulog@sv2:~$Here the nocloud data source read the configuration from /dev/sr0, a virtual CD-ROM.
The user-data that was actually read is kept in /var/lib/cloud/instance/user-data.txt, so you can tell later which configuration built the server.
kazulog@sv2:~$ sudo head -11 /var/lib/cloud/instance/user-data.txt
#cloud-config
hostname: sv2
manage_etc_hosts: true
timezone: Asia/Tokyo
users:
- name: kazulog
groups: [sudo]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: false
ssh_authorized_keys:
kazulog@sv2:~$Timing and Logs
cloud-init analyze blame shows how long each step took, which helps when boot is slow.
sudo cloud-init analyze blamekazulog@sv2:~$ sudo cloud-init analyze blame | head -10
-- Boot Record 01 --
07.24200s (modules-final/config-package_update_upgrade_install)
00.75900s (modules-config/config-apt_configure)
00.23800s (init-local/search-NoCloud)
00.15500s (modules-final/config-keys_to_console)
00.13400s (init-network/config-set_passwords)
00.11000s (modules-final/config-scripts_user)
00.10500s (init-network/config-growpart)
00.10200s (init-network/config-ssh)
00.07300s (modules-final/config-ssh_authkey_fingerprints)
kazulog@sv2:~$Package installation (config-package_update_upgrade_install) dominates the total.
There are two log files.
| File | Contents |
|---|---|
/var/log/cloud-init.log | cloud-init’s own detailed log |
/var/log/cloud-init-output.log | Output of the commands it ran, such as apt |
kazulog@sv2:~$ sudo ls -l /var/log/cloud-init.log /var/log/cloud-init-output.log
-rw-r----- 1 root adm 4059 Sep 10 19:42 /var/log/cloud-init-output.log
-rw-r----- 1 syslog adm 109119 Sep 10 19:42 /var/log/cloud-init.log
kazulog@sv2:~$Validating the Syntax
A mistake in the user-data leaves part of the configuration unapplied while the server still boots. Validate it with cloud-init schema before you use it.
sudo cloud-init schema --config-file [FILE]| Field | Value |
|---|---|
| [FILE] | The cloud-config file to validate |
kazulog@sv2:~$ sudo cloud-init schema --config-file /tmp/good.yaml
Valid schema /tmp/good.yaml
kazulog@sv2:~$ sudo cloud-init schema --config-file /tmp/bad.yaml
Invalid user-data /tmp/bad.yaml
Error: Cloud config schema errors: packages: 'git' is not of type 'array', runcmds: Additional properties are not allowed ('runcmds' was unexpected)
Error: Invalid schema: user-data
kazulog@sv2:~$Errors are reported with the key and the reason. Above, packages was given a string where a list is required, and runcmd was misspelled as runcmds.
Use --system to validate the configuration that was actually applied.
kazulog@sv2:~$ sudo cloud-init schema --system | tail -3
2. network-config at /var/lib/cloud/instances/ubuntu/network-config.json:
Valid schema network-configStarting Over
cloud-init only runs its setup on the first boot. In a test environment you can clear its state and reboot to have it run as a first boot again.
sudo cloud-init clean --logs
sudo rebootkazulog@sv2:~$ sudo cloud-init clean --logs
kazulog@sv2:~$ ls /var/lib/cloud/
seed
kazulog@sv2:~$After the reboot the user-data is read again and runcmd runs once more.
kazulog@sv2:~$ uptime -p
up 0 minutes
kazulog@sv2:~$ cloud-init status
status: done
kazulog@sv2:~$ cat /var/log/provision.log
provisioned at 2026-09-10T19:37:39+09:00
provisioned at 2026-09-10T19:42:01+09:00
provisioned at 2026-09-10T19:43:35+09:00
kazulog@sv2:~$ cat /etc/motd
このサーバーは cloud-init で構成されています。runcmd ran again and added a new line to provision.log.
cloud-init clean on a production server. Users and networking would be reconfigured, which can leave the machine in an unintended state.Disabling cloud-init
To keep cloud-init from running on a server that is already built, create the marker file.
sudo touch /etc/cloud/cloud-init.disabledkazulog@sv2:~$ sudo touch /etc/cloud/cloud-init.disabled
kazulog@sv2:~$ ls -l /etc/cloud/cloud-init.disabled
-rw-r--r-- 1 root root 0 Sep 10 19:44 /etc/cloud/cloud-init.disabled
kazulog@sv2:~$After the next boot the status is disabled and the user-data is not read.
kazulog@sv2:~$ cloud-init status --long
status: disabled
extended_status: disabled
boot_status_code: disabled-by-marker-file
detail: Cloud-init disabled by /etc/cloud/cloud-init.disabled
errors: []
recoverable_errors: {}
kazulog@sv2:~$detail states why it was disabled. Delete the file to enable it again.
About the Network Configuration
Network settings are handed over separately as network-config. The format is the same as Netplan, and it is written out as /etc/netplan/50-cloud-init.yaml. It is covered in Configuring the Network on Ubuntu 26.04 LTS Server.
Test Environment and Session Logs
The examples were captured on Ubuntu 26.04 LTS Server (cloud-init 26.1) running on CML. The session log of each step can be downloaded below.
| Step | Session log |
|---|---|
| Building the seed ISO (cloud-localds) | log |
| The ISO with a network configuration | log |
| Installing cloud-localds and qemu | log |
| Console of the VM booted from the seed ISO | log |
| Result and data source | log |
| Timing and password | log |
| Confirming the applied settings | log |
| Log files | log |
| Validating the syntax | log |
| Clearing the state and rebooting | log |
| After the reboot | log |
| Disabling cloud-init | log |
| Confirming it is disabled | log |
Reference
Related articles
- Changing the Hostname on Ubuntu 26.04 LTS Server (hostnamectl)
- Updating Packages on Ubuntu 26.04 LTS Server (apt update / upgrade)
- Setting the Timezone and Time Synchronisation on Ubuntu 26.04 LTS Server
- Creating Users and Granting sudo Privileges on Ubuntu 26.04 LTS Server
- Configuring the SSH Server on Ubuntu 26.04 LTS Server
- Managing Services with systemctl and Reading Logs with journalctl on Ubuntu 26.04 LTS Server
- Configuring Automatic Updates on Ubuntu 26.04 LTS Server (unattended-upgrades)
- Automating the Initial Setup of Ubuntu 26.04 LTS Server with cloud-init
- Configuring Kernel Parameters on Ubuntu 26.04 LTS Server (sysctl)
- Configuring the Network on Ubuntu 26.04 LTS Server (Netplan)
- Configuring Name Resolution on Ubuntu 26.04 LTS Server (systemd-resolved)
- Changing the NTP Source on Ubuntu 26.04 LTS Server (chrony)
- Static Routes on Ubuntu 26.04 LTS Server (Netplan)
- How to Install the Latest neovim from the Official Site on Ubuntu 26.04 LTS Server