- Sun 28 December 2025
- FreeBSD
- #freebsd, #proxmox, #cloud-init, #virtualization, #automation
FreeBSD 15.0-RELEASE shipped earlier this month with VM images that are “cloud-init compatible” with a catch. Instead of using the Python-based cloud-init that Linux distributions rely on, FreeBSD implemented their own solution: nuageinit(7). It’s lighter, written in Lua, and fits FreeBSD’s philosophy of minimal dependencies. The only problem? It doesn’t understand network-config version 1.
This matters because Proxmox VE-even in version 9.1 still generates network-config v1 format when you configure cloud-init through the web UI. The result: your carefully configured static IP settings are silently ignored, and the VM falls back to DHCP. For production environments where predictable addressing matters, this is a deal-breaker.
The Version Mismatch
Cloud-init’s network configuration has evolved through several versions:
- Version 1: The original format, with nested dictionaries and explicit type fields
- Version 2 (Netplan): A cleaner YAML structure that became the de facto standard
Here’s what Proxmox generates (v1):
version: 1
config:
- type: physical
name: vtnet0
subnets:
- type: static
address: 10.254.254.42/24
gateway: 10.254.254.1
And what nuageinit expects (v2):
version: 2
ethernets:
vtnet0:
addresses:
- 10.254.254.42/24
gateway4: 10.254.254.1
The structures are fundamentally different. nuageinit simply doesn’t parse v1, leaving your VM with DHCP-assigned addresses instead of the static configuration you specified.
The Workaround
Until Proxmox adds v2 support or nuageinit gains v1 compatibility, the solution is to generate your own cloud-init ISO. The script below runs on a Proxmox host and creates a properly formatted ISO that FreeBSD will actually understand.
Installation
Save the script as /usr/local/bin/freebsd-cloudinit-iso and make it executable:
chmod +x /usr/local/bin/freebsd-cloudinit-iso
Dependencies are minimal-just genisoimage or mkisofs, which are typically already present on Proxmox hosts.
Basic Usage
freebsd-cloudinit-iso \
--vmid 203 \
--hostname myfreebsd \
--domain example.com \
--user admin \
--password 'secretpassword' \
--ip4 10.254.254.42/24 \
--gw4 10.254.254.1 \
--dns 1.1.1.1 \
--dns 1.0.0.1 \
--storage local
This creates an ISO with:
- Hostname set to
myfreebsd.example.com - A user
adminwith sudo privileges and the specified password - Static IPv4 configuration
- Cloudflare DNS servers
The ISO is automatically attached to VM 203 as a CD-ROM drive.
Dual-Stack Configuration
For IPv6-first environments:
freebsd-cloudinit-iso \
--vmid 204 \
--hostname webserver \
--domain prod.example.com \
--user deploy \
--ssh-key-file ~/.ssh/id_ed25519.pub \
--ip6 2001:db8::50/64 \
--gw6 2001:db8::1 \
--ip4 10.0.0.50/24 \
--gw4 10.0.0.1 \
--dns 2001:db8::1 \
--dns 10.0.0.1 \
--storage ceph-nvme
Note that IPv6 addresses are listed first in the generated config-nuageinit applies them in order, and IPv6-first makes sense for modern networks.
SSH Key Authentication
For production deployments, prefer SSH keys over passwords:
freebsd-cloudinit-iso \
--vmid 205 \
--hostname secure \
--domain internal.lan \
--user ansible \
--ssh-key-file ~/.ssh/automation.pub \
--ip4 10.100.0.10/24 \
--gw4 10.100.0.1 \
--dns 10.100.0.1 \
--storage local-zfs
You can also pass the key directly with --ssh-key "ssh-ed25519 AAAA...".
What the Script Generates
The script creates three files in the ISO:
meta-data
instance-id: 203-1735400000
local-hostname: myfreebsd
The instance-id uses the VM ID and timestamp, ensuring cloud-init recognizes each boot as potentially needing reconfiguration.
user-data
#cloud-config
hostname: myfreebsd
fqdn: myfreebsd.example.com
manage_etc_hosts: true
users:
- name: admin
groups: wheel
shell: /bin/sh
sudo: ALL=(ALL) NOPASSWD:ALL
lock_passwd: false
passwd: $6$rounds=5000$...
ssh_authorized_keys:
- ssh-ed25519 AAAA...
ssh_pwauth: true
chpasswd:
expire: false
Passwords are hashed using SHA-512 before being written-plaintext credentials never touch the ISO.
network-config
version: 2
ethernets:
vtnet0:
addresses:
- 2001:db8::50/64
- 10.0.0.50/24
gateway6: 2001:db8::1
gateway4: 10.0.0.1
nameservers:
addresses:
- 2001:db8::1
- 10.0.0.1
search:
- prod.example.com
This is the crucial part-version 2 format that nuageinit actually parses.
Full Option Reference
Required:
--vmid <id> VM ID in Proxmox
--hostname <name> Hostname (without domain)
--user <username> Username to create
Network (at least one required):
--ip4 <addr/prefix> IPv4 address with prefix (e.g., 10.0.0.50/24)
--gw4 <addr> IPv4 gateway
--ip6 <addr/prefix> IPv6 address with prefix
--gw6 <addr> IPv6 gateway
Authentication (at least one required):
--password <pass> Password for user (will be hashed)
--root-password <pass> Password for root (will be hashed)
--ssh-key <key> SSH public key string
--ssh-key-file <file> Path to SSH public key file
DNS:
--dns <addr> DNS server (repeatable)
--search <domain> Search domain (repeatable)
--domain <domain> Domain (also added to search)
Storage:
--storage <name> Proxmox storage for ISO (default: auto-detect)
Options:
--iface <name> Network interface (default: vtnet0)
--groups <groups> Comma-separated groups (default: wheel)
--output-dir <dir> Output directory instead of storage
--no-attach Create ISO but don't attach to VM
Workflow Integration
With Terraform/OpenTofu
If you’re provisioning FreeBSD VMs with Terraform, call the script as a local-exec provisioner after creating the VM but before first boot:
resource "proxmox_vm_qemu" "freebsd" {
name = "freebsd-web"
vmid = 210
target_node = "pve1"
# ... other config ...
}
resource "null_resource" "cloudinit" {
depends_on = [proxmox_vm_qemu.freebsd]
provisioner "local-exec" {
command = <<-EOT
ssh root@pve1 'freebsd-cloudinit-iso \
--vmid 210 \
--hostname freebsd-web \
--domain example.com \
--user terraform \
--ssh-key-file /root/.ssh/terraform.pub \
--ip4 10.0.0.210/24 \
--gw4 10.0.0.1 \
--dns 10.0.0.1 \
--storage local'
EOT
}
}
With Ansible
For Ansible-driven provisioning:
- name: Generate FreeBSD cloud-init ISO
delegate_to: "{{ proxmox_host }}"
command: >
freebsd-cloudinit-iso
--vmid {{ vm_id }}
--hostname {{ inventory_hostname_short }}
--domain {{ domain }}
--user {{ ansible_user }}
--ssh-key-file /root/.ssh/ansible.pub
--ip4 {{ ansible_host }}/24
--gw4 {{ gateway }}
--dns {{ dns_server }}
--storage {{ proxmox_storage }}
Limitations and Caveats
Single interface only: The script currently supports one network interface. Multiple NICs would require extending the network-config generation.
No DHCP option: This is intentionally for static configurations. If you want DHCP, the default Proxmox cloud-init works fine (nuageinit falls back to DHCP when it can’t parse the network config).
VirtIO interface name: The default vtnet0 assumes VirtIO network devices. If you’re using emulated e1000 or another driver, specify --iface accordingly.
Storage detection: Auto-detection looks for storage with iso content type. If your Proxmox setup is non-standard, specify --storage explicitly.
Why Not Just Fix Proxmox?
Fair question. There’s an open feature request for network-config v2 support in Proxmox, but it’s not trivial-the entire cloud-init subsystem assumes v1 internally. FreeBSD’s nuageinit could also add v1 parsing, but the project explicitly chose v2 as the modern format.
In the meantime, this script fills the gap. It’s not elegant, but it works reliably and integrates cleanly with existing Proxmox workflows.
The Script
The full script is available below. It’s a single Bash file with no external dependencies beyond standard Proxmox tools:
#!/bin/bash
#
# freebsd-cloudinit-iso - Generate cloud-init ISO for FreeBSD VMs on Proxmox
#
# This works around FreeBSD 15's nuageinit not supporting network-config v1
# by generating v2 format that nuageinit actually understands.
set -e
# Defaults
VMID=""
HOSTNAME=""
DOMAIN=""
USER=""
PASSWORD=""
ROOT_PASSWORD=""
SSH_KEY=""
SSH_KEY_FILE=""
IP4=""
GW4=""
IP6=""
GW6=""
DNS=()
SEARCH=()
STORAGE=""
IFACE="vtnet0"
OUTPUT_DIR=""
ATTACH=true
USER_GROUPS="wheel"
usage() {
cat <<EOF
Usage: $(basename "$0") [options]
Required:
--vmid <id> VM ID
--hostname <name> Hostname (without domain)
--user <username> Username to create
Network (at least one of --ip4 or --ip6 required):
--ip4 <addr/prefix> IPv4 address with prefix (e.g., 10.0.0.50/24)
--gw4 <addr> IPv4 gateway
--ip6 <addr/prefix> IPv6 address with prefix (e.g., 2001:db8::50/64)
--gw6 <addr> IPv6 gateway
Authentication (at least one required):
--password <pass> Password for user (plaintext, will be hashed)
--root-password <pass> Password for root (plaintext, will be hashed)
--ssh-key <key> SSH public key string
--ssh-key-file <file> Path to SSH public key file
DNS:
--dns <addr> DNS server (can be specified multiple times)
--search <domain> Search domain (can be specified multiple times)
--domain <domain> Domain name (also added to search if not present)
Storage:
--storage <name> Proxmox storage name (default: auto-detect)
Options:
--iface <name> Network interface name (default: vtnet0)
--groups <groups> Comma-separated groups (default: wheel)
--output-dir <dir> Output directory for ISO (default: storage path)
--no-attach Don't attach ISO to VM, just create it
--help Show this help
EOF
exit 1
}
error() {
echo "Error: $1" >&2
exit 1
}
warn() {
echo "Warning: $1" >&2
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--vmid) VMID="$2"; shift 2 ;;
--hostname) HOSTNAME="$2"; shift 2 ;;
--domain) DOMAIN="$2"; shift 2 ;;
--user) USER="$2"; shift 2 ;;
--password) PASSWORD="$2"; shift 2 ;;
--root-password) ROOT_PASSWORD="$2"; shift 2 ;;
--ssh-key) SSH_KEY="$2"; shift 2 ;;
--ssh-key-file) SSH_KEY_FILE="$2"; shift 2 ;;
--ip4) IP4="$2"; shift 2 ;;
--gw4) GW4="$2"; shift 2 ;;
--ip6) IP6="$2"; shift 2 ;;
--gw6) GW6="$2"; shift 2 ;;
--dns) DNS+=("$2"); shift 2 ;;
--search) SEARCH+=("$2"); shift 2 ;;
--storage) STORAGE="$2"; shift 2 ;;
--iface) IFACE="$2"; shift 2 ;;
--groups) USER_GROUPS="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
--no-attach) ATTACH=false; shift ;;
--help) usage ;;
*) error "Unknown option: $1" ;;
esac
done
# Validation
[[ -z "$VMID" ]] && error "--vmid is required"
[[ -z "$HOSTNAME" ]] && error "--hostname is required"
[[ -z "$USER" ]] && error "--user is required"
[[ -z "$IP4" && -z "$IP6" ]] && error "At least one of --ip4 or --ip6 is required"
[[ -z "$PASSWORD" && -z "$SSH_KEY" && -z "$SSH_KEY_FILE" ]] && \
error "At least one of --password, --ssh-key, or --ssh-key-file is required"
# Check for required tools
command -v genisoimage >/dev/null 2>&1 || \
command -v mkisofs >/dev/null 2>&1 || \
error "genisoimage or mkisofs required"
command -v qm >/dev/null 2>&1 || warn "qm not found - not running on Proxmox?"
# Load SSH key from file if specified
if [[ -n "$SSH_KEY_FILE" ]]; then
[[ -f "$SSH_KEY_FILE" ]] || error "SSH key file not found: $SSH_KEY_FILE"
SSH_KEY=$(cat "$SSH_KEY_FILE")
fi
# Find storage path
find_storage_path() {
local storage="$1"
if [[ -n "$OUTPUT_DIR" ]]; then
echo "$OUTPUT_DIR"
return
fi
if [[ -z "$storage" ]]; then
storage=$(pvesm status --content iso 2>/dev/null | awk 'NR>1 {print $1; exit}')
[[ -z "$storage" ]] && \
error "No storage with 'iso' content type found. Specify --storage or --output-dir"
fi
local path=$(pvesm path "${storage}:iso/dummy.iso" 2>/dev/null | sed 's|/dummy\.iso$||')
[[ -z "$path" ]] && \
error "Could not determine path for storage: $storage"
STORAGE="$storage"
echo "$path"
}
SNIPPETS_PATH=$(find_storage_path "$STORAGE")
mkdir -p "$SNIPPETS_PATH"
# Create temp directory
TMPDIR=$(mktemp -d)
trap "rm -rf '$TMPDIR'" EXIT
# Generate password hashes
PASSWORD_HASH=""
if [[ -n "$PASSWORD" ]]; then
PASSWORD_HASH=$(openssl passwd -6 "$PASSWORD" 2>/dev/null) || \
PASSWORD_HASH=$(python3 -c "import crypt; print(crypt.crypt('$PASSWORD', crypt.mksalt(crypt.METHOD_SHA512)))" 2>/dev/null) || \
error "Could not hash password"
fi
ROOT_PASSWORD_HASH=""
if [[ -n "$ROOT_PASSWORD" ]]; then
ROOT_PASSWORD_HASH=$(openssl passwd -6 "$ROOT_PASSWORD" 2>/dev/null) || \
ROOT_PASSWORD_HASH=$(python3 -c "import crypt; print(crypt.crypt('$ROOT_PASSWORD', crypt.mksalt(crypt.METHOD_SHA512)))" 2>/dev/null) || \
error "Could not hash root password"
fi
# Build FQDN
FQDN="$HOSTNAME"
[[ -n "$DOMAIN" ]] && FQDN="${HOSTNAME}.${DOMAIN}"
# Add domain to search if not present
if [[ -n "$DOMAIN" ]]; then
domain_in_search=false
for s in "${SEARCH[@]}"; do
[[ "$s" == "$DOMAIN" ]] && domain_in_search=true
done
$domain_in_search || SEARCH=("$DOMAIN" "${SEARCH[@]}")
fi
# Create meta-data
cat > "$TMPDIR/meta-data" <<EOF
instance-id: $(uuidgen 2>/dev/null || echo "${VMID}-$(date +%s)")
local-hostname: ${HOSTNAME}
EOF
# Create user-data
cat > "$TMPDIR/user-data" <<EOF
#cloud-config
hostname: ${HOSTNAME}
fqdn: ${FQDN}
manage_etc_hosts: true
users:
- name: ${USER}
groups: ${USER_GROUPS}
shell: /bin/sh
sudo: ALL=(ALL) NOPASSWD:ALL
EOF
if [[ -n "$PASSWORD_HASH" ]]; then
cat >> "$TMPDIR/user-data" <<EOF
lock_passwd: false
passwd: ${PASSWORD_HASH}
EOF
fi
if [[ -n "$SSH_KEY" ]]; then
cat >> "$TMPDIR/user-data" <<EOF
ssh_authorized_keys:
- ${SSH_KEY}
EOF
fi
if [[ -n "$PASSWORD_HASH" ]]; then
cat >> "$TMPDIR/user-data" <<EOF
ssh_pwauth: true
chpasswd:
expire: false
EOF
fi
if [[ -n "$ROOT_PASSWORD_HASH" ]]; then
cat >> "$TMPDIR/user-data" <<EOF
chpasswd:
expire: false
users:
- name: root
password: ${ROOT_PASSWORD_HASH}
type: HASH
EOF
fi
# Create network-config (v2 format)
cat > "$TMPDIR/network-config" <<EOF
version: 2
ethernets:
${IFACE}:
EOF
echo " addresses:" >> "$TMPDIR/network-config"
[[ -n "$IP6" ]] && echo " - ${IP6}" >> "$TMPDIR/network-config"
[[ -n "$IP4" ]] && echo " - ${IP4}" >> "$TMPDIR/network-config"
[[ -n "$GW6" ]] && echo " gateway6: ${GW6}" >> "$TMPDIR/network-config"
[[ -n "$GW4" ]] && echo " gateway4: ${GW4}" >> "$TMPDIR/network-config"
if [[ ${#DNS[@]} -gt 0 ]] || [[ ${#SEARCH[@]} -gt 0 ]]; then
echo " nameservers:" >> "$TMPDIR/network-config"
if [[ ${#DNS[@]} -gt 0 ]]; then
echo " addresses:" >> "$TMPDIR/network-config"
for dns in "${DNS[@]}"; do
echo " - ${dns}" >> "$TMPDIR/network-config"
done
fi
if [[ ${#SEARCH[@]} -gt 0 ]]; then
echo " search:" >> "$TMPDIR/network-config"
for search in "${SEARCH[@]}"; do
echo " - ${search}" >> "$TMPDIR/network-config"
done
fi
fi
# Generate ISO
ISO_NAME="freebsd-ci-${VMID}.iso"
ISO_PATH="${SNIPPETS_PATH}/${ISO_NAME}"
if command -v genisoimage >/dev/null 2>&1; then
MKISO="genisoimage"
else
MKISO="mkisofs"
fi
$MKISO -output "$ISO_PATH" -volid cidata -joliet -rock "$TMPDIR" 2>/dev/null
echo "Created: ${ISO_PATH}"
# Attach to VM
if $ATTACH && command -v qm >/dev/null 2>&1; then
if qm status "$VMID" >/dev/null 2>&1; then
qm set "$VMID" --delete ide2 2>/dev/null || true
qm set "$VMID" --ide2 "${STORAGE}:iso/${ISO_NAME},media=cdrom"
echo "Attached to VM ${VMID} as ide2"
else
warn "VM ${VMID} not found, ISO created but not attached"
fi
fi
Conclusion
FreeBSD 15’s nuageinit and Proxmox’s cloud-init generator speak different dialects of the same configuration language. Until upstream fixes arrive, this script provides a reliable workaround for anyone running FreeBSD VMs on Proxmox who needs static network configuration.
The approach is straightforward: generate the ISO yourself with the correct format. It integrates with existing automation tools, requires no modifications to either Proxmox or FreeBSD, and produces predictable results.