Andrew Mercer
on this page

Automating FreeBSD Installations: A Comprehensive Guide

This guide covers every practical layer of FreeBSD install automation, from a single unattended ISO boot to fleet-scale PXE/ZFS provisioning and post-install configuration management. It assumes comfort with the command line and basic FreeBSD administration.


1. Understanding the FreeBSD Installer Stack

Before automating anything, it helps to know what's actually running under the hood.

  • bsdinstall — the framework, a collection of shell scripts under /usr/libexec/bsdinstall/, orchestrated by a main script that drives the menu-based (dialog-based) TUI.
  • bsdconfig — a related but distinct post-install configuration tool; shares some libexec scripts with bsdinstall.
  • Stages: bsdinstall runs a fixed pipeline of stages — keymap, hostname, netconfig, distsite, partition, zfsboot/ufs, distextract, rootpass, services, config, etc.
  • Automation hook: bsdinstall supports a scripted mode via an installerconfig file. When present on removable media, or specified via BSDINSTALL_CONFIGFILE, bsdinstall runs entirely non-interactively, executing the file as a shell script with certain environment variables pre-populated.

This scripted mode is the foundation for almost every automation technique below — PXE installs, cloud images, and custom ISOs all ultimately feed bsdinstall (or bypass it entirely in favor of raw scripting).


2. The installerconfig Approach (Unattended ISO/USB Install)

2.1 How it's triggered

On boot, the FreeBSD installer checks all mounted filesystems for a file named installerconfig. If found, it skips the interactive menus and executes that file as a script, using the environment bsdinstall sets up (e.g., BSDINSTALL_DISTDIR, BSDINSTALL_CHROOT).

Two common delivery methods:

  1. Custom ISO — bake installerconfig into a modified install image.
  2. Secondary USB/CD — leave the official ISO untouched, and put installerconfig on a second piece of removable media (labeled so it mounts predictably). The stock installer will detect and use it.

2.2 Anatomy of an installerconfig

#!/bin/sh
# installerconfig — fully scripted FreeBSD install

# --- Partitioning (example: single-disk, ZFS on root) ---
export ZFSBOOT_DISKS="ada0"
export ZFSBOOT_POOL_NAME="zroot"
export ZFSBOOT_GPT_SCHEME="GPT"
export ZFSBOOT_VDEV_TYPE="stripe"
export ZFSBOOT_SWAP_SIZE="4g"
export ZFSBOOT_BEROOT_NAME="ROOT"
export ZFSBOOT_BOOTFS_NAME="default"
export nonInteractive="YES"

# Partition + create pool + boot environment non-interactively
bsdinstall zfsboot

# Extract the base distribution
export BSDINSTALL_DISTDIR="/usr/freebsd-dist"
export DISTRIBUTIONS="base.txz kernel.txz"
bsdinstall distextract

# --- Post-extraction chroot configuration ---
bsdinstall config

cat <<EOF >> /mnt/etc/rc.conf
hostname="node01.example.com"
ifconfig_DEFAULT="DHCP"
sshd_enable="YES"
ntpd_enable="YES"
growfs_enable="YES"
EOF

pw -V /mnt/etc usermod root -h 0 <<EOF
S3cur3RootPassw0rd!
EOF

pw -V /mnt/etc useradd admin -m -G wheel -s /bin/sh -h 0 <<EOF
AdminPassw0rd!
EOF

echo 'PermitRootLogin no' >> /mnt/etc/ssh/sshd_config

mkdir -p /mnt/root/.ssh
cat <<EOF > /mnt/root/.ssh/authorized_keys
ssh-ed25519 AAAA...yourkeyhere... admin@bastion
EOF
chmod 700 /mnt/root/.ssh
chmod 600 /mnt/root/.ssh/authorized_keys

# --- First-boot provisioning hook (optional) ---
mkdir -p /mnt/usr/local/etc/rc.d.firstboot
cat <<'EOF' > /mnt/etc/rc.local
#!/bin/sh
fetch -o /tmp/bootstrap.sh https://provisioning.example.com/bootstrap.sh
sh /tmp/bootstrap.sh
EOF
chmod +x /mnt/etc/rc.local

