With my colleague
When you deploy Linux appliances at scale, cloud‑init is usually the go‑to mechanism for injecting instance‑specific configuration (IP, hostname, NTP, credentials, etc.) at boot time.
However, the Veeam Software Appliance used here does not ship with cloud‑init currently, so we need another way to achieve the same result:
build one customized ISO and then let vSphere guest variables drive per‑VM configuration.
This post walks through how I achieved that using:
- a customized Kickstart file,
- a customized GRUB configuration,
- VMware guestinfo / OVF properties as the source of truth for variables, and
- a small shell script that dynamically extracts and uses `vmware-rpctool` (even though open‑vm‑tools is not installed in the installer environment),
cleans up after itself, and hands configuration over to the post‑install phase via a temporary env file.
The result: you can build one ISO once, and later, in vSphere, simply pass variables to each VM, which the Kickstart logic will consume at install time.
Cloud‑Init vs. Kickstart in This Appliance
On many modern Linux images, cloud‑init is responsible for:
- pulling metadata from cloud platforms or config drives,
- writing out network config,
- creating users and SSH keys,
- and running first‑boot scripts.
Because the Veeam appliance installer environment does not include cloud‑init currently, we instead rely on Kickstart:
- Kickstart’s `%pre` section runs before the actual installation and can:
- discover environment data,
- write temporary configuration files (for example, network config),
- and export variables for later phases.
- Kickstart’s `%post` section runs after packages are installed and can:
- read those temporary files,
- configure services,
- and finish the OS customization inside the installed system.
Our goal is to treat `%pre` as a lightweight “poor man’s cloud‑init”: it reads values from vSphere and writes out the config and variables that `%post` will consume.
---
Customizing GRUB and the Kickstart File Once
First, we prepare custom versions of the GRUB and Kickstart files and inject them into a copy of the original ISO.
Unpack the original ISO and extract the two files you want to customize:
xorriso -boot_image any keep -dev "VeeamSoftwareAppliance_13.0.1.180_20251101.iso" -osirrox on -extract vbr-ks.cfg custom2_vbr-ks.cfg
xorriso -boot_image any keep -dev "VeeamSoftwareAppliance_13.0.1.180_20251101.iso" -osirrox on -extract /EFI/BOOT/grub.cfg custom2_grub.cfg
You then edit `custom_vbr-ks.cfg` and `custom_grub.cfg` according to Veeam’s documentation, for example:
- Set a non‑interactive GRUB entry with `inst.assumeyes`.
- Add post‑install automation, including disabling the initialization wizard and creating an answer file (`/etc/veeam/vbr_init.cfg`).
Once your changes are done, you normalize line endings and write them back into a copy of the ISO:
# 1. Define filenames
SOURCE_ISO="VeeamSoftwareAppliance_13.0.1.180_20251101.iso"
TARGET_ISO="VeeamAppliance_Customized.iso"
cp "$SOURCE_ISO" "$TARGET_ISO"
tr -d '\r' < custom_vbr-ks.cfg > vbr-ks.cfg
tr -d '\r' < custom_grub.cfg > grub.cfg
# 2. Run xorriso to replace the files inside the ISO
xorriso -boot_image any keep \
-dev "$TARGET_ISO" \
-rm /vbr-ks.cfg -- \
-map vbr-ks.cfg /vbr-ks.cfg \
-rm /EFI/BOOT/grub.cfg -- \
-map grub.cfg /EFI/BOOT/grub.cfg \
-commit
# 3. Cleanup temporary files
rm vbr-ks.cfg grub.cfgFrom here on, all logic is inside the custom Kickstart and GRUB configs. You never have to rebuild the ISO again unless you change that logic.
---
How vSphere Passes Variables to the Appliance
We want each VM to receive its own:
- `ipaddr`
- `netmask`
- `gateway`
- `dns`
- `hostname`
- `ntp`
In vSphere, you can provide these in two ways:
- Direct guestinfo keys on a regular template:
- `guestinfo.ipaddr`
- `guestinfo.netmask`
- `guestinfo.gateway`
- `guestinfo.dns`
- `guestinfo.hostname`
- `guestinfo.ntp`
- OVF properties on an OVF/OVA:
- For example:
- `oe:key="ipaddr" oe:value="172.16.1.7"`
- `oe:key="netmask" oe:value="255.255.255.0"`
- `oe:key="gateway" oe:value="172.16.1.1"`
- `oe:key="dns" oe:value="172.16.1.1"`
- `oe:key="hostname" oe:value="vbr-custom1"`
- `oe:key="ntp" oe:value="1.2.3.0"`
Our `%pre` script will try OVF first, then direct guestinfo keys as a fallback.
---
The Interesting Part: Using `vmware-rpctool` When Tools Are Not Installed
The installer environment does not have open‑vm‑tools installed as a regular package, but the RPM is present in the install tree. The trick is:
1. Extract `vmware-rpctool` on the fly from the `open-vm-tools` RPM.
2. Use `vmware-rpctool` to query the OVF environment or guestinfo keys.
3. Generate a Kickstart‑compatible network config file.
4. Store the NTP variable in a temp env file for later use in `%post`.
5. Clean up everything before continuing.
This all happens in the `%pre` section of `custom_vbr-ks.cfg`:
# --- START VMWARE UNIFIED CONFIGURATION ---
GUESTINFO_LOG="/tmp/guestinfo-debug.log"
exec > >(tee -a "$GUESTINFO_LOG") 2>&1
echo "=== VMware Configuration Debug Log - $(date) ==="
TOOLS_BIN=""
G_IP=""
G_NETMASK=""
G_GATEWAY=""
G_DNS=""
G_HOSTNAME=""
G_NTP=""
CONFIG_METHOD="none"
# 1. Extract vmware-rpctool from open-vm-tools RPM (installer-only environment)
echo "Step 1: Extracting vmware-rpctool..."
RPM_PATH=$(find /run/install/repo/BaseOS -name "open-vm-tools-*.rpm" | head -n 1)
if [ -n "$RPM_PATH" ]; then
echo "Found RPM: $RPM_PATH"
pushd /tmp >/dev/null
rpm2cpio "$RPM_PATH" | cpio -idmv ./usr/bin/vmware-rpctool >/dev/null 2>&1
if [ -f ./usr/bin/vmware-rpctool ]; then
cp ./usr/bin/vmware-rpctool /usr/bin/
chmod +x /usr/bin/vmware-rpctool
TOOLS_BIN="/usr/bin/vmware-rpctool"
echo "vmware-rpctool extracted successfully"
else
echo "ERROR: vmware-rpctool not found after extraction"
fi
popd >/dev/null
else
echo "ERROR: open-vm-tools RPM not found"
fi
Once `vmware-rpctool` is available, we try the OVF environment first:
# 2. Try Method 1: OVF Environment (ovf:transport="com.vmware.guestInfo")
if [ -n "$TOOLS_BIN" ]; then
echo "Step 2: Attempting Method 1 - OVF Environment XML..."
OVF_ENV=$($TOOLS_BIN 'info-get guestinfo.ovfEnv' 2>&1)
if [ -n "$OVF_ENV" ] && [ "$OVF_ENV" != "No value found" ]; then
echo "OVF Environment found, parsing..."
# Parse properties from XML (format: <Property oe:key="ipaddr" oe:value="172.16.1.7"/>)
G_IP=$(echo "$OVF_ENV" | grep -oP 'oe:key="ipaddr"[^>]*oe:value="\K[^"]*' 2>/dev/null || echo "")
G_NETMASK=$(echo "$OVF_ENV" | grep -oP 'oe:key="netmask"[^>]*oe:value="\K[^"]*' 2>/dev/null || echo "")
G_GATEWAY=$(echo "$OVF_ENV" | grep -oP 'oe:key="gateway"[^>]*oe:value="\K[^"]*' 2>/dev/null || echo "")
G_DNS=$(echo "$OVF_ENV" | grep -oP 'oe:key="dns"[^>]*oe:value="\K[^"]*' 2>/dev/null || echo "")
G_HOSTNAME=$(echo "$OVF_ENV" | grep -oP 'oe:key="hostname"[^>]*oe:value="\K[^"]*' 2>/dev/null || echo "")
G_NTP=$(echo "$OVF_ENV" | grep -oP 'oe:key="ntp"[^>]*oe:value="\K[^"]*' 2>/dev/null || echo "")
if [ -n "$G_IP" ]; then
CONFIG_METHOD="ovf_environment"
echo "SUCCESS: Retrieved values from OVF Environment"
fi
else
echo "OVF Environment not found or empty"
fi
fi
If we don’t find OVF data, we fall back to direct guestinfo keys:
# 3. Try Method 2: Direct guestinfo keys (fallback)
if [ "$CONFIG_METHOD" = "none" ] && [ -n "$TOOLS_BIN" ]; then
echo "Step 3: Attempting Method 2 - Direct guestinfo keys..."
G_IP=$($TOOLS_BIN 'info-get guestinfo.ipaddr' 2>&1)
[ "$G_IP" = "No value found" ] && G_IP=""
G_NETMASK=$($TOOLS_BIN 'info-get guestinfo.netmask' 2>&1)
[ "$G_NETMASK" = "No value found" ] && G_NETMASK=""
G_GATEWAY=$($TOOLS_BIN 'info-get guestinfo.gateway' 2>&1)
[ "$G_GATEWAY" = "No value found" ] && G_GATEWAY=""
G_DNS=$($TOOLS_BIN 'info-get guestinfo.dns' 2>&1)
[ "$G_DNS" = "No value found" ] && G_DNS=""
G_HOSTNAME=$($TOOLS_BIN 'info-get guestinfo.hostname' 2>&1)
[ "$G_HOSTNAME" = "No value found" ] && G_HOSTNAME=""
G_NTP=$($TOOLS_BIN 'info-get guestinfo.ntp' 2>&1)
[ "$G_NTP" = "No value found" ] && G_NTP=""
if [ -n "$G_IP" ]; then
CONFIG_METHOD="guestinfo_direct"
echo "SUCCESS: Retrieved values from direct guestinfo keys"
fi
fi
We then log the values and generate `/tmp/pre-network.cfg`, which Kickstart uses for its `network` command:
# 4. Display retrieved values
echo "Configuration Method: $CONFIG_METHOD"
echo "Parsed values:"
echo " IP: $G_IP"
echo " Netmask: $G_NETMASK"
echo " Gateway: $G_GATEWAY"
echo " DNS: $G_DNS"
echo " Hostname: $G_HOSTNAME"
echo " NTP: $G_NTP"
# 5. Generate Network Config
if [ -n "$G_IP" ] && [ -n "$G_NETMASK" ]; then
echo "SUCCESS: Using static IP configuration"
[ -z "$G_HOSTNAME" ] && G_HOSTNAME="vbr-${MACH_HASH}"
GW_STR=""
[ -n "$G_GATEWAY" ] && GW_STR="--gateway=$G_GATEWAY"
DNS_STR=""
[ -n "$G_DNS" ] && DNS_STR="--nameserver=$G_DNS"
cat <<EOF > /tmp/pre-network.cfg
network --bootproto=static --ip=$G_IP --netmask=$G_NETMASK $GW_STR $DNS_STR --hostname=$G_HOSTNAME --device=link --onboot=on --activate
EOF
else
echo "WARNING: Falling back to DHCP (no valid IP configuration found)"
cat <<EOF > /tmp/pre-network.cfg
network --bootproto=dhcp --nodns --hostname=vbr-${MACH_HASH}
EOF
fi
echo "Generated network config:"
cat /tmp/pre-network.cfg
Storing the NTP variable for later is a crucial part: `%pre` can see the OVF/guestinfo, but `%post` runs inside the target root filesystem. To bridge that gap, we write a small env file:
if [ -n "$G_NTP" ]; then
# Save NTP variable for use in %post section
echo "export G_NTP=\"${G_NTP}\"" > /tmp/custom_vars.env
echo "NTP variable saved to /tmp/custom_vars.env"
fi
Finally, we clean up everything we created in the installer environment:
# 6. Cleanup
if [ -n "$TOOLS_BIN" ]; then
rm -f "$TOOLS_BIN"
rm -rf /tmp/usr
echo "Cleanup completed"
fi
echo "=== VMware Configuration Debug Complete - $(date) ==="
# --- END VMWARE UNIFIED CONFIGURATION ---
Key takeaway: although open‑vm‑tools is not installed, we still leverage `vmware-rpctool` by extracting it briefly from the RPM, using it,
and then removing it again to keep the environment clean.
Handing Variables Over to `%post` via a Temporary Env File
The `%pre` section wrote `/tmp/custom_vars.env` with:
export G_NTP="${G_NTP}"To make that available inside the installed system, we copy it over in the `%post --nochroot` section:
# Copy custom environment variable if it exists
if [ -f /tmp/custom_vars.env ]; then
log "Copy custom environment variable"
cp /tmp/custom_vars.env /mnt/sysimage/tmp/custom_vars.env
fiLater, in the regular `%post` (chrooted) section, we can safely source it and use the NTP server value:
# Source NTP variable from %pre if available
if [ -f /tmp/custom_vars.env ]; then
source /tmp/custom_vars.env
log "Loaded NTP from pre: $G_NTP"
fi
NTP_SERVER="${G_NTP}"
log "Using NTP server: $NTP_SERVER"
At this point, `%post` can:
- write NTP configuration using `$NTP_SERVER`,
- fill in `ntp.servers` in `/etc/veeam/vbr_init.cfg`,
- and run any additional initialization logic.
This pattern (write temp file in `%pre`, copy in `%post --nochroot`, source in `%post`) gives us a reliable way to pass data from the installer environment into the future running system without cloud‑init.
vbr_init.cfg part with the vars and the rest is the same content from the original Kickstart
# Source NTP variable from %pre if available
if [ -f /tmp/custom_vars.env ]; then
source /tmp/custom_vars.env
log "Loaded NTP from pre: $G_NTP"
fi
NTP_SERVER="${G_NTP}"
log "Using NTP server: $NTP_SERVER"
cat << EOF >> /etc/veeam/vbr_init.cfg
veeamadmin.password=pass_here_based_on_docu
veeamadmin.mfaSecretKey=mfa_key
veeamadmin.isMfaEnabled=false
veeamso.password=pass_here_based_on_docu
veeamso.mfaSecretKey=mfa_key
veeamso.isMfaEnabled=false
veeamso.recoveryToken=token_here
veeamso.isEnabled=false
ntp.servers=${NTP_SERVER}
ntp.runSync=true
vbr_control.runInitIso=true
vbr_control.runStart=true
EOF
Building an OVF is very easy, create a new VM with the minimum hardware specifications: 8CPU, 16GB ram, 2x 240GB disks, 1 NIC.
Attach the modified ISO to the cd/dvd rom.
In the VM configuration in vAPP enable the properties and add the defined variables to the vAPP properties. Export the VM as OVF template.
These vars will be able to fill while deploying from this exported OVF template.
The other way is to skip the vAPP properties and only change the VM configuration and add the variablenames and variable values directly to the VM.
You can further automate this procedure with terraform or VMware automation , vRA etc.
This procedure can be further finetuned to fill every variable in the vbr_init.cfg from outside of the VM but for this time only the NTP is described and the rest is static in the iso
From One ISO to Many VMs
Putting it all together:
- You customize the ISO once by:
- tweaking GRUB for non‑interactive installs,
- extending the Kickstart `%pre` and `%post` to read guestinfo/OVF variables and fully configure the appliance.
- You then deploy new VMs in vSphere by:
- attaching the customized ISO,
- setting guestinfo keys or OVF properties (IP, netmask, gateway, DNS, hostname, NTP),
- and powering on.
The `%pre` section:
- extracts and uses `vmware-rpctool` from the installer RPM,
- reads configuration from guestinfo/OVF,
- writes `/tmp/pre-network.cfg` for Kickstart,
- and stores NTP and other values in `/tmp/custom_vars.env`.
The `%post` sections:
- copy and source `/tmp/custom_vars.env`,
- configure NTP and other service settings,
- and finish the Veeam appliance configuration automatically.
No cloud‑init is needed, but you still get cloud‑like, data‑driven, repeatable deployments from a single, reusable ISO.
The best thing is that you can use this appliance modification for proxy, Enterprise Manager and also for VBR.
In the attachment you can find full modification of kickstart.
