ubuntu-cloud-init
cloud-init runs early in boot, reads configuration from a datasource, and applies it through modules across four boot stages. This skill is authoring-led (produce correct cloud-config from a description) with a strong validation/debug path, aimed at on-prem / air-gapped Ubuntu Server LTS using the NoCloud datasource.
Mapping: 24.04 LTS ≈ cloud-init 24.x; 26.04 LTS ≈ 26.1.
Authoring workflow
- Identify the goal & datasource. On-prem/air-gapped → NoCloud (seed dir,
cidataISO/USB, HTTP seed, or SMBIOS serial). Pindatasource_list: [NoCloud]to stop cloud-init probing cloud metadata services and timing out. - Choose the user-data format — almost always
#cloud-config. Use MIME multipart only to combine cloud-config with scripts. (Formats table below.) - Write the
#cloud-configusing the modules needed (see Quick reference). - Validate:
cloud-init schema -c user-data --annotate. - Seed it (NoCloud) and set a unique
instance-idinmeta-data. - Test on a throwaway boot, then
cloud-init clean --logsto re-run as if first boot.
user-data formats
First line determines the format:
| Header (line 1) | Format |
|---|---|
#cloud-config |
YAML config processed by modules (the usual choice). |
#!/bin/sh (shebang) |
A script, run once per instance in the Final stage. |
#cloud-boothook |
Runs very early, every boot (guard with cloud-init-per). |
#include |
List of URLs, each fetched as user-data. |
Content-Type: multipart/mixed |
MIME — combine cloud-config + scripts. Build with cloud-init devel make-mime. |
## template: jinja |
Jinja template (line 1); real header on line 2. Variables = instance-data keys, e.g. {{ v1.instance_id }}. |
Any of these may be gzipped.
Quick reference — the modules that matter on-prem
users / ssh (created in the Network stage):
#cloud-config
ssh_pwauth: false
users:
- default # KEEP this first to retain the distro user + cloud keys
- name: deploy
groups: [sudo]
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
shell: /bin/bash
ssh_authorized_keys:
- ssh-ed25519 AAAA... deploy@site
passwd (a hash) applies only to new users; hashed_passwd applies even to
existing ones. Generate a hash with mkpasswd --method=SHA-512 --rounds=4096.
write_files:
write_files:
- path: /etc/myapp/app.conf
owner: root:root
permissions: '0640'
content: |
backend = db.internal:5432
- encoding: b64 # also gz, gz+b64
content: aGVsbG8K
path: /etc/motd.d/banner
defer: true # write in Final stage (after users/packages)
runcmd vs bootcmd: runcmd runs once per instance in the Config stage (later);
bootcmd runs early on every boot (guard once-only work with
cloud-init-per once <name> ...).
runcmd:
- [systemctl, enable, --now, myapp]
apt — local mirror + internal repo (the air-gapped essential):
apt:
preserve_sources_list: false
primary:
- arches: [default]
uri: http://mirror.internal/ubuntu
security:
- arches: [default]
uri: http://mirror.internal/ubuntu
sources:
internal-app.list:
source: "deb [signed-by=$KEY_FILE] http://mirror.internal/app stable main"
key: |
-----BEGIN PGP PUBLIC KEY BLOCK-----
...embed the key inline; do NOT rely on a keyserver air-gapped...
-----END PGP PUBLIC KEY BLOCK-----
conf: |
Acquire::http::Proxy "http://apt-cache.internal:3142";
ca_certs — trust an internal CA (runs in the Network stage, before apt, so HTTPS to the internal mirror works):
ca_certs:
trusted:
- |
-----BEGIN CERTIFICATE-----
<internal root CA>
-----END CERTIFICATE-----
ntp — point at local time source:
ntp:
enabled: true
servers: [ntp.internal]
pools: []
Other on-prem-relevant modules: packages/package_update/package_upgrade
(avoid package_upgrade without a full mirror), disk_setup+fs_setup+mounts,
growpart/resize_rootfs, timezone/locale/keyboard, set_passwords/chpasswd,
seed_random, power_state, hostname (hostname/fqdn/manage_etc_hosts). Avoid
phone_home air-gapped — it only calls out to a URL and retries on failure. Full
per-module detail (keys, snippets, stage, frequency) in references/modules.md.
NoCloud seeding (the air-gapped core)
cloud-init finds NoCloud config from (in precedence order) SMBIOS serial → kernel cmdline → seed dirs → labeled block device. The common methods:
- Seed directory: drop
meta-data+user-data(and optionallyvendor-data,network-config) into/var/lib/cloud/seed/nocloud/. cidataISO/USB: avfat/iso9660filesystem labeledCIDATA(case-insensitive) containing those files in its root.genisoimage -output seed.iso -volid cidata -joliet -rock user-data meta-data network-config(orcloud-localds seed.iso user-data meta-data).- HTTP seed: kernel cmdline
ds=nocloud;s=http://10.0.0.1:8000/(the scheme decides local vs network; trailing slash required — files are appended as<uri>/user-dataetc.). - SMBIOS serial (QEMU/libvirt):
-smbios type=1,serial=ds=nocloud;s=http://10.0.0.1:8000/.
Required files: meta-data and user-data. Optional: vendor-data,
network-config. Since cloud-init 24.3, an HTTP/seed source that omits
network-config triggers a boot retry/timeout — ship an empty network-config
for back-compat, or a real netplan-v2 one.
instance-id re-run rule: cloud-init only re-applies user-data when the
instance-id in meta-data changes (or after cloud-init clean). Bump it
whenever the config changes.
Full seeding detail (cmdline grammar + aliases, DMI variable expansion, FTP, GRUB
escaping, seedfrom, dsname deprecations) is in references/nocloud-and-airgapped.md.
Network config (hand-off to netplan)
cloud-init network-config has v1 (its own schema) and v2 (which IS netplan
format). On Ubuntu the renderer is netplan; cloud-init writes
/etc/netplan/50-cloud-init.yaml. user-data cannot set network config —
networking comes from the datasource, system config, or kernel cmdline.
To author the network block, or to hand network control back to netplan entirely
(network: {config: disabled} in /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg),
use the ubuntu-netplan skill.
Validation & debugging (fast path)
cloud-init schema -c user-data --annotate # validate a file, errors inline
sudo cloud-init schema --system --annotate # validate the live system's user-data
cloud-init status --long --wait # 0=ok, 1=error, 2=recoverable error
cloud-init query --all # inspect instance-data
sudo cloud-init clean --logs # wipe state → next boot is "first boot"
Logs: /var/log/cloud-init.log, /var/log/cloud-init-output.log,
/run/cloud-init/ (incl. ds-identify.log, result.json, instance-data.json).
Config: /etc/cloud/cloud.cfg + /etc/cloud/cloud.cfg.d/*.cfg. Full CLI, the boot
stages, and re-run/golden-image notes in references/cli-and-debugging.md.
Air-gapped checklist
- Pin
datasource_list: [NoCloud](or[NoCloud, None]) in acloud.cfg.ddrop-in — stops cloud probing/timeouts. - Local apt mirror via the
aptmodule; embed repo signing keys inline. - Internal CA via
ca_certs.trusted. - Local NTP via
ntp.servers. - Omit
phone_home; don'tpackage_upgradewithout a full mirror; avoidsnapunless a local store / pre-acked assertions are available. - Use
vendor-datafor site-wide defaults (mirror, CA, NTP, base users); per-hostuser-dataoverrides it.
Upstream breaking changes worth knowing (doc/rtd/reference/breaking_changes.rst)
Verified against main on 2026-07-21. Ubuntu vendors patch some of these out —
the doc says so itself — so confirm against the image before assuming.
25.1.4 — strict datasource identity before network. The one most likely to
bite an on-prem/ARM fleet. ds-identify now requires strict identification
from DMI platform data, the kernel command line, or an explicit
datasource_list: in /etc/cloud/cloud.cfg.d. Previously, a platform without
clear identifying data fell into a late discovery mode that brought networking
up and reached out to well-known link-local IPs to fetch config — the hardening
exists to stop a bad actor on the local network answering those requests.
- Most affected: Ec2, OpenStack and AltCloud on non-x86, where the kernel may not expose DMI data.
- Failure mode is silent-ish: if no datasource is identified, cloud-init stays disabled and performs no configuration at all during boot — the machine simply comes up unconfigured.
- Mitigations: launch with
openstack server create … --config-drive true, or pindatasource_list:explicitly. This is a second, stronger reason for thedatasource_list: [NoCloud]pin already recommended in the air-gapped checklist — it is no longer only about avoiding probe timeouts.
Also recorded, lower operational impact for this skill's use cases:
| Version | Change |
|---|---|
| 26.1 | OpenStack bond names are no longer hard-coded to bond0/bond1… — they now take whatever network_data.json provides |
| 25.3 | Systemd socket protocol changed for compatibility with openbsd-netcat alternatives (e.g. nmap's ncat -U) — downstreams shipping a custom ExecStart= must update. Build backend moved setuptools/distutils → meson (PEP-0632); packagers should diff the generated package |
| 25.1 | /usr merge — packaging installs nothing to /lib any more, everything to /usr/lib. Affects non-systemd / older / non-Linux distros |
| 24.4 | cloud-final.service ordering standardized — changed the systemd boot order on some distributions |
| 24.3 | cloud-init.service → cloud-init-network.service (already covered above) |
| 24.1 | ds-identify no longer auto-appends None to a single-entry datasource_list (already covered above) |
Boundaries (sibling skills)
network:→ ubuntu-netplan skill (v2 is netplan format).- Ubuntu Server install automation → ubuntu-autoinstall skill. The installer
is itself driven by a NoCloud
user-datacarrying a top-levelautoinstall:key; cloud-init ignoresautoinstalland passes it to Subiquity. Inside that document, the nesteduser-data:is cloud-config for the installed system (first boot).
Reference files
references/nocloud-and-airgapped.md— every NoCloud seeding method, cmdline grammar, SMBIOS/DMI,instance-id,datasource_listpinning, DataSourceNone, vendor-data, deprecations.references/modules.md— full cloud-config module reference (on-prem modules in depth; the rest listed by stage). Has a TOC.references/cli-and-debugging.md— CLI, boot stages & module ordering, re-run / golden-image, version notes (24.x vs 26.1).references/examples.md— minimal and realistic air-gapped configs with matchingmeta-data.