☁️ 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
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:
- Who am I? A host name.
- Who may log in? A user and their SSH public key (never a password you typed during an install).
- How big is my disk? You may have chosen a bigger one than the image was built with.
- What should I install? Packages, files, a script.
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):
- nuageinit: runs early, before the network is configured. It finds the cloud's configuration data and handles the basics, such as the host name.
- nuageinit_post_net: runs after the network is up: installs packages and creates users (a user's shell can come from a package).
- nuageinit_user_data_script: runs at the end of the boot: your commands or script.
- growfs: a separate first-boot script that grows the root partition (and the file system on it) to fill the disk.
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
- meta-data is about the instance: its unique ID, and local-hostname: cloudbox, which nuageinit uses as the host name.
- user-data is the instructions, in a YAML format called cloud-config. It must start with #cloud-config. (If a user-data file starts with #! instead, nuageinit treats it as a script and runs it at the end of the boot: from the manual page.)
What we asked for in the user-data:
- timezone: set the time zone (Europe/Amsterdam).
- users: create the user ops, with a description, in the group wheel (so it can su to root), the shell /bin/sh, and an SSH public key in ssh_authorized_keys. (The key is the public half of a pair we generated for this test, and it is not secret by design.)
- ssh_pwauth: false: set PasswordAuthentication no in the SSH server's configuration.
- write_files: create a file, /root/provisioned-by.txt, with some text.
- packages: install tree (this needs the network, which is why it runs after it).
- runcmd: run a command at the end. Ours writes the time to a file.
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
- We restored the SSH server's original configuration first. In the hardening page we had set AllowUsers bsd, which would have blocked the new user: a good reminder that a hardened server needs its first-boot configuration to agree with its security settings.
- nuageinit_enable=YES and growfs_enable=YES switch the scripts on (nuageinit_enable covers all three of its scripts). A cloud image already has them.
- touch /firstboot creates the first-boot flag.
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:
- Growing root partition to fill device, vtbd0 recovered (the partition table was moved to the new end of the disk), and vtbd0p3 resized: growfs made the ZFS partition fill the larger disk.
- Setting hostname: cloudbox. The name came from the config disk. (The machine had been called practice.)
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:
- The host name is cloudbox, and /firstboot is gone: the flag was used up.
- /var/log/nuageinit.log records the whole first boot (the pkg bootstrap, the package install, and last the line Executing 'runcmd'), and our command wrote its file: runcmd ran at Tue Oct 6 19:34:29 CEST 2026.
- User ops exists (uid 1002, with groups ops and wheel), and its authorized_keys holds our public key.
- /root/provisioned-by.txt was written by write_files.
- The time zone is CEST (a link /etc/localtime to Europe/Amsterdam, which nuageinit made).
- sshd_config now says PasswordAuthentication no, and the package tree-2.3.2 is installed. (It was downloaded over the network, using the package setup of the lab machine.)
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
- sysrc hostname still says practice. nuageinit does not edit /etc/rc.conf. The next listing shows where the new name lives:
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"
- It wrote a drop-in file, /etc/rc.conf.d/hostname, with hostname="cloudbox". FreeBSD reads /etc/rc.conf.d/ after /etc/rc.conf, so the drop-in wins, and sysrc -a (which reads only rc.conf) does not show it. Good to know when you wonder where a setting comes from.
- The user ops is in wheel (wheel:*:0:root,bsd,ops). The log is tiny (19 bytes), and /media is empty again: the config disk was mounted, read, and unmounted.
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:
- Official images. The FreeBSD project builds ready-made images for several clouds (the amd64 and arm64 images for Amazon EC2, Google Compute Engine, Microsoft Azure, DigitalOcean and others, and generic "cloud-init" images for OpenStack and KVM hosts). They are the base system with /firstboot in place and the scripts enabled, and each one pulls its data from its own cloud's metadata mechanism.
- The metadata service. Most clouds hand over the data through a web address that only the instance can reach (famously 169.254.169.254). On EC2 the packages ec2-scripts (and the firstboot-* packages) do the fetching; nuageinit itself asks OpenStack's service. The package list also has the full cloud-init (py312-cloud-init) for places that need it.
- Patching at first boot. Small packages named firstboot-freebsd-update and firstboot-pkg-upgrade update the base system and the packages at the very first start, so an instance is patched before it takes traffic. And firstboot-pkgs installs a list of packages.
- Network settings. A network-config file (or the network key) can set DHCP or fixed addresses, DNS servers and routes.
- A default user. If you give it no users, nuageinit can create a default user named freebsd with the password freebsd (from the manual page). Never leave that in place on a machine on the internet: always supply your own user and key, and switch off password logins.
- Building your own image. Tools such as mkimg, the release build scripts, and poudriere's image command can build a disk image with your packages and settings baked in. Prepare it in a virtual machine (bhyve), enable the first-boot scripts, put /firstboot in place, and shut it down.
Cloud habits that pay off
- Treat instances as disposable. If everything is in the user-data and a repository of your own packages (the poudriere page), a broken server can be replaced by a new one in minutes.
- Never put secrets in user-data. It can usually be read from the instance and from the cloud's console. Put public keys in it, and fetch real secrets some other way.
- Take a boot environment before upgrades (upgrade page). On ZFS it is the cheapest undo button in any cloud.
- Harden it (hardening page), and use the cloud's own firewall (security groups) and the machine's firewall (pf or ipfw).
- Test your user-data on a local virtual machine first, as we did here. Debugging a first boot on a rented machine with no console is painful.
⚠️ 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.
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
- Scripts: /etc/rc.d/nuageinit, nuageinit_post_net, nuageinit_user_data_script, growfs. Switch on with sysrc nuageinit_enable=YES growfs_enable=YES. Run once because of /firstboot.
- Config disk: an iso9660 or msdosfs disk labeled cidata (NoCloud) or config-2 (OpenStack), holding meta-data and user-data. Build with makefs -t cd9660 -o rockridge,label=cidata OUT.iso DIR.
- user-data: starts with #cloud-config (YAML) or with #! (a script). Keys: users, ssh_authorized_keys, ssh_pwauth, packages, write_files, runcmd, timezone, fqdn, network, chpasswd.
- Results: log in /var/log/nuageinit.log; host name in /etc/rc.conf.d/hostname.
- Disk growth: automatic with growfs (UFS and ZFS) on first boot.
- Extras: firstboot-freebsd-update, firstboot-pkg-upgrade, firstboot-pkgs, ec2-scripts, py312-cloud-init.
- Do not: leave the default user freebsd/freebsd, put secrets in user-data, or skip testing the first boot locally.
← Containers on FreeBSD: Podman, OCI Images and Jails · ↑ FreeBSD Corner · Monitoring FreeBSD: From top to Prometheus →