bsdinstall entropy
bsdinstall config

echo "Automated install complete." > /mnt/firstboot.log

Key points:

  • bsdinstall zfsboot and bsdinstall distextract are the same subroutines the interactive menu calls — you're just invoking them directly with the environment variables pre-set instead of letting the TUI prompt for them.
  • Everything after distextract operates against /mnt, which is where bsdinstall mounts the target filesystem (value is actually in $BSDINSTALL_CHROOT, conventionally /mnt).
  • pw -V /mnt/etc lets you manipulate the target system's passwd/master.passwd from within the running installer environment, without chrooting.
  • For UFS instead of ZFS, use bsdinstall autopartition (or manually drive bsdinstall partedit) with PARTITIONS/export UFS style variables — see /usr/libexec/bsdinstall/partition if you need the exact variable names for your version.

2.3 Baking a custom ISO with installerconfig embedded

# On a FreeBSD build host
fetch https://download.freebsd.org/releases/amd64/14.1-RELEASE/FreeBSD-14.1-RELEASE-amd64-disc1.iso

mkdir -p /tmp/isoroot
tar -C /tmp/isoroot -xf FreeBSD-14.1-RELEASE-amd64-disc1.iso   # or mount via mdconfig + mount_cd9660

cp installerconfig /tmp/isoroot/

# Rebuild the ISO, preserving the boot catalog
mkisofs -b boot/cdboot -no-emul-boot -r -J \
  -V "FreeBSD_Install" \
  -o FreeBSD-14.1-custom.iso /tmp/isoroot

mkisofs/xorriso flags must match the original image's boot setup (El Torito catalog for BIOS, plus an EFI system partition image for UEFI — bsdinstall's own release-build scripts under /usr/src/release/ are the authoritative reference if you need both boot paths).


3. Network (PXE) Automated Installs

PXE is the right tool when you're provisioning many physical or virtual machines and don't want to manage physical media at all.

3.1 Components needed

  1. DHCP server pointing PXE clients at a TFTP boot loader (pxeboot or the UEFI equivalent).
  2. TFTP server serving FreeBSD's boot loader plus a boot config.
  3. NFS or HTTP root serving the install media contents (kernel, mfsroot, distribution sets).
  4. An installerconfig served the same way as in section 2, referenced via BSDINSTALL_CONFIGFILE or fetched at runtime.

3.2 DHCP (ISC dhcpd example)

subnet 10.0.10.0 netmask 255.255.255.0 {
  range 10.0.10.100 10.0.10.200;
  next-server 10.0.10.5;         # TFTP server
  if substring(option vendor-class-identifier, 0, 9) = "PXEClient" {
    filename "pxeboot";
  }
}

For UEFI clients you'll need class-based conditionals to serve loader.efi instead of the BIOS pxeboot.

3.3 Serving the boot loader over TFTP + NFS root

# On the TFTP root
cp /usr/obj/.../boot/pxeboot /tftpboot/
mkdir -p /tftpboot/boot
cp -r /usr/freebsd-dist/... /tftpboot/boot   # kernel + loader config

/tftpboot/boot/loader.conf.local (or the equivalent loader config) sets:

vfs.root.mountfrom="nfs:10.0.10.5:/export/freebsd-install"

The NFS export at 10.0.10.5:/export/freebsd-install should contain an mfsroot-style filesystem: essentially the installer environment (same content as the ISO), including your installerconfig at the top level so bsdinstall picks it up automatically on boot, exactly as with removable media.

3.4 Simpler alternative: mfsBSD

mfsBSD builds a small memory-filesystem-based FreeBSD image designed for exactly this kind of network boot / rescue / install scenario, and is widely used because it sidesteps a lot of the NFS-root plumbing above. A typical flow:

git clone https://github.com/mmatuska/mfsbsd
cd mfsbsd
make BASE=/path/to/14.1-RELEASE iso        # produces mfsbsd-se-14.1...iso

