☁️ FreeBSD in the Cloud: First-Boot Provisioning with nuageinit

How a cloud image configures itself with nobody at the keyboard: a config disk, a first-boot flag, a new user, an SSH key, a package, a bigger disk. Tested by booting a FreeBSD machine as a fresh cloud instance

Video: FreeBSD in the Cloud (FreeBSD video)
Prefer to watch? This short video runs this page's main commands on a real FreeBSD 15.0 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.

What makes a "cloud image" different

When you rent a virtual machine in a cloud, you do not install an operating system. You pick an image: one generic disk, the same for everybody, copied onto a new virtual disk and booted. There is no one at a keyboard, so the image has to configure itself at its first boot:

The cloud supplies these answers as metadata and user data, either on a small config disk attached to the machine or through a metadata service on a special address. On Linux the program that reads them is cloud-init. FreeBSD has its own small equivalent in the base system, nuageinit ("nuage" is French for cloud), plus a script that grows the disk, plus a first boot mechanism that makes sure all this runs once and only once.

This page shows all of it working. We turned the lab machine into a fresh "cloud instance": built a config disk, attached it, made the disk bigger, flagged the machine as a first boot, and rebooted. Everything below was run for real on FreeBSD 15.0-RELEASE in a virtual machine. (We did not download an official cloud image: those are several hundred megabytes. Everything the image's first boot does is in the base system, and that is what we used.)

The three ingredients

1. The first-boot flag. Many startup scripts in /etc/rc.d are marked as firstboot scripts. They only run if a sentinel file, /firstboot, exists, and the first boot deletes it. Cloud images ship with that file in place (from the documentation: the check is in /etc/rc), so these scripts run once, on the very first start, and never again.

2. The scripts. nuageinit is three startup scripts and a program (written in Lua, in /usr/libexec/nuageinit):

3. The configuration source. How does nuageinit find the data?

root@practice:~ # cat /etc/rc.d/nuageinit
[... the first part of the script is left out ...]
	# detect cloud init provider
	# according to the specification, the config drive
	# is either formatted in vfat or iso9660 and labeled
	# config-2
	for f in iso9660 msdosfs; do
		drive="/dev/$f/[cC][oO][nN][fF][iI][gG]-2"
		if [ -e $drive ]; then
			citype=config-2
			break
		fi
		drive="/dev/$f/[cC][iI][dD][aA][tT][aA]"
		if [ -e $drive ]; then
			citype=nocloud
			break
		fi
		unset drive
[... the rest of the script is left out ...]

It looks for a disk, formatted as either iso9660 (a CD image) or msdosfs (FAT), whose label is either config-2 (the OpenStack format) or cidata (the "NoCloud" format used by many tools). If it finds one it mounts it and reads it. If not, and the machine says it is an OpenStack instance, it asks the OpenStack metadata service over the network (from the documentation, not run here). Other environments need other tools (see the end of the page). We used the cidata disk, which any virtual machine can use.

The machine before

root@practice:~ # hostname
practice
root@practice:~ # id ops
id: ops: no such user
root@practice:~ # zpool list -H -o name,size,free zroot
zroot	13.5G	6.02G
root@practice:~ # gpart show vtbd0
=>      40  33554352  vtbd0  GPT  (16G)
        40      1024      1  freebsd-boot  (512K)
      1064       984         - free -  (492K)
      2048   4194304      2  freebsd-swap  (2.0G)
   4196352  29356032      3  freebsd-zfs  (14G)
  33552384      2008         - free -  (1.0M)
root@practice:~ # ls -l /firstboot
ls: /firstboot: No such file or directory
root@practice:~ # sysrc nuageinit_enable growfs_enable
nuageinit_enable: NO
growfs_enable: NO

An ordinary FreeBSD machine called practice: there is no user ops, the pool is 13.5 GB on a 16 GB disk, there is no /firstboot, and the cloud scripts are switched off (nuageinit_enable: NO, growfs_enable: NO).

The config disk

A cloud provider builds this disk for each instance. We built ours by hand: two small text files in a folder.

root@practice:~ # mkdir -p /root/cidata
root@practice:~ # cat > /root/cidata/meta-data <<'EOF'
> instance-id: i-techcommune-001
> local-hostname: cloudbox
> EOF
root@practice:~ # cat > /root/cidata/user-data <<'EOF'
> #cloud-config
> timezone: Europe/Amsterdam
> users:
>   - name: ops
>     gecos: Cloud Operator
>     groups: [wheel]
>     shell: /bin/sh
>     ssh_authorized_keys:
>       - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPmybE7OVDCkI2KsOeChHYQB2ys9jE6s16FqDObSrwv6 ops@lab
> ssh_pwauth: false
> write_files:
>   - path: /root/provisioned-by.txt
>     content: |
>       provisioned by nuageinit on first boot
> packages:
>   - tree
> runcmd:
>   - 'echo "runcmd ran at $(date)" > /var/tmp/runcmd.txt'
> EOF
root@practice:~ # ls -l /root/cidata
total 1
-rw-r--r--  1 root wheel  56 Oct  6 17:33 meta-data
-rw-r--r--  1 root wheel 459 Oct  6 17:33 user-data
root@practice:~ # makefs -t cd9660 -o rockridge,label=cidata /root/cidata.iso /root/cidata
root@practice:~ # ls -l /root/cidata.iso
-rw-r--r--  1 root wheel 356352 Oct  6 17:33 /root/cidata.iso

What we asked for in the user-data:

And makefs -t cd9660 -o rockridge,label=cidata turned the folder into a 356 KB CD image with the label cidata. makefs is a base-system tool that builds a file system image from a folder, with no mounting needed.

The manual page lists everything nuageinit understands: fqdn, chpasswd, more users options (passwd, locked, sudo, doas), ssh_keys, network settings (DHCP or fixed addresses) and so on.

Setting the stage

root@practice:~ # cp /etc/ssh/sshd_config.before /etc/ssh/sshd_config
root@practice:~ # sshd -t && service sshd reload
Performing sanity check on sshd configuration.
root@practice:~ # sysrc nuageinit_enable=YES growfs_enable=YES
nuageinit_enable: NO -> YES
growfs_enable: NO -> YES
root@practice:~ # touch /firstboot
root@practice:~ # ls -l /firstboot
-rw-r--r--  1 root wheel 0 Oct  6 17:33 /firstboot

Then we powered the machine off and made two changes from outside, the way a cloud does when you pick a bigger instance: we added 4 GB to its disk (qemu-img resize, 16 GB to 20 GB), and attached the config disk as a CD drive. And we booted.

First boot

The virtual serial console records what the machine said while it started:

(from the virtual serial console, first boot)
Setting hostid: 0xb3fcf993.
Starting file system checks:
Growing root partition to fill device
vtbd0 recovered
vtbd0p3 resized
Mounting local filesystems:.
[... more boot messages ...]
Setting hostname: cloudbox.
[... services start ...]
FreeBSD/amd64 (cloudbox) (ttyu0)
login:

And once it was up, the results:

root@cloudbox:~ # uptime
 7:36PM  up 2 mins, 1 user, load averages: 0.07, 0.05, 0.01
root@cloudbox:~ # hostname
cloudbox
root@cloudbox:~ # ls -l /firstboot
ls: /firstboot: No such file or directory
root@cloudbox:~ # cat /var/log/nuageinit.log
Bootstrapping pkg
Installing pkg-2.8.4...
Extracting pkg-2.8.4: .......... done
Bootstrapping pkg from pkg+https://pkg.FreeBSD.org/FreeBSD:15:amd64/quarterly, please wait...
Verifying signature with trusted certificate pkg.freebsd.org.2013102301... done
Updating FreeBSD-ports repository catalogue...
Fetching meta.conf: . done
Fetching data: .......... done
Processing entries:
Processing entries............. done
FreeBSD-ports repository update completed. 38288 packages processed.
Updating FreeBSD-ports-kmods repository catalogue...
Fetching meta.conf: . done
Fetching data: ..... done
Processing entries: .......... done
FreeBSD-ports-kmods repository update completed. 240 packages processed.
All repositories are up to date.
Updating database digests format: . done
The following 1 package(s) will be affected (of 0 checked):

New packages to be INSTALLED:
	tree: 2.3.2 [FreeBSD-ports]

Number of packages to be installed: 1

62 KiB to be downloaded.
[1/1] Fetching tree-2.3.2: ........ done
Checking integrity... done (0 conflicting)
[1/1] Installing tree-2.3.2...
[1/1] Extracting tree-2.3.2: ........ done
Executing 'runcmd'
root@cloudbox:~ # id ops
uid=1002(ops) gid=1002(ops) groups=1002(ops),0(wheel)
root@cloudbox:~ # cat /home/ops/.ssh/authorized_keys | cut -c1-60
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPmybE7OVDCkI2KsOeChHYQB
root@cloudbox:~ # cat /root/provisioned-by.txt
provisioned by nuageinit on first boot
root@cloudbox:~ # cat /var/tmp/runcmd.txt
runcmd ran at Tue Oct  6 19:34:29 CEST 2026
root@cloudbox:~ # date +%Z; ls -l /etc/localtime | cut -c1-90
CEST
lrwxr-xr-x  1 root wheel 36 Oct  6 19:34 /etc/localtime -> /usr/share/zoneinfo/Europe/Amst
root@cloudbox:~ # grep -n "^PasswordAuthentication" /etc/ssh/sshd_config
125:PasswordAuthentication no
root@cloudbox:~ # pkg info tree | head -1
tree-2.3.2

Everything we asked for happened:

root@cloudbox:~ # gpart show vtbd0
=>      40  41942960  vtbd0  GPT  (20G)
        40      1024      1  freebsd-boot  (512K)
      1064       984         - free -  (492K)
      2048   4194304      2  freebsd-swap  (2.0G)
   4196352  37746648      3  freebsd-zfs  (18G)
root@cloudbox:~ # zpool list -H -o name,size,free zroot
zroot	17.5G	10.0G
root@cloudbox:~ # grep -m3 -i "growfs\|cd0:" /var/run/dmesg.boot | cut -c1-120
cd0: <QEMU QEMU DVD-ROM 2.5+> Removable CD-ROM SCSI device
cd0: Serial Number QM00005
cd0: 150.000MB/s transfers (SATA 1.x, UDMA5, ATAPI 12bytes, PIO 8192bytes)

The disk really grew: the partition table shows a 20 GB disk with the ZFS partition at 18 GB (it was 14 GB), and the pool zroot is 17.5 GB (it was 13.5 GB). growfs handles ZFS as well as UFS (its script has a branch for each; seen in the script). No manual gpart or zpool commands were typed.

And from outside, with the key we generated for ops:

laptop:~ $ ssh -i /home/liloland/freebsd-lab/id_ops -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p 12223 ops@127.0.0.1 "id; hostname; tree --version | head -1"
uid=1002(ops) gid=1002(ops) groups=0(wheel),1002(ops)
cloudbox
tree v2.3.2 © 1996 - 2026 by Steve Baker, Thomas Moore, Francesc Rocher, Florian Sesser, Kyosuke Tokoro
laptop:~ $ ssh -o BatchMode=yes -o PubkeyAuthentication=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p 12223 ops@127.0.0.1 true; echo "exit $?"
ops@127.0.0.1: Permission denied (publickey,keyboard-interactive).
exit 255

The new user logs in with its key: uid 1002 ... cloudbox, and the program tree that the first boot installed is there. A password attempt is refused, but notice Permission denied (publickey,keyboard-interactive): ssh_pwauth: false turned off PasswordAuthentication, but keyboard-interactive, the other route to a password prompt, is still offered. That is the same detail as on the hardening page: if you need to rule out passwords entirely, switch off KbdInteractiveAuthentication as well (for example with a write_files entry or runcmd: from the documentation, not run here).

What nuageinit changed

root@cloudbox:~ # sysrc hostname
hostname: practice
root@cloudbox:~ # grep -c "" /var/log/nuageinit.log
31
root@cloudbox:~ # ls -l /var/log/nuageinit.log
-rw-r--r--  1 root wheel 1137 Oct 25 02:55 /var/log/nuageinit.log
root@cloudbox:~ # pw usershow ops | cut -d: -f1,3,4,7,8
ops:1002:1002:0:Cloud Operator
root@cloudbox:~ # pw groupshow wheel
wheel:*:0:root,bsd,ops
root@cloudbox:~ # ls /media
root@cloudbox:~ # ls /etc/rc.conf.d
hostname
root@cloudbox:~ # cat /etc/rc.conf.d/hostname
hostname="cloudbox"
root@cloudbox:~ # grep -n "^hostname" /etc/rc.conf
2:hostname="practice"
root@cloudbox:~ # sysrc -a | grep -i "^hostname"
hostname: practice
root@cloudbox:~ # grep -rn "nuage" /etc/rc.conf /etc/rc.conf.d 2>/dev/null | head -3
/etc/rc.conf:20:nuageinit_enable="YES"

A second boot changes nothing

root@cloudbox:~ # uptime
 7:37PM  up 18 secs, 1 user, load averages: 0.28, 0.06, 0.02
root@cloudbox:~ # hostname
cloudbox
root@cloudbox:~ # ls -l /firstboot
ls: /firstboot: No such file or directory
root@cloudbox:~ # grep -c "" /var/log/nuageinit.log
31
root@cloudbox:~ # id ops
uid=1002(ops) gid=1002(ops) groups=1002(ops),0(wheel)
root@cloudbox:~ # zpool list -H -o name,size zroot
zroot	17.5G

We rebooted. Everything stayed: the host name, the user, the pool size (17.5 GB, not grown again). /firstboot stayed absent, the log has the same 31 lines, and nothing ran twice. A cloud instance is configured once, and after that it is an ordinary FreeBSD machine that you manage in the usual way. (The config disk was still attached, and was ignored.)

What a real cloud adds

From the documentation, not run here:

Cloud habits that pay off

⚠️ Check that the first boot's settings do not lock you out. A hardening setting such as AllowUsers, a firewall that blocks SSH, or a wrong key in user-data leaves a cloud machine you cannot log in to. Test the exact user-data on a local virtual machine, keep the cloud's serial console or recovery option in mind, and never lose the only copy of the private key.

Why did the cloud instance have no memory of its childhood?<br>
Its first boot ran once<br>
and then deleted the file that said so.

🧪 Try it yourself

On a FreeBSD virtual machine (it must be one you can attach a disk to):

1. Read man nuageinit and /etc/rc.d/nuageinit. What labels does it look for? 2. Make a folder with meta-data (an instance-id and a local-hostname) and a user-data that starts with #cloud-config. Add a user with your public key, ssh_pwauth: false, a file and a runcmd. 3. Build a cidata disk with makefs -t cd9660 -o rockridge,label=cidata. 4. Switch on nuageinit_enable and growfs_enable, create /firstboot, and power off. 5. Make the virtual disk bigger, attach the config disk and boot. 6. Check the host name, the user, the file, the runcmd output and the pool size. Log in with the key. 7. Reboot. What does not happen the second time? 8. Where did the host name end up? (/etc/rc.conf.d/)

The cheat sheet

← Containers on FreeBSD: Podman, OCI Images and Jails · ↑ FreeBSD Corner · Monitoring FreeBSD: From top to Prometheus →