Ubuntu's cloud images are the quickest way to a Linux VM I know. There's no installer to click through, the download is under 1 GB, and they boot in seconds. Then you boot one on your Mac and hit two walls at once: the disk is about 3.5 GB, and there's no account you can log in with.
Both walls have the same cause. A cloud image expects a cloud to finish setting it up. On your laptop, there is no cloud, so you have to play its part.
This post walks through what that means and how to do it by hand on macOS with QEMU: a login, a disk of the size you want, and SSH without the fingerprint prompt. It ends with a script that does all of it, and two open-source tools that do it for you.
Written with the help of an AI assistant, and reviewed by me. I build Velo Workspaces, a Mac app that automates these steps, but nothing here needs it: everything works with plain QEMU, and the same ideas apply to UTM, Tart or anything else that boots a disk image.
What's actually in a cloud image
Grab the arm64 image for Ubuntu 26.04 and look at it:
curl -LO https://cloud-images.ubuntu.com/releases/26.04/release/ubuntu-26.04-server-cloudimg-arm64.img
qemu-img info ubuntu-26.04-server-cloudimg-arm64.img
Despite the .img name it's a QCOW2 file. Its virtual size is about 3.5 GiB, while the file itself is under 1 GB, because QCOW2 only stores the blocks that hold data. Inside is a fully installed Ubuntu: an EFI system partition, a root filesystem, a kernel, and cloud-init.
What it doesn't have is a user you can sign in as. The stock ubuntu account is created by cloud-init at first boot, with its password locked, and the only way in is an SSH key that "the cloud" hands over.
So everything depends on cloud-init finding a datasource: the place a cloud publishes a VM's configuration. On first boot, a tool called ds-identify looks for one (EC2's metadata service, Azure's, GCE's, and a dozen more). On Ubuntu, if it finds none, cloud-init simply disables itself. No account is created, no SSH key is installed, and the disk isn't grown. You're left at a login prompt with nothing that works.
Be the cloud: a NoCloud seed
The datasource meant for exactly this situation is NoCloud. It's just a small filesystem whose volume label is cidata (or CIDATA), FAT or ISO 9660, with two files at its root.
meta-data identifies the instance:
instance-id: devbox-001
local-hostname: devbox
cloud-init runs its "once per instance" setup whenever it sees a new instance-id. Keep it stable for the VM's life, and change it if you want first-boot setup to run again.
user-data says what to set up:
#cloud-config
hostname: devbox
users:
- name: you
groups: [sudo]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
ssh_authorized_keys:
- "ssh-ed25519 AAAA...your-public-key... you@your-mac"
ssh_pwauth: false
A few details worth knowing:
-
There's no
- defaultunderusers:. That means the stockubuntuaccount is never created, and the only way in is the account you named. -
Quote anything that could be read as something else. In YAML, a hostname of
yesis a boolean, and a key comment containing#loses everything after it. -
A password is optional. Here the account has no password and passwordless
sudo, which is common for throwaway VMs. If you want one (forsudoor the console),passwd:takes a crypt hash such as$6$..., never the plain password. Making that hash on a Mac is less obvious than it sounds: macOS'scrypt(3)only does old DES, so use OpenSSL 3 from Homebrew (openssl passwd -6) ormkpasswdin any Linux box.
macOS can build the seed without installing anything:
mkdir seed && mv meta-data user-data seed/
hdiutil makehybrid -o seed.iso -iso -joliet -default-volume-name cidata seed
Attach seed.iso to the VM as a second drive, and cloud-init finds it by its label on first boot.
Growing the disk, and the part most guides skip
Making the disk bigger looks like one command:
qemu-img resize ubuntu-26.04-server-cloudimg-arm64.img 40G
That only makes the file bigger, though. The partition table and the root filesystem inside it still describe a 3.5 GB disk. Two more things have to happen:
-
The root partition has to grow to the new end of the disk (
growpart). -
The filesystem has to grow to fill the partition (
resize2fsfor ext4).
The good news: cloud-init's growpart and resizefs modules do both at boot. So if you resize before the first boot, and the seed is attached so cloud-init actually runs, the VM comes up at full size. If cloud-init didn't run, or you'd rather not reboot, do it yourself inside the VM:
sudo growpart /dev/vda 1
sudo resize2fs /dev/vda1
(Check your layout with lsblk first. On Ubuntu's cloud images the root filesystem is partition 1.)
Now the part most guides skip. A GPT partition table keeps a backup copy of its header at the very end of the disk, and the primary header records where that backup is. Resizing the file moves the end of the disk, but not the backup header, which is now stranded somewhere in the middle. On the next boot the kernel notices and says so:
GPT:Alternate GPT header not at the end of the disk.
GPT:Use GNU Parted to correct GPT errors.
Linux carries on with the primary header, and a recent growpart moves the backup as part of growing the partition, so on current Ubuntu it usually resolves itself. Older growpart versions on other distributions didn't, and some partitioning tools refuse to touch the disk until it's fixed. If you see that message, one command puts the backup where it belongs:
sudo sgdisk -e /dev/vda
If you're using Apple's Virtualization framework
QEMU reads QCOW2 directly. Apple's Virtualization framework (used by Tart, Lima's vz mode, UTM's Apple backend and others) only boots raw disk images, so convert first, then grow:
qemu-img convert -f qcow2 -O raw ubuntu-26.04-server-cloudimg-arm64.img devbox.raw
qemu-img resize -f raw devbox.raw 40G
On APFS both steps produce a sparse file. ls -lh shows 40G, but du -h shows only what Ubuntu has actually written, so a generous size costs nothing until you fill it.
SSH without the fingerprint prompt
Boot a fresh VM, SSH in, and you get the familiar "authenticity of host can't be established" question. That's because cloud-init generates the VM's host keys on first boot, so your Mac has never seen them. Recreate the VM on the same address and port, and SSH greets you with a full-screen "REMOTE HOST IDENTIFICATION HAS CHANGED" warning instead. With throwaway VMs, that happens all the time.
The fix is to generate the host key yourself, hand it to cloud-init, and tell SSH about it before the VM ever boots:
ssh-keygen -q -t ed25519 -N '' -C devbox -f devbox-host
Add it to user-data:
ssh_deletekeys: true
ssh_keys:
ed25519_private: |
-----BEGIN OPENSSH PRIVATE KEY-----
...the contents of devbox-host, indented...
-----END OPENSSH PRIVATE KEY-----
ed25519_public: "ssh-ed25519 AAAA... devbox"
Then pin it in a known-hosts file for this VM:
echo "[127.0.0.1]:2222 $(cut -d' ' -f1,2 devbox-host.pub)" > devbox.known_hosts
ssh -p 2222 -o UserKnownHostsFile=devbox.known_hosts you@127.0.0.1
The first connection is trusted with no prompt, because it really is the key you made. One caveat: the seed now contains a private key, so treat seed.iso like one and delete it once the VM is set up.
The whole thing as one script
Here's everything above in one script for an Apple silicon Mac with QEMU (brew install qemu). It downloads the image once and checks it against Ubuntu's published checksums. Then, for each VM, it makes an APFS clone of the image, grows the clone, generates a host key, writes the seed and prints the commands to boot and connect.
#!/bin/sh
# make-vm.sh NAME SIZE e.g. ./make-vm.sh devbox 40G
set -eu
NAME=${1:-devbox}
SIZE=${2:-40G}
GUEST_USER=you # a valid Linux username
PUBKEY=$(cat ~/.ssh/id_ed25519.pub) # the key you'll sign in with
IMG=ubuntu-26.04-server-cloudimg-arm64.img
URL=https://cloud-images.ubuntu.com/releases/26.04/release
# 1. The image: download once, verify, clone per VM, grow the clone
[ -f "$IMG" ] || curl -fLO "$URL/$IMG"
curl -fsL "$URL/SHA256SUMS" | grep -E "[ *]$IMG\$" | sed 's/ \*/ /' | shasum -a 256 -c -
cp -c "$IMG" "$NAME.qcow2" # -c: an APFS clone, instant and free
qemu-img resize "$NAME.qcow2" "$SIZE"
# 2. A host key we can trust before the first boot
rm -f "$NAME-host" "$NAME-host.pub"
ssh-keygen -q -t ed25519 -N '' -C "$NAME" -f "$NAME-host"
# 3. The NoCloud seed
mkdir -p "$NAME-seed"
cat > "$NAME-seed/meta-data" <<EOF
instance-id: $NAME-$(date +%s)
local-hostname: $NAME
EOF
{
cat <<EOF
#cloud-config
hostname: "$NAME"
users:
- name: "$GUEST_USER"
groups: [sudo]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
ssh_authorized_keys:
- "$PUBKEY"
ssh_pwauth: false
ssh_deletekeys: true
ssh_keys:
ed25519_private: |
EOF
sed 's/^/ /' "$NAME-host"
echo " ed25519_public: \"$(cat "$NAME-host.pub")\""
} > "$NAME-seed/user-data"
rm -f "$NAME-seed.iso"
hdiutil makehybrid -o "$NAME-seed.iso" -iso -joliet -default-volume-name cidata "$NAME-seed"
# 4. Pin the host key, and say how to boot and connect
echo "[127.0.0.1]:2222 $(cut -d' ' -f1,2 "$NAME-host.pub")" > "$NAME.known_hosts"
cat <<EOF
Boot it (Ctrl-A then X quits QEMU):
qemu-system-aarch64 -machine virt -accel hvf -cpu host -smp 4 -m 4G \\
-bios "$(brew --prefix qemu)/share/qemu/edk2-aarch64-code.fd" \\
-drive if=virtio,format=qcow2,file=$NAME.qcow2 \\
-drive if=virtio,format=raw,readonly=on,file=$NAME-seed.iso \\
-nic user,model=virtio-net-pci,hostfwd=tcp:127.0.0.1:2222-:22 \\
-nographic
Then, in another terminal:
ssh -p 2222 -o UserKnownHostsFile=$PWD/$NAME.known_hosts $GUEST_USER@127.0.0.1
EOF
Once you're in, df -h / shows a root filesystem of nearly the full size you asked for, from an image that started at 3.5 GB.
Lessons from automating this for real
I've built this flow into a Mac app, and these are the things that cost me the most time. They apply whatever tool you use.
-
Fedora and Rocky Linux search every cloud on every boot. Without a hint, their cloud-init probes each cloud provider's metadata service in turn and waits for each to time out. In a local VM that can add minutes to every boot. Write
datasource_list: [ NoCloud, None ]to a file in/etc/cloud/cloud.cfg.d/(cloud-config'swrite_filescan do it on first boot), and they boot like Ubuntu. -
Attach the seed as a virtio disk, not USB. Some cloud kernels leave out drivers for physical hardware. Debian's
cloud-arm64kernel, for example, has no USB storage support. Every cloud kernel has virtio-blk, because its root disk is on it. -
The seed doesn't have to be an ISO. cloud-init reads a FAT volume labelled
CIDATAjust as well, which matters if you're writing the seed from code, where building an ISO is awkward. -
Hash the password on the host. The seed is a file on your disk. Put a
$6$hash in it, never the password itself. - Fix the GPT before the first boot, not after. If you grow the disk in code, move the backup header to the new end at the same time. The guest then never boots from a partition table that disagrees with its disk.
Or let a tool do it
If you'd rather not maintain a script:
-
Lima (free, open source) generates the seed, the SSH keys and the port forward for you, from a YAML template:
limactl start --name=devbox --disk=40 template:ubuntu. It also mounts your home folder into the VM (read-only by default), which you'll either love or want to turn off. -
Multipass (free, open source, from Canonical) is the Ubuntu-centric version:
multipass launch --name devbox --disk 40G.
Takeaways
- A cloud image is an installed system waiting for a cloud. On your Mac, a 2-file
CIDATAdisk is the cloud. - Growing a disk is three changes: the file, the partition, the filesystem. cloud-init does the last two at boot, if it runs at all.
- Pre-generating the host key turns SSH's first-connection prompt from a ritual into a non-event.
If you try the script and something doesn't boot, tell me in the comments what you see on the console. Most problems show up there in the first ten seconds.
Top comments (0)