Unattended install¶
VirtualBox 7.0+ ships an unattended install subcommand that wraps "install Ubuntu/Debian/Fedora/etc. without clicking through the installer". It works well — when the bundled templates know about your specific OS version. When they don't (and they don't for Ubuntu 26.04 as of VBox 7.2.8), you do it yourself via cloud-init.
This page covers both: VBoxManage's built-in path, why it falls behind, and the approach the lab uses (self-built cloud-init CIDATA ISO + remastered install ISO with autoinstall in GRUB).
The VBoxManage built-in path¶
VBoxManage unattended detect --iso /path/to/ubuntu-24.04.4-live-server-amd64.iso
# Detected '...' to be:
# OS TypeId = Ubuntu24_LTS_64
# OS Version = 24.04
# OS Flavor = Server
# OS Languages = ...
# OS Hints = ...
# Unattended installation supported = yes
VBoxManage unattended install <vm> \
--iso /path/to/iso \
--user morten \
--password 'changeme' \
--full-user-name "Morten Hansen" \
--hostname testvm.local \
--time-zone Europe/Oslo \
--locale en_US \ # ll_CC ONLY, no .UTF-8
--install-additions
What VBoxManage actually does:
- Detects the ISO's flavour from its metadata.
- Renders a
preseed.cfg(Debian) orautoinstall(Ubuntu Subiquity) or kickstart (RHEL) from a built-in template, filling in your--user,--hostname, etc. - Builds a small ISO containing that config plus an init script.
- Attaches the install ISO + the config ISO to the VM.
- Modifies the install ISO's bootloader to add the kernel cmdline that tells the installer to use the config.
- Boots the VM. Installer runs fully unattended.
Pros: one command. Works.
Cons:
- Only works for ISOs the bundled templates know. Each VirtualBox release ships templates for a snapshot of Ubuntu/RHEL/etc. Newer releases (Ubuntu 26.04 as of VBox 7.2.8) return
Unattended installation supported = noandPrepare()fails. - Custom autoinstall (extra packages, fancy partitioning, late-commands) requires templating that VBoxManage doesn't expose — you'd have to extract its templates and patch them.
For lab use against current Ubuntu, the lab does it manually instead.
The lab's approach — cloud-init + remastered ISO¶
Modern Ubuntu (24.04+) uses Subiquity as the installer. Subiquity supports a "fully unattended" mode that reads a cloud-init autoinstall: block from:
- The Ubuntu ISO itself (
/cdrom/autoinstall.yaml), if it's there, OR - Any cloud-init
NoClouddatasource — most easily, a CD-ROM labelledCIDATAcontaininguser-dataandmeta-datafiles
You also need to tell the kernel at boot time to look for autoinstall. That's done by adding autoinstall to the kernel cmdline. The simplest way: edit the install ISO's GRUB config.
So the lab does three things:
- Build a CIDATA ISO with cloud-init
user-data(the autoinstall config) andmeta-data(instance-id + hostname). - Remaster the Ubuntu install ISO to add
autoinstallto GRUB's kernel cmdline. - Boot the VM with both ISOs attached. Subiquity sees the cmdline, finds the CIDATA volume, applies the autoinstall config, installs Ubuntu unattended, reboots.
Step 1: build the CIDATA ISO¶
# Render user-data
cat > /tmp/user-data <<'EOF'
#cloud-config
autoinstall:
version: 1
interactive-sections: []
refresh-installer:
update: false
locale: en_US.UTF-8
keyboard: {layout: us}
network:
version: 2
ethernets:
enp0s3: {dhcp4: true}
eth0: {dhcp4: true}
identity:
realname: "Lab User"
username: morten
hostname: testvm
password: "$6$rounds=4096$..." # SHA-512 crypt, generated with `openssl passwd -6`
ssh:
install-server: true
allow-pw: false
authorized-keys:
- "ssh-ed25519 AAAA... my-key"
storage:
layout: {name: direct}
packages:
- openssh-server
- python3
late-commands:
- echo 'morten ALL=(ALL) NOPASSWD:ALL' > /target/etc/sudoers.d/90-morten
- chmod 0440 /target/etc/sudoers.d/90-morten
shutdown: reboot
EOF
# Render meta-data (instance-id is opaque; hostname is shown to cloud-init)
cat > /tmp/meta-data <<'EOF'
instance-id: iid-local-testvm
local-hostname: testvm
EOF
# Empty vendor-data (some Subiquity versions look for it)
touch /tmp/vendor-data
# Build the ISO — volume label MUST be CIDATA (case-insensitive)
xorriso -as mkisofs \
-output /tmp/cidata.iso \
-volid CIDATA \
-joliet -rock \
/tmp/user-data /tmp/meta-data /tmp/vendor-data
This is what src/msai_setup/lab/cloudinit.py does, parameterised by the lab config. The CIDATA ISO ends up ~400 KB.
Step 2: remaster the Ubuntu ISO¶
Add autoinstall to GRUB:
# Extract grub.cfg from the Ubuntu ISO
mkdir -p /tmp/iso-mod
xorriso -osirrox on \
-indev ubuntu-26.04-live-server-arm64.iso \
-extract /boot/grub/grub.cfg /tmp/iso-mod/grub.cfg
# Original lines look like:
# linux /casper/vmlinuz --- console=tty0
# We want:
# linux /casper/vmlinuz autoinstall --- console=tty0
chmod 0644 /tmp/iso-mod/grub.cfg # extracted file is read-only
sed -i 's|\(linux\s\+/casper/vmlinuz\)\s\+\(.*---\)|\1 autoinstall \2|g' /tmp/iso-mod/grub.cfg
# Also handle hwe-vmlinuz
sed -i 's|\(linux\s\+/casper/hwe-vmlinuz\)\s\+\(.*---\)|\1 autoinstall \2|g' /tmp/iso-mod/grub.cfg
# Write a new ISO with the patched grub.cfg, preserving the boot record
xorriso -indev ubuntu-26.04-live-server-arm64.iso \
-outdev ubuntu-26.04-live-server-arm64-autoinstall.iso \
-boot_image any keep \
-map /tmp/iso-mod/grub.cfg /boot/grub/grub.cfg \
-commit
This is what src/msai_setup/lab/iso.py:remaster_iso_for_autoinstall does. Result: a new ISO that boots straight into autoinstall mode.
-boot_image any keep is the key flag — it preserves the original boot record so the remastered ISO is still bootable. Without it, the new ISO has no bootloader and the VM refuses to boot from it.
Step 3: attach both ISOs and boot¶
VBoxManage storageattach test \
--storagectl SATA --port 7 --device 0 \
--type dvddrive --medium ubuntu-26.04-live-server-arm64-autoinstall.iso
VBoxManage storageattach test \
--storagectl SATA --port 8 --device 0 \
--type dvddrive --medium /tmp/cidata.iso
VBoxManage startvm test --type headless
Subiquity boots, sees autoinstall cmdline, looks for a NoCloud datasource, finds the CIDATA-labelled CD, applies the user-data, installs, and reboots.
After install, the ISOs auto-detach (the OS disk's GRUB takes over the boot chain).
Why this approach is more robust¶
- Any Ubuntu version works — no dependency on VBox shipping a template for it
- You control the autoinstall fully — late-commands, custom packages, network config, storage layout
- Reproducible — the CIDATA ISO is deterministic from your user-data; commit it to a script
- Forward-compatible — when Ubuntu 28.04 ships, the same code works without VBox upgrades
The trade-off is more code to maintain. The lab's cloudinit.py (~120 lines) + iso.py:remaster_iso_for_autoinstall (~50 lines) is the price.
Subiquity autoinstall — the full schema¶
The full schema lives at https://ubuntu.com/server/docs/install/autoinstall-reference. The minimum keys you actually need:
#cloud-config
autoinstall:
version: 1 # always 1 for now
interactive-sections: [] # empty = run everything unattended
locale: en_US.UTF-8
keyboard: {layout: us}
network: # required, even if just DHCP
version: 2
ethernets:
enp0s3: {dhcp4: true}
identity: # required
username: morten
hostname: testvm
password: "$6$..." # SHA-512 crypt
ssh: # optional but useful
install-server: true
authorized-keys: ["ssh-ed25519 ..."]
storage: # required
layout:
name: direct # use the whole first disk
packages: # optional
- openssh-server
late-commands: # optional, runs in the installer's chroot
- echo "..." > /target/etc/foo
shutdown: reboot # or 'poweroff'
The storage: block is where complexity hides. layout: name: direct is "use the first disk, default partition layout". For more control (LVM, encryption, multiple disks, specific partitions), the schema is documented but verbose. The lab keeps it simple.
late-commands: runs inside the target system's chroot, not the installer's live environment. Use /target/... paths (the installer mounts the target at /target during install) or curtin in-target -- ... to run commands in the actual target.
Password generation¶
Subiquity wants a crypt-hashed password in identity.password, not plaintext:
Or in Python (3.13+ removed crypt; use passlib or shell out to openssl):
import subprocess
result = subprocess.run(
["openssl", "passwd", "-6", "changeme"],
capture_output=True, text=True, check=True,
)
crypted = result.stdout.strip()
The lab's cloudinit.py:_crypt_password does exactly this.
Common autoinstall mistakes¶
Whitespace + YAML¶
The lab learned this the hard way: building YAML with f-string + textwrap.dedent leaves subtle indentation bugs when interpolated values themselves contain lines. Use yaml.safe_dump(dict, default_flow_style=False) instead — it generates correct YAML by construction.
# BAD — fragile
return textwrap.dedent(f"""\
autoinstall:
packages:
{package_lines} # easy to misalign
identity:
password: "{crypted}"
""")
# GOOD — robust
import yaml
return "#cloud-config\n" + yaml.safe_dump(
{"autoinstall": {
"packages": packages,
"identity": {"password": crypted, ...},
...
}},
default_flow_style=False, sort_keys=False,
)
Wrong volume label¶
The NoCloud datasource specifically looks for CIDATA (case-insensitive). Other labels are ignored. xorriso -volid CIDATA is the right invocation.
Missing meta-data¶
Even if it's just instance-id: iid-something, cloud-init wants it present.
Network interface names¶
The default Subiquity expects enp0s3 (the systemd-predictable name for the first VBox NIC). On other hypervisors it might be different (enp1s0 etc.). The lab's user-data sets both enp0s3 and eth0 to DHCP so it works across naming schemes.
interactive-sections: []¶
Without this (and with required keys missing), Subiquity falls back to interactive prompts. Always include interactive-sections: [] for true unattended.
Debugging an install that hangs at the menu¶
If Subiquity opens to the language menu instead of running unattended:
- Check the kernel cmdline. Look at the GRUB menu (
screenshotpng). Doesautoinstallappear in the linux line? If not, the remaster didn't take. Checkxorriso -indev <iso> -extract /boot/grub/grub.cfg -to see what's in the remastered ISO. - Check the CIDATA ISO contents.
xorriso -indev cidata.iso -ls— should showuser-data,meta-data,vendor-dataat the root. Volume label should beCIDATA. - Check the YAML.
xorriso -osirrox on -indev cidata.iso -extract /user-data -| python3 -c "import yaml, sys; yaml.safe_load(sys.stdin)"— does it parse? Does it have the right top-levelautoinstall:` key? - Check Subiquity's logs. SSH in (the live installer's sshd runs even during install, on the same port; user is
installer/no-key, so you need a way to read the framebuffer). Or boot interactively just to see what Subiquity says in/var/log/installer/on the target.
See also¶
- Storage — attaching the install ISO + CIDATA ISO
- VMs — VM lifecycle
- Apple Silicon — ARM-specific install quirks
- Ubuntu autoinstall reference