Ubuntu Autoinstall: A Complete Guide¶
Autoinstall is the unattended installation mechanism for Ubuntu Server since 20.04, and for Ubuntu Desktop since 23.04. It replaced debian-installer preseeding. You describe the finished machine in a YAML file, hand that file to the installer, and it partitions, installs, configures and reboots without anyone touching a keyboard.
This guide is written against Ubuntu 24.04 (Noble) and current Subiquity. Where behaviour differs on older releases it says so.
1. The moving parts¶
Three programs cooperate during an autoinstall, and most confusion comes from not knowing which one is reading which part of your file.
cloud-init runs first, inside the live installer environment booted from the ISO. Its job here is only to find a datasource (usually NoCloud), fetch meta-data and user-data, and apply whatever cloud-config it finds to the live system. When it sees a top-level autoinstall: key it validates that it is present and hands it off rather than acting on it.
Subiquity is the installer itself (the TUI you see on an interactive install). It reads the autoinstall: section, validates it against a JSON schema, and drives the install. Every key documented in the autoinstall reference is a Subiquity key.
curtin is the low-level engine Subiquity calls to actually partition disks, extract the root filesystem, install the bootloader and run in-target commands. The storage.config action list is curtin's format (plus Subiquity extensions), which is why it reads so differently from the rest of the file.
After the reboot, cloud-init runs again on the installed system, this time consuming whatever Subiquity put in the target from the autoinstall.user-data key. That is the point where first-boot configuration (extra users, runcmd, write_files) happens.
The practical consequence is the most important rule in this guide:
Top-level cloud-config keys in
user-data(alongsideautoinstall:) configure the live installer. Keys underautoinstall.user-dataconfigure the installed system on first boot.
Put users: or runcmd: at the top level and they run in a RAM disk that is about to be thrown away.
2. Getting the config to the installer¶
2.1 Delivery methods¶
There are two families of delivery.
Via cloud-init (NoCloud). The file must start with #cloud-config and contain a top-level autoinstall: key. cloud-init needs a datasource, which you supply either over HTTP (ds=nocloud-net;s=http://host:port/path/ on the kernel command line) or as a local volume labelled CIDATA (or cidata) containing user-data and meta-data. The volume can be an ISO built with cloud-localds, a second USB stick, or a small partition.
Directly on the media. A file named autoinstall.yaml at the root of the installation medium (the directory that contains casper/) is read by Subiquity without cloud-init being involved. No #cloud-config header is needed. Since 24.04 a single top-level autoinstall: key is accepted here as well, and you should use it for consistency. You can also point at a file inside the live system with subiquity.autoinstallpath=path/to/file.yaml.
When several sources exist, Subiquity uses the first it finds in this order: kernel command line (subiquity.autoinstallpath), root of the installation system, cloud-config, root of the installation medium.
2.2 The autoinstall kernel parameter¶
Delivering a config is not the same as authorising it to wipe disks. If Subiquity finds autoinstall data via cloud-init but autoinstall is not on the kernel command line, it stops and asks Continue with autoinstall? (yes|no). This is a safety net against a stray CIDATA USB stick reimaging a machine.
For a truly unattended install, the command line needs both pieces:
autoinstall ds=nocloud-net;s=http://192.168.1.10:8080/kvm/
Two details that trip people up. The seed URL must end with a slash, because cloud-init appends meta-data, user-data and vendor-data to it literally. And in a GRUB linux line the semicolon is a command separator, so it must be escaped (ds=nocloud-net\;s=...) or the whole argument quoted ("ds=nocloud-net;s=..."). When you pass arguments with virt-install --extra-args or QEMU -append there is no GRUB in the loop, so no escaping is needed. Recent cloud-init treats nocloud-net as an alias of nocloud, so ds=nocloud;s=http://... also works on 24.04; nocloud-net remains the most widely compatible spelling.
2.3 The seed files¶
A NoCloud seed directory contains three files.
user-data holds your configuration. It must begin with exactly #cloud-config on line one.
meta-data is mandatory even if it carries almost nothing; cloud-init treats a missing meta-data as "no datasource here." It usually sets instance-id and local-hostname. local-hostname names the live installer session; the installed system's hostname comes from identity.hostname.
vendor-data is optional. Leaving an empty file in place avoids a 404 in your server logs and keeps older cloud-init versions quiet.
3. File anatomy¶
A minimal valid file:
#cloud-config
autoinstall:
version: 1
identity:
hostname: ubuntu-server
username: ubuntu
password: '$6$...'
version: 1 is required. identity (or user-data) is the only section that must be present; everything else has a default.
Keys are hyphenated, not underscored: install-server, authorized-keys, allow-pw, late-commands, interactive-sections. The one notable exception is inside apt:, which inherits curtin's underscore style (preserve_sources_list). In schema version 1 an unknown key only produces a warning, so a typo like authorized_keys is silently ignored and your SSH key simply never lands. Future schema versions will make unknown keys fatal. Validate before you boot (section 10).
Quote password hashes with single quotes. They contain $ and sometimes / and .; single quotes guarantee YAML never interprets any of it.
4. Top-level key reference¶
| Key | Purpose | Interactive? |
|---|---|---|
version |
Must be 1 |
no |
interactive-sections |
Keys to still show in the UI; "*" shows everything with your values as defaults |
— |
early-commands |
Run before device probing; can rewrite /autoinstall.yaml |
no |
refresh-installer |
Update Subiquity from a snap channel before installing | yes |
locale, keyboard, timezone |
Localisation | locale/keyboard yes |
source |
Which install source on the ISO (ubuntu-server, ubuntu-server-minimal) |
yes |
network |
Netplan config, applied during install and written to the target | yes |
proxy |
HTTP proxy for install, apt and snapd | yes |
apt |
Mirrors, PPAs, fallback behaviour | yes |
storage |
Disk layout | yes |
identity |
First user, hostname, password hash | yes |
ssh |
install-server, authorized-keys, allow-pw |
yes |
ubuntu-pro, active-directory |
Attach Pro token, join AD | yes |
drivers, oem, codecs, kernel, kernel-crash-dumps |
Hardware and kernel selection | mostly no |
snaps, packages, debconf-selections |
Software in the target | snaps yes |
updates |
security (default) or all before first reboot |
no |
late-commands, error-commands |
Hooks after success / on failure | no |
reporting |
Progress to console, webhook, or nowhere | no |
user-data |
cloud-config for the installed system's first boot | no |
shutdown |
reboot (default) or poweroff |
no |
Note what is not on the list: there is no reboot: key (use shutdown:), no console-setup: key (keyboard config covers it), and no hostname: at top level (it lives under identity).
5. Identity, users and SSH¶
identity:
realname: Andrew
hostname: node01
username: andrew
password: '$6$rounds=...'
groups:
append: [adm, sudo]
ssh:
install-server: true
authorized-keys:
- ssh-ed25519 AAAAC3Nz... andrew@workstation
allow-pw: false
Generate the hash with mkpasswd -m sha-512 (from the whois package) or openssl passwd -6. A password is required for sudo even when you log in with keys, so do not skip it unless you grant NOPASSWD sudo via user-data. A hash that looks valid but was never generated from a real password (for example $6$abcd...abcd) produces a user who cannot authenticate at the console at all.
allow-pw defaults to true when authorized-keys is empty and false otherwise. Setting it explicitly documents intent.
If you need more than one user, or need users created by cloud-init on first boot rather than during install, use the user-data key with standard cloud-config users:. When user-data is present, identity becomes optional.
6. Networking¶
network takes a Netplan document. It is applied in the live environment so the installer can reach mirrors, and then written into the target.
Avoid hard-coding interface names. enp3s0 on one motherboard is eno1 on the next, and under libvirt a virtio NIC is ens2/ens3 on the i440fx machine type but enp1s0 on q35. Use a match block with an arbitrary key name:
network:
version: 2
ethernets:
primary:
match:
name: "en*"
dhcp4: true
For a static address on a specific NIC, match on MAC and set a name:
network:
version: 2
ethernets:
lan:
match:
macaddress: "52:54:00:12:34:56"
set-name: lan0
addresses: [192.168.1.50/24]
routes:
- to: default
via: 192.168.1.1
nameservers:
addresses: [192.168.1.1]
The 20.04 GA installer required an extra nested network: key (network: { network: { version: 2, ... } }) because of a bug. Modern Subiquity accepts both forms, but use the single-level form in new files.
If you omit network entirely, the installer runs DHCPv4 on every eth*/en* interface and disables any that get no address, which is often exactly what you want.
7. APT and mirrors¶
The modern form wraps candidates in mirror-selection; Subiquity tests each in order and uses the first that works:
apt:
preserve_sources_list: false
mirror-selection:
primary:
- country-mirror
- uri: "http://archive.ubuntu.com/ubuntu"
fallback: offline-install
geoip: true
country-mirror resolves to http://CC.archive.ubuntu.com/ubuntu using a geoip lookup. fallback decides what happens when nothing is reachable: abort, offline-install (install from the ISO only), or continue-anyway. For a local apt cache such as apt-cacher-ng, put its URL first. PPAs and extra repositories go under apt.sources in curtin's format.
8. Storage¶
Storage is the hardest part of autoinstall and the most destructive when wrong. There are two mutually exclusive styles.
8.1 Layouts¶
Layouts are presets:
storage:
layout:
name: lvm # or: direct, zfs, hybrid
sizing-policy: all # lvm only; default "scaled"
password: "..." # lvm only; enables LUKS
match:
ssd: true # which disk; default is the largest
direct gives you a GPT disk with an ESP (or BIOS boot partition) and one ext4 root. lvm adds a volume group; with the default scaled policy the root LV gets 100 GiB on large disks and the rest of the VG is left free for snapshots and growth, which surprises people who expected the whole disk. sizing-policy: all gives root the lot. hybrid with encrypted: yes is TPM-backed full disk encryption.
On a machine with more than one disk there is no default; you must supply a layout with a match, or an action list.
A LUKS passphrase in layout.password is stored in plain text in your user-data and in /var/log/installer/autoinstall-user-data on the installed system. Treat the file accordingly.
8.2 Action lists (storage.config)¶
For anything a layout cannot express (RAID, custom partitioning, multiple filesystems) you write curtin actions. Each action has a type and an id, and later actions refer to earlier ones by id. Actions are processed in file order, so a reference to an id that has not been declared yet is an error.
The action types you will use are disk, partition, raid, lvm_volgroup, lvm_partition, dm_crypt, format and mount. A useful mental model is a pipeline: disks are carved into partitions; partitions optionally feed RAID, LVM or crypt devices; anything block-shaped gets a format; formats get a mount.
Fields that matter on every action: preserve: false means "create this, destroying what's there" (with true, curtin expects it to already exist and leaves it alone). wipe on disks and partitions controls how aggressively old signatures are cleared; superblock-recursive on a disk also clears signatures on existing partitions, which matters when reusing disks that once held md or LVM metadata.
Disk selection. A disk action can name a disk with path: /dev/sda or serial: ... directly, or use a Subiquity match spec with globbing: model, vendor, serial, path, id_path, devpath, ssd: true|false, size: largest|smallest, and the special install-media: true. Each disk action is assigned a disk not already claimed, so two consecutive match: {size: largest} actions pick the largest disk and then the largest remaining disk. The size selectors never return the install medium. Since Subiquity 24.08.1 match may be an ordered list of specs to try in turn.
Sizes. Subiquity extends curtin to accept 512M, 20G, percentages like 50%, and -1 for "the rest of the device" on the last partition.
Boot partitions. On UEFI you need an ESP: a FAT32 partition with flag: boot and grub_device: true, mounted at /boot/efi. On BIOS with GPT you need a 1 MiB partition with flag: bios_grub, and grub_device: true goes on the disk. Getting this wrong is the most common reason an otherwise successful install does not boot.
8.3 Worked example: RAID1 root on UEFI¶
The goal: two identical disks, an ESP on each so the machine boots with either disk missing, and the rest of both disks mirrored with md RAID1 holding an ext4 root.
storage:
config:
- {type: disk, id: disk0, match: {size: largest}, ptable: gpt,
wipe: superblock-recursive, preserve: false, grub_device: false}
- {type: disk, id: disk1, match: {size: largest}, ptable: gpt,
wipe: superblock-recursive, preserve: false, grub_device: false}
- {type: partition, id: disk0-esp, device: disk0, number: 1, size: 1G,
flag: boot, grub_device: true, wipe: superblock, preserve: false}
- {type: partition, id: disk1-esp, device: disk1, number: 1, size: 1G,
flag: boot, grub_device: true, wipe: superblock, preserve: false}
- {type: partition, id: disk0-md, device: disk0, number: 2, size: -1,
flag: raid, wipe: superblock, preserve: false}
- {type: partition, id: disk1-md, device: disk1, number: 2, size: -1,
flag: raid, wipe: superblock, preserve: false}
- {type: raid, id: md0, name: md0, raidlevel: 1,
devices: [disk0-md, disk1-md], preserve: false}
- {type: format, id: disk0-esp-fs, volume: disk0-esp, fstype: fat32, preserve: false}
- {type: format, id: disk1-esp-fs, volume: disk1-esp, fstype: fat32, preserve: false}
- {type: format, id: md0-fs, volume: md0, fstype: ext4, label: ROOT, preserve: false}
- {type: mount, id: md0-mount, device: md0-fs, path: /}
- {type: mount, id: esp-mount, device: disk0-esp-fs, path: /boot/efi}
The RAID level key is raidlevel (curtin's name), not level, and a raid action without devices cannot be built. The ESPs live outside the array because firmware cannot read md metadata. Only one ESP is mounted; marking both grub_device: true tells the installer to install GRUB to each, and GRUB's grub-efi-amd64/install_devices debconf setting keeps both updated on later kernel and GRUB upgrades. After install, cat /proc/mdstat should show [UU] once the initial resync completes, and efibootmgr -v should list an entry per disk.
If the server has an additional larger data disk, size: largest will grab it. Pin the mirror members by serial instead (lsblk -o NAME,SERIAL,SIZE from the live shell gives you the values):
- {type: disk, id: disk0, match: {serial: "Samsung_SSD_870*S5Y1NJ0R1*"}, ...}
8.4 Variant: LVM on the mirror¶
To keep LVM flexibility on top of RAID, replace the md0-fs format and its mount with:
- {type: lvm_volgroup, id: vg0, name: vg0, devices: [md0], preserve: false}
- {type: lvm_partition, id: lv-root, volgroup: vg0, name: root, size: 50G, preserve: false}
- {type: format, id: lv-root-fs, volume: lv-root, fstype: ext4, preserve: false}
- {type: mount, id: lv-root-mount, device: lv-root-fs, path: /}
8.5 Swap¶
With a layout, the installer creates a swap file sized by its own heuristic. With an action list you control it via storage.swap; swap: {size: 0} disables the swap file, which is common on Kubernetes nodes.
9. Commands and hooks¶
early-commands, late-commands and error-commands are lists. A string item runs through sh -c; a list item is executed directly. All run as root in the live environment. A non-zero exit from early or late commands aborts the install.
early-commands run before disks and networks are probed. The config is re-read from /autoinstall.yaml afterwards, so an early command can fetch or template the real config, for example selecting a profile by MAC address.
late-commands run after packages and updates are installed, with the target mounted at /target. Use curtin in-target -- <command> to run inside the target (always include the -- so options belong to your command, not curtin's):
late-commands:
- curtin in-target -- systemctl enable --now fstrim.timer
- echo 'andrew ALL=(ALL) NOPASSWD:ALL' > /target/etc/sudoers.d/90-andrew
- chmod 0440 /target/etc/sudoers.d/90-andrew
Writing into /target/... from the live side is fine for files; use in-target when you need the target's binaries, package manager or systemd.
You rarely need systemctl enable for packages you listed under packages:. Debian packaging enables services at install time, and some, like qemu-guest-agent, are activated by udev when the matching device appears.
error-commands run only on failure, and their own failures are ignored. Their best use is preserving evidence:
error-commands:
- tar -czf /var/log/installer-logs.tar.gz /var/log/installer/
- journalctl -b > /var/log/installer-journal.log
For first-boot configuration that belongs to the installed OS rather than the install process, prefer autoinstall.user-data with cloud-config modules (write_files, runcmd, packages). It is idempotent-ish, logged in /var/log/cloud-init-output.log, and keeps installer hooks small.
A useful debugging trick is to pause the installer so you can inspect state:
late-commands:
- while [ ! -f /run/finish-late ]; do sleep 1; done
Switch to another console, look around /target, then touch /run/finish-late to continue.
10. Validation¶
Validate in layers. A YAML linter catches indentation and syntax. cloud-init schema -c user-data checks the cloud-config wrapper and that an autoinstall key with a version exists. Subiquity's own validator applies the same JSON schema the installer uses at runtime:
git clone https://github.com/canonical/subiquity.git && cd subiquity
make install_deps
./scripts/validate-autoinstall-user-data.py ../autoinstall/profiles/kvm/user-data -vvv
Pass --no-expect-cloudconfig for an autoinstall.yaml destined for the install medium. Know its limits: it assumes an Ubuntu Server target whose only source is synthesized, so a source.id such as ubuntu-server-minimal fails validation even though it is correct on the real ISO, and it cannot check whether a match spec will find a disk on real hardware. Neither tool catches semantic mistakes like an ESP without grub_device. The final validator is a test install in a VM.
11. Serving seeds over HTTP¶
Any static web server works; the installer only issues plain GETs. The repo uses nginx with each profile in its own directory, so one server can hold many machine types:
http://host:8080/kvm/{meta-data,user-data,vendor-data}
http://host:8080/baremetal-raid1/{meta-data,user-data,vendor-data}
A few things in the nginx config are worth explaining. Seed files have no extension, so an nginx types { text/cloud-config user-data; } block never matches them: types maps extensions, and user-data has none. default_type is what actually sets the content type. cloud-init does not care what the content type is, so text/plain is the most practical choice because browsers then display the file inline. The access log is your best install-time diagnostic: a request for meta-data followed by user-data proves the kernel command line, networking and URL are all correct. Cache-Control: no-store stops anything caching a stale profile while you iterate.
Mount only the profiles directory into the container. Mounting the whole repo publishes your README, nginx config and anything else that happens to sit beside them.
The seed files contain password hashes, so restrict the server to your LAN with allow/deny, or only run it while you are installing. For the occasional quick test, python3 -m http.server 8080 from inside profiles/ is perfectly adequate.
12. Testing in KVM¶
The fast iteration loop is: edit a profile, make vm, watch it install on the serial console, poke at the result, make vm-destroy, repeat.
The key to unattended installs under libvirt is --location rather than --cdrom. --extra-args only works with --location, because virt-install needs to boot the kernel and initrd directly to inject arguments; with --cdrom it refuses. Pointing --location at the ISO with explicit kernel and initrd paths works for the live server ISO:
virt-install \
--name ai-kvm \
--memory 4096 --vcpus 2 \
--osinfo ubuntu24.04 \
--boot uefi \
--disk size=20,format=qcow2,bus=virtio \
--network network=default,model=virtio \
--location /var/lib/libvirt/boot/ubuntu-24.04-live-server-amd64.iso,kernel=casper/vmlinuz,initrd=casper/initrd \
--graphics none \
--extra-args 'autoinstall ds=nocloud-net;s=http://192.168.122.1:8080/kvm/ console=ttyS0,115200n8'
192.168.122.1 is the host's address on libvirt's default NAT bridge. With --graphics none virt-install attaches to the serial console and stays running; when the installer reboots, virt-install restarts the domain from its disk instead of the ISO. If you detach (Ctrl-]), reattach with virsh console. --osinfo must match the release; ubuntu22.04 for a 24.04 ISO is harmless but wrong, and an outdated osinfo-db may not know ubuntu24.04 yet, in which case --osinfo linux2022 works.
To test seed-ISO delivery instead of HTTP, build a CIDATA image and attach it as a second CD-ROM:
cloud-localds build/kvm-seed.iso profiles/kvm/user-data profiles/kvm/meta-data
# then add to virt-install:
--disk path=$PWD/build/kvm-seed.iso,device=cdrom \
--extra-args 'autoinstall console=ttyS0,115200n8'
cloud-localds comes from the cloud-image-utils package. Under qemu:///system the qemu user must be able to read the ISO, so if your home directory is not traversable, build it under /var/lib/libvirt/boot.
To test the RAID profile in a VM, give it two disks and UEFI firmware: make vm PROFILE=baremetal-raid1 DISKS=2. Then pull a disk with virsh detach-disk and confirm it still boots. Testing degraded boot in a VM is much cheaper than discovering a missing ESP on real hardware.
Do not mix a cloud image backing store (--disk backing_store=ubuntu.qcow2) with an installer ISO. A cloud image is an already-installed system that boots directly with cloud-init; an installer would just overwrite it. Pick one approach per VM.
13. Bare metal delivery¶
Edit GRUB once. Boot the stock USB, highlight "Try or Install Ubuntu Server", press e, and add to the end of the linux line before ---:
autoinstall ds=nocloud-net\;s=http://192.168.1.10:8080/baremetal-raid1/
Press Ctrl-x or F10. Fine for one or two machines.
Two USB sticks. Write the stock ISO to one stick and make the second a FAT filesystem labelled CIDATA containing user-data and meta-data. You will still be asked to confirm unless you add autoinstall at the GRUB prompt, which is a reasonable safety property for a stick you carry around.
Remastered ISO. Bake both the kernel arguments and, optionally, the config into a custom ISO. xorriso can replace a file and replay the original boot setup without unpacking anything:
xorriso -osirrox on -indev ubuntu-24.04-live-server-amd64.iso \
-extract /boot/grub/grub.cfg grub.cfg
chmod u+w grub.cfg
# edit grub.cfg: add `autoinstall` to the linux line (and ds=... if serving over HTTP)
xorriso -indev ubuntu-24.04-live-server-amd64.iso \
-outdev ubuntu-24.04-autoinstall.iso \
-map grub.cfg /boot/grub/grub.cfg \
-map profiles/baremetal-raid1/user-data /autoinstall.yaml \
-boot_image any replay
If you map a file to /autoinstall.yaml, it is read directly by Subiquity, not cloud-init, which is allowed to keep its #cloud-config header but is better kept as a plain autoinstall: document. Remember that such an ISO wipes whatever it boots on without asking. Label it.
PXE / iPXE. Serve the ISO's casper/vmlinuz and casper/initrd, and have the kernel download the ISO itself:
#!ipxe
kernel http://192.168.1.10:8080/boot/vmlinuz ip=dhcp url=http://192.168.1.10:8080/boot/ubuntu-24.04-live-server-amd64.iso autoinstall ds=nocloud-net;s=http://192.168.1.10:8080/baremetal-raid1/ cloud-config-url=/dev/null
initrd http://192.168.1.10:8080/boot/initrd
boot
cloud-config-url=/dev/null stops cloud-init from trying to treat the url= ISO as a cloud-config. The ISO is loaded into RAM, so netbooted machines need roughly 4 GB of memory or more. With PXE, set shutdown: poweroff or make sure the firmware boots from disk after install; otherwise the machine boots straight back into the installer and reinstalls itself forever.
14. Debugging¶
Everything Subiquity and curtin do is logged under /var/log/installer/, both in the live session and, after a successful install, on the installed system. The files you will open most are subiquity-server-debug.log (decisions, validation errors, storage matching), curtin-install.log (partitioning and package steps, with the actual commands run) and autoinstall-user-data (the effective config Subiquity used). cloud-init's side is in /var/log/cloud-init.log and /var/log/cloud-init-output.log. In the live system, /autoinstall.yaml is what Subiquity actually loaded, and cloud-init query userdata shows what cloud-init fetched.
To get a shell in the live environment during an install, switching to another virtual console (Ctrl-Alt-F2) usually gets you one on a physical console; on a serial-only VM, use the pause trick from section 9 combined with virsh console, or open the Help menu's shell option if the install is interactive.
| Symptom | Likely cause |
|---|---|
| No requests in the web server log | Kernel argument missing, typo'd, or ; not escaped in GRUB; seed URL missing trailing slash; live system has no network |
meta-data fetched but not user-data |
meta-data returned an error or invalid YAML |
| Files fetched, but the interactive installer appears | #cloud-config missing from line 1, or no top-level autoinstall: key |
| "Continue with autoinstall? (yes|no)" | Config found, but autoinstall not on the kernel command line |
| "Malformed autoinstall in '...' section" | Schema error; run the Subiquity validator with -vvv |
| SSH key not installed / sshd missing | Underscored keys (authorized_keys, install_server) silently ignored in v1 |
| Storage error at start of install | Forward reference to an undeclared id, raid without devices, match found no unassigned disk |
| Installs fine, won't boot | ESP or bios_grub partition missing, or grub_device on the wrong action |
| Network config wrong after install | Hard-coded interface name that does not exist on this hardware |
| Machine reinstalls in a loop | Netboot or remastered ISO still first in boot order; use shutdown: poweroff |
15. Security notes¶
A seed served over plain HTTP is readable by anyone on the network path, and it contains a password hash and possibly a LUKS passphrase or Ubuntu Pro token. SHA-512 crypt hashes are slow to crack but not uncrackable, so do not reuse a password that guards anything else, and rotate it if the hash has ever been in a public repository. Prefer SSH keys with allow-pw: false, keep the server LAN-only and stopped when idle, and keep secrets out of git (template them in at serve time) if the repo is ever pushed anywhere public. early-commands that fetch a config should only fetch from a server you trust, because whatever they download runs as root on a machine that is about to have its disks wiped.
Appendix: changes made to the original repository¶
The original layout mixed the served seed files with the repository's own docs and server config, and the README's docker run mounted ./autoinstall-nginx/default.conf, a path that did not exist (the file lived at nginx/default.conf). It also mounted the whole repo into the web root, publishing the README and nginx config alongside the seeds.
The new layout separates what is served from what is not:
autoinstall/
├── README.md
├── Makefile # serve, lint, validate, seed, vm, vm-seed, console, vm-destroy
├── compose.yaml # nginx, mounts only profiles/
├── .gitignore # build/, *.iso, *.qcow2, *.img
├── .yamllint.yaml # lints extension-less user-data/meta-data
├── docs/
│ └── autoinstall-guide.md
├── nginx/
│ └── default.conf
└── profiles/ # the web root; one directory per machine type
├── kvm/
│ ├── user-data
│ ├── meta-data
│ └── vendor-data
└── baremetal-raid1/
├── user-data
├── meta-data
└── vendor-data
The old root user-data became profiles/baremetal-raid1/. It had ssh.authorized_keys (ignored; now authorized-keys), a hard-coded enp3s0 (now a match), and a storage section that could not install: a raid action with no devices, nothing to put it on, no filesystem and no mount, plus a commented-out alternative using level instead of raidlevel and declaring a partition on md0 after formatting all of md0. It is replaced by the working two-disk UEFI RAID1 layout from section 8.3.
The old data/kvm/ became profiles/kvm/. ssh.install_server was ignored, so sshd was never installed; console-setup and reboot are not autoinstall keys; locale: en_US became en_US.UTF-8; the doubled network: network: form and hard-coded ens2 became a match block; the placeholder password hash, which matched no password despite the comment saying ubuntu, is replaced with Canonical's documented test hash for ubuntu; timezone moved from first-boot user-data to the top-level key; the redundant systemctl enable qemu-guest-agent late command was removed; and the legacy apt.primary became mirror-selection with an offline fallback.
The KVM README's commands are now Makefile targets. The --cdrom plus --extra-args combination, which virt-install rejects, became --location with explicit kernel and initrd paths; --os-variant ubuntu22.04 became --osinfo ubuntu24.04; the cloud-image backing_store example was dropped because it conflicts with installing from the ISO; serial was dropped because it is not a kernel parameter, as was subiquity.show=1, which does not appear in Subiquity's documented parameters; and --boot uefi was added so the RAID profile's ESP layout can be tested in a VM.