# boot the resulting image (PXE or ISO), then from a login shell:
bsdinstall script /path/to/installerconfig

mfsBSD-based flows are popular for scripted installs to disk over PXE precisely because they give you a full shell before/instead of the bsdinstall menu, so you can also skip bsdinstall entirely and do raw zpool create / newfs / tar extraction (see section 5) if you want more control than the installerconfig contract gives you.

3.5 iPXE chainloading (common in modern labs)

If your environment already uses iPXE (common with Packer, MAAS-like setups, or homelab PXE stacks), you can chainload straight to the FreeBSD loader over HTTP instead of TFTP+NFS, which is faster and avoids NFS entirely:

#!ipxe
kernel http://10.0.10.5/freebsd/boot/kernel/kernel
initrd http://10.0.10.5/freebsd/boot/kernel/kernel.ko
boot

(Exact syntax depends on version; FreeBSD's loader can also be chainloaded via sanboot/chain to a loader.efi served over HTTP for UEFI hosts, avoiding legacy TFTP block-size problems with large kernels.)


4. ZFS-on-Root Layout Automation

Since ZFS is the default and generally preferred root filesystem, it's worth detailing the variables bsdinstall zfsboot actually consumes, since these are what you'll template per-host in installerconfig:

Variable Purpose
ZFSBOOT_DISKS Space-separated disk device list, e.g. "da0 da1"
ZFSBOOT_VDEV_TYPE stripe, mirror, raidz1, raidz2, raidz3
ZFSBOOT_POOL_NAME Defaults to zroot
ZFSBOOT_GPT_SCHEME Almost always GPT
ZFSBOOT_SWAP_SIZE e.g. 4g
ZFSBOOT_SWAP_MIRROR YES to mirror swap across all disks
ZFSBOOT_BEROOT_NAME Boot environment root dataset, default ROOT
ZFSBOOT_BOOTFS_NAME Active boot environment name, default default
ZFSBOOT_DATASETS Multi-line var defining extra datasets, e.g. /tmp, /var/log, /usr/home with custom properties

Example multi-disk mirrored root with custom datasets:

export ZFSBOOT_DISKS="da0 da1"
export ZFSBOOT_VDEV_TYPE="mirror"
export ZFSBOOT_POOL_NAME="zroot"
export ZFSBOOT_SWAP_SIZE="8g"
export ZFSBOOT_SWAP_MIRROR="YES"
export ZFSBOOT_DATASETS="
zroot/var/log       mountpoint=/var/log
zroot/var/audit     mountpoint=/var/audit
zroot/var/mail      mountpoint=/var/mail
zroot/usr/home      mountpoint=/usr/home
zroot/tmp       mountpoint=/tmp,exec=on,setuid=off
"
bsdinstall zfsboot

This gives you boot-environment support (via bectl) out of the box, which pairs well with automated patch/upgrade pipelines — snapshot before an upgrade run, roll back automatically if a post-install health check fails.


5. Fully Manual (Non-bsdinstall) Scripted Installs

For maximum control — common in image-building pipelines (Packer, cloud image builds) — many teams bypass bsdinstall entirely and script the raw steps. This is essentially what bsdinstall's own libexec scripts do internally, laid bare:

DISK=/dev/vtbd0
ZPOOL=zroot

gpart create -s gpt ${DISK}
gpart add -t freebsd-boot -s 512k -l bootfs0 ${DISK}
gpart add -t freebsd-zfs  -l zfs0 ${DISK}
gpart bootcode -b /boot/pmbr -p /boot/gptzfsboot -i 1 ${DISK}

zpool create -o altroot=/mnt -O compress=lz4 -O atime=off \
  -m none ${ZPOOL} ${DISK}p2

zfs create -o mountpoint=none   ${ZPOOL}/ROOT
zfs create -o mountpoint=/      ${ZPOOL}/ROOT/default
zfs create -o mountpoint=/var   ${ZPOOL}/var
zfs create -o mountpoint=/usr   ${ZPOOL}/usr
zfs create -o mountpoint=/tmp -o exec=on -o setuid=off ${ZPOOL}/tmp

zpool set bootfs=${ZPOOL}/ROOT/default ${ZPOOL}

# Extract base system
tar -C /mnt -xpJf base.txz
tar -C /mnt -xpJf kernel.txz

# Basic config
sysrc -R /mnt hostname="node01"
sysrc -R /mnt ifconfig_DEFAULT="DHCP"
sysrc -R /mnt sshd_enable="YES"
sysrc -R /mnt zfs_enable="YES"

echo 'zroot/ROOT/default / zfs rw,noatime 1 1' > /mnt/etc/fstab   # optional; ZFS mountpoints often make fstab unnecessary

# Root password (non-interactive, chroot)
chroot /mnt sh -c "echo 'S3cur3RootPassw0rd!' | pw usermod root -h 0"

# Timezone / locale, if needed, non-interactively
chroot /mnt sh -c "tzsetup -sC / America/Toronto"

zfs umount -a
zpool export ${ZPOOL}

This is exactly the pattern Packer's qemu or vmware-iso builder uses when building FreeBSD base images: boot from ISO, run this script via shell or shell-local provisioner over serial console, then shut down and convert the resulting disk image.


6. Packer for FreeBSD Image Building

Packer is the most common tool for producing repeatable, versioned FreeBSD images (qcow2, VMDK, AMI, GCE image).

Minimal qemu example (freebsd.pkr.hcl):

source "qemu" "freebsd" {
  iso_url          = "https://download.freebsd.org/releases/amd64/14.1-RELEASE/FreeBSD-14.1-RELEASE-amd64-disc1.iso"
  iso_checksum     = "file:https://download.freebsd.org/releases/amd64/14.1-RELEASE/CHECKSUM.SHA256"
  disk_size        = "20G"
  format           = "qcow2"
  accelerator      = "kvm"
  headless         = true
  ssh_username     = "root"
  ssh_password     = "packer"
  ssh_timeout      = "30m"
  shutdown_command = "shutdown -p now"

  boot_command = [
    "<esc><wait>",
    "boot -v<enter>"
  ]

  http_directory = "http"     # serves installerconfig via the QEMU built-in HTTP server
  boot_wait      = "5s"
}

build {
  sources = ["source.qemu.freebsd"]

  provisioner "shell" {
    inline = [
      "pkg update -f",
      "pkg install -y sudo curl bash",
      "echo 'PermitRootLogin without-password' >> /etc/ssh/sshd_config"
    ]
  }
}

The trick with qemu/BIOS-boot FreeBSD + Packer is getting installerconfig to the installer at all: since there's no cloud-init-style metadata service at install time, common approaches are:

  1. Serve installerconfig via Packer's built-in http_directory and have a tiny custom boot script fetch it during early boot (some pipelines patch the ISO to auto-fetch from {{ .HTTPIP }}:{{ .HTTPPort }}/installerconfig as the first line of a wrapper).
  2. Pre-bake installerconfig into a custom ISO (section 2.3) so no network fetch is needed at all — simpler and more reliable for CI.

Most production FreeBSD Packer templates use option 2 for reliability.


7. Cloud-Init on FreeBSD

FreeBSD has decent cloud-init support (packaged as py-cloud-init / cloud-init in ports/pkg), which matters for AWS, GCE, Azure, OpenStack, and any cloud-init-compatible private cloud (e.g., Proxmox with cloud-init disk support).

For cloud images (AWS AMIs, GCE images, the official FreeBSD cloud images), the "install automation" problem is really "image automation" (section 6) plus cloud-init for first-boot personalization, not per-machine OS installation. Typical setup baked into the image:

pkg install -y cloud-init py39-cloud-init      # exact package name varies by version
sysrc cloudinit_enable="YES"
sysrc growfs_enable="YES"    # auto-grow root fs to match the cloud disk size

/usr/local/etc/cloud/cloud.cfg — set the datasource list to match your provider:

datasource_list: [Ec2, GCE, Azure, OpenStack, None]

A user-supplied #cloud-config at instance launch then handles the classic per-instance concerns without touching the base image:

#cloud-config
hostname: node01
users:
  - name: admin
    groups: wheel
    shell: /bin/sh
    ssh_authorized_keys:
      - ssh-ed25519 AAAA...
packages:
  - vim
  - git
runcmd:
  - [ "pkg", "install", "-y", "some-app" ]
  - [ "service", "some-app", "start" ]

This is the standard way to get "zero-touch" behavior for cloud VMs: bsdinstall/Packer builds the golden image once, cloud-init personalizes every instance launched from it.


8. Jail Provisioning Automation

Automating the installation problem often extends to automating jails on top of a base FreeBSD host. Three common tool tiers:

8.1 Raw iocage

pkg install -y py39-iocage
iocage fetch                       # fetches base release once, cached
iocage create -n webjail -r 14.1-RELEASE ip4_addr="lo1|10.0.0.10/24"
iocage exec webjail pkg install -y nginx
iocage set boot=on webjail
iocage start webjail

Wrap this in a shell/Ansible loop over a manifest of jail names/IPs for repeatable fleets of jails.

8.2 bastille (declarative-ish, Ansible-friendly)

pkg install -y bastille
bastille bootstrap 14.1-RELEASE
bastille create webjail 14.1-RELEASE 10.0.0.10
bastille pkg webjail install nginx
bastille start webjail

Bastille supports Bastillefile templates — a declarative recipe format very similar in spirit to a Dockerfile — which makes it the closest thing to reproducible "jail images":

# Bastillefile for webjail
PKG nginx
CP files/nginx.conf /usr/local/etc/nginx/nginx.conf
SERVICE nginx enable
SERVICE nginx start
bastille template webjail myuser/nginx-template

8.3 ezjail (older, still common in legacy environments)

pkg install -y ezjail
ezjail-admin install                       # fetch base release
ezjail-admin create webjail 'lo1|10.0.0.10/24'
ezjail-admin start webjail

For most new automation work, Bastille or iocage are the better choices — ezjail is stable but has seen less active development.


9. Post-Install Configuration Management

Regardless of how the base OS got there (bsdinstall, PXE, Packer image, cloud-init), you'll typically hand off to a CM tool for ongoing state management.

9.1 Ansible

FreeBSD is a first-class target for Ansible; the main gotchas are Python location and package module naming:

# inventory
[freebsd]
node01 ansible_host=10.0.10.11

[freebsd:vars]
ansible_python_interpreter=/usr/local/bin/python3.11
ansible_shell_type=sh
# playbook.yml
- hosts: freebsd
  become: true
  tasks:
    - name: install packages
      pkgng:
        name: [nginx, vim, git]
        state: present

    - name: enable service
      service:
        name: nginx
        enabled: true
        state: started

    - name: manage rc.conf keys
      sysrc:
        name: sshd_enable
        value: "YES"

Ansible needs Python present on the target before it can bootstrap fully — either bake python3 into your golden image (Packer/installerconfig pkg install), or use a raw task to install it first:

- hosts: freebsd
  gather_facts: false
  tasks:
    - name: bootstrap python
      raw: pkg install -y python3

9.2 Salt

FreeBSD is supported as both master and minion; pkg install -y py39-salt and standard salt-minion service management via sysrc salt_minion_enable=YES — states use the pkgng module analogous to Ansible's.

9.3 Puppet / Chef

Both have mature FreeBSD support via pkg install -y puppet7 / the Chef Omnibus-adjacent packages; the FreeBSD package resource in Puppet maps onto pkg, and service maps onto rc.d/sysrc-managed rc.conf entries — the main adaptation from Linux manifests is remembering FreeBSD's rc.conf-driven service enablement model rather than systemd unit files.

9.4 A minimal roll-your-own bootstrap (no CM tool)

For smaller fleets, a single bootstrap.sh fetched via rc.local (as shown in section 2.2) is often enough:

#!/bin/sh
set -eu
pkg update -f
pkg install -y $(cat /usr/local/etc/pkg-manifest.txt)
fetch -o /usr/local/etc/nginx/nginx.conf https://config.example.com/nginx.conf
sysrc nginx_enable=YES
service nginx start
touch /var/db/bootstrap.done

Guard it so it only runs once:

[ -f /var/db/bootstrap.done ] && exit 0

10. Building and Maintaining Custom Packages: poudriere

If your automated installs pull from a custom package repo (common when you need reproducible, pinned package builds rather than the public pkg.freebsd.org), poudriere is the standard tool, and it's itself highly automatable:

pkg install -y poudriere

poudriere jail -c -j 141amd64 -v 14.1-RELEASE -a amd64
poudriere ports -c -p default

echo "www/nginx" > /usr/local/etc/poudriere.d/mylist
echo "editors/vim" >> /usr/local/etc/poudriere.d/mylist

poudriere bulk -j 141amd64 -p default -f /usr/local/etc/poudriere.d/mylist

Serve /usr/local/poudriere/data/packages/141amd64-default/ over HTTP/NFS, and point pkg.conf/repos.conf on target hosts at it:

# /usr/local/etc/pkg/repos.conf.d/local.conf
local: {
  url: "http://pkgrepo.example.com/141amd64-default",
  enabled: yes
}

Wire poudriere bulk into cron or CI so package builds stay current, and your installerconfig/Packer/CM pipelines all pull from the same reproducible repo.


11. Validation and Testing the Automation Itself

A few practices worth building in from day one:

  • Serial console logging: for PXE/Packer flows, redirect the installer's console to serial (-nographic/console=comconsole in loader.conf) so failures during unattended boot are captured to a log file instead of vanishing on a headless VNC session.
  • installerconfig self-check: end the script with a canary — write a marker file and a checksum of the intended rc.conf/dataset layout — so first-boot provisioning can assert the install actually completed the way you expect before proceeding.
  • Golden-image diffing: for Packer-built images, run freebsd-version, pkg info, and sysrc -a at the end of the build and diff against a known-good baseline as an automated Packer provisioner "shell" post-check, failing the build on drift.
  • bectl safety net: since ZFS boot environments are essentially free, snapshot (bectl create pre-update) before any automated in-place upgrade step, and script a health-check + auto-rollback (bectl activate pre-update && reboot) if services fail to come up post-upgrade.

12. Choosing an Approach — Quick Reference

Scenario Recommended approach
One-off unattended install from USB/ISO installerconfig on a second USB stick
Small number of custom images for repeated re-imaging Custom ISO with embedded installerconfig
Fleet of bare-metal/VM installs on a LAN PXE + mfsBSD or NFS-root + installerconfig
Reproducible VM images for cloud/hypervisor Packer (qemu/vmware-iso) + installerconfig-baked ISO
Cloud instances (AWS/GCE/Azure) Golden image via Packer + cloud-init for per-instance config
Many lightweight isolated services on one host Bastille or iocage jails, scripted/templated
Ongoing configuration drift management Ansible/Salt/Puppet layered on top of any of the above
Custom/pinned package builds poudriere + a local pkg repo

13. Key Reference Files and Commands

  • /usr/libexec/bsdinstall/ — the actual bsdinstall stage scripts; the ground truth for every environment variable mentioned above.
  • bsdinstall script <path> — run scripted mode explicitly against a given config file post-boot from a live installer shell.
  • sysrc -R /mnt <key>=<value> — the correct way to manage a target system's rc.conf from outside a chroot.
  • pw -V /mnt/etc ... — manage users/passwords in a target root without chrooting.
  • bectl — boot environment management (list/create/activate), essential for safe automated upgrades.
  • zpool set bootfs=... — required whenever you build a ZFS root manually outside bsdinstall.

This should cover the full spectrum from "unattend a single ISO install" through "build and maintain a fleet with reproducible images, jails, and CM." If you want, I can turn any one of these sections (e.g., a working PXE+mfsBSD lab setup, or a Packer template tailored to your homelab's hypervisor) into a deeper, fully worked example.