Larvitz Blog

FreeBSD, Linux, all things cleanly engineered

FreeBSD 15 Cloud-Init on Proxmox: Working Around nuageinit’s Network-Config Gap



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 admin with 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.


References