- Quick Start
- Project Structure
- Configuration
- Provisioning Scripts
- Include System
- Network Configuration
- Users and Authentication
- CLI Reference
- Postbuild
- Connecting ISO to Proxmox
- Debugging
# Install dependencies
apt-get install -y python3-yaml genisoimage # Debian/Ubuntu/Proxmox
dnf install -y python3-pyyaml genisoimage # RHEL/Rocky/Alma
pacman -S python-yaml cdrtools # Arch
# Create main.json (see examples below)
# Place provisioning scripts in samples/{profile}/
# Build everything
python3 build.py -a
# Build specific preset
python3 build.py --preset my-router
# Build multiple presets
python3 build.py --preset my-router,web-server
# Preview without generating
python3 build.py -a --dry-run
# Inline build without any config file
python3 build.py --no-config --profile ubuntu --ids default --hostname myvm --ip 10.0.0.10/24 --gateway 10.0.0.1cibuilder/
├── build.py # CLI frontend
├── lib/ # Core library
│ ├── __init__.py
│ ├── builder.py # Build orchestration
│ ├── config.py # Config loading + conf.d merging
│ ├── scripts.py # Script resolution + includes
│ ├── network.py # Network config generation
│ └── iso.py # ISO creation + YAML writing
├── main.json # Orchestrator: global + builds
├── conf.d/ # Modular build definitions
│ └── *.json
├── samples/ # Provisioning scripts + includes
│ ├── ubuntu/ # Profile: Ubuntu-based machines
│ │ ├── 01-base_default.sh
│ │ ├── 10-network_default.sh
│ │ ├── 10-network_router.sh
│ │ ├── 30-packages_default.sh
│ │ └── include/ # Include files: {id}_{name}
│ │ ├── default_motd
│ │ └── router_iptables.conf
│ ├── debian/ # Profile: Debian-based machines
│ ├── alpine/ # Profile: Alpine-based machines
│ ├── rhel/ # Profile: RHEL-based machines
│ └── common/ # Shared across all profiles
├── postbuild/
│ └── deploy-to-proxmox.sh
└── output/ # Generated ISOs and files
├── {preset-name}/
│ ├── user-data
│ ├── meta-data
│ ├── network-config
│ └── scripts/
│ ├── 01-base.sh
│ ├── 10-network.sh
│ └── include/
│ ├── motd
│ └── iptables.conf
└── {preset-name}.iso
Central orchestrator. Defines global defaults and a builds array.
{
"global": {
"output_dir": "./output",
"iso_label": "cidata",
"default_ssh_key": "~/.ssh/id_rsa.pub",
"auto_clean": true,
"postbuild": "postbuild/deploy-all.sh",
"default_include_permissions": {
"*": "0644",
".sh": "0755",
".env": "0600"
},
"params": {
"timezone": "UTC",
"dns": "8.8.8.8",
"domain": "local"
}
},
"builds": [
{
"preset": "ubuntu-router",
"profile": "ubuntu",
"ids": ["default", "router"],
"default_include_permissions": {
"*": "0644",
".conf": "0640"
},
"includes": [
"router_iptables.rules",
{
"src": "motd",
"dest": "/etc/update-motd.d/99-custom",
"mode": "0755"
}
],
"params": {
"hostname": "router1",
"ip": "dhcp",
"interface": "ens18"
},
"ignore_missing_ids": false
},
{
"preset": "ubuntu-web",
"profile": "ubuntu",
"ids": ["default"],
"includes": [
{
"src": ".env",
"dest": "/etc/app/config.env",
"mode": "0600",
"owner": "1000:1000"
},
{
"src": "scripts",
"dest": "/usr/local/bin",
"tree": true,
"mode": "0755"
}
],
"params": {
"hostname": "web-01",
"ciuser": "admin",
"password": "changeme123",
"ip": "10.0.0.10/24",
"gateway": "10.0.0.1",
"interface": "ens18"
}
}
]
}Split builds across files. Files are sorted alphabetically and merged after main.json.
// conf.d/routers.json
{
"builds": [
{
"preset": "router-east",
"profile": "ubuntu",
"ids": ["default", "router"],
"includes": [
{
"src": "iptables",
"dest": "/etc/iptables/rules.v4",
"mode": "0644"
}
],
"params": {
"hostname": "router-east",
"ip": "dhcp"
}
},
{
"preset": "router-west",
"profile": "ubuntu",
"ids": ["default", "router"],
"params": {
"hostname": "router-west",
"ip": "dhcp"
}
}
]
}Each file can contain a single build, an array of builds, or a dict with global and builds.
// conf.d/99-production.json
{
"global": {
"postbuild": "postbuild/deploy-to-prod.sh",
"params": {
"timezone": "Europe/Moscow",
"dns": "1.1.1.1"
},
"default_include_permissions": {
"*.key": "0400"
}
}
}Merge rules:
paramsanddefault_include_permissionsare deep-merged- All other global keys are shallow-merged
- Build definitions are appended after
main.jsonbuilds
| Field | Type | Default | Description |
|---|---|---|---|
output_dir |
string | ./output |
Output directory for ISOs |
iso_label |
string | cidata |
ISO volume label |
default_ssh_key |
string | ~/.ssh/id_rsa.pub |
Path to SSH public key |
auto_clean |
bool | true |
Clean output dir before build |
postbuild |
string | — | Global postbuild script (fallback for all builds) |
default_include_permissions |
object | {"*": "0644"} |
Default mode/owner for includes |
params |
object | {} |
Default params for all builds |
| Field | Type | Description |
|---|---|---|
preset |
string | Required. Name of output ISO |
profile |
string | Required. Subdirectory in samples/ |
ids |
array | Required. Script IDs to include |
params |
object | Variables for {{key}} substitution |
include_dest |
string | Destination for includes (default: /opt/provision/include) |
includes |
array | Explicit include manifest (strings or objects) |
default_include_permissions |
object | Default mode/owner for this build's includes |
overrides.disable |
array | Script names to skip |
overrides.extra_files |
array | Additional script paths |
ignore_missing_ids |
bool | Skip build if false and ID has no scripts and no includes |
postbuild |
string | Per-build postbuild script (overrides global) |
| Param | Type | Description |
|---|---|---|
hostname |
string | System hostname |
ciuser |
string | Create user (triggers users block) |
password |
string | Plain-text password for ciuser |
ssh_pwauth |
bool | Enable password auth |
disable_root |
bool | Disable root login |
growpart |
bool | Auto-expand root partition |
package_update |
bool | Run package update |
package_upgrade |
bool | Run package upgrade |
packages |
array|string | Packages to install |
timezone |
string | System timezone |
ntp |
object | NTP configuration |
network_version |
int | 1 (default) or 2 (Netplan) |
interfaces |
array | Network interfaces |
ip |
string | Single IP with CIDR or dhcp (v1) |
gateway |
string | Default gateway (v1) |
interface |
string | Interface name, default eth0 (v1) |
dns |
string | DNS servers, comma-separated |
domain |
string | DNS search domain |
global.params → conf.d global.params → build.params → CLI overrides
{priority}-{name}_{id}.sh
| Component | Description | Example |
|---|---|---|
priority |
Execution order (numeric, zero-padded) | 01, 10, 50 |
name |
Script purpose | disk, network, packages |
id |
Configuration set ID | default, router, web |
Examples:
01-disk_default.sh→ priority=1, name=disk, id=default10-network_router.sh→ priority=10, name=network, id=router50-nginx_web.sh→ priority=50, name=nginx, id=web
Scripts live in samples/{profile}/ and samples/common/.
When multiple scripts share the same priority, the one whose id appears later in ids wins.
"ids": ["default", "router"]samples/ubuntu/
01-disk_default.sh → included
10-network_default.sh → conflict with 10-network_router.sh
10-network_router.sh → wins (router is later in ids)
30-packages_default.sh → included
When an ID is specified but no scripts are found, the builder checks for includes. If includes exist, build continues with [INFO]. If neither scripts nor includes are found, build is blocked (unless ignore_missing_ids=true).
Scripts are standard bash. Use {{key}} for parameter substitution.
#!/bin/bash
# samples/ubuntu/30-packages_default.sh
apt-get update
apt-get install -y nginx curl vim
echo "Hostname: {{hostname}}"
echo "Timezone: {{timezone}}"Best practices:
- Use
set -efor fail-fast (builder wraps execution automatically) - Use
|| truefor commands that may legitimately fail - Keep scripts focused on one task
- Use
apt-getinstead ofaptfor stable CLI
Include files are static assets copied into the VM via write_files.
samples/{profile}/include/{id}_{name}
{id}— matches IDs from the build'sidsarray{name}— source filename (preserved, any extension or none)- First underscore is the separator:
private_.env→ id=private, name=.env
Examples:
samples/ubuntu/include/
default_motd → /opt/provision/include/motd (mode=0644)
router_iptables.rules → /opt/provision/include/iptables.rules
private_.env → /opt/provision/include/.env (mode=0600)
Without an includes manifest, the builder auto-discovers all matching files.
Destination defaults to include_dest (/opt/provision/include). Last ID wins on conflicts.
{
"global": {
"default_include_permissions": {
"*": "0644",
".sh": "0755",
".env": "0600",
".key": {"mode": "0400", "owner": "0:0"},
"nginx.conf": "0644"
}
},
"builds": [
{
"preset": "app-server",
"default_include_permissions": {
"*": {"mode": "0640", "owner": "1000:1000"}
}
}
]
}Priority (first match wins):
- Explicit
mode/ownerinincludes[]entry preserve_mode: true— mode from source filesystem- Exact filename match in
default_include_permissions - Extension match (
.sh,.env) *fallback
Owner format: uid:gid (e.g., "0:0", "1000:1000"). Numeric IDs are safest.
{
"preset": "ubuntu-router",
"profile": "ubuntu",
"ids": ["default", "router", "private"],
"includes": [
"router_iptables.rules",
{
"src": "motd",
"dest": "/etc/update-motd.d/99-custom",
"mode": "0755"
},
{
"src": ".env",
"dest": "/etc/app/config.env",
"mode": "0600",
"owner": "1000:1000"
},
{
"src": "scripts",
"dest": "/usr/local/bin",
"tree": true,
"mode": "0755"
},
{
"src": "bin",
"dest": "/opt/app/bin",
"tree": true,
"preserve_mode": true,
"owner": "1000:1000"
}
]
}Object fields:
| Field | Type | Default | Description |
|---|---|---|---|
src |
string | Required | Source name in include/ (without {id}_) |
dest |
string | include_dest |
Destination path in VM |
mode |
string | from defaults | File permissions (octal string) |
owner |
string | from defaults | uid:gid or user:group |
tree |
bool | false |
Recursively copy directory |
preserve_mode |
bool | false |
Use mode from source filesystem |
When tree: true, src is a directory. Structure is preserved:
samples/ubuntu/include/private_scripts/
backup.sh
restore.sh
config/settings.json
{"src": "scripts", "dest": "/opt/app/scripts", "tree": true, "mode": "0755"}Result:
/opt/app/scripts/backup.sh
/opt/app/scripts/restore.sh
/opt/app/scripts/config/settings.json
preserve_mode: true copies mode from source filesystem. Explicit mode or owner still override:
{
"src": "bin",
"dest": "/opt/app/bin",
"tree": true,
"preserve_mode": true,
"owner": "1000:1000"
}Include files are always base64-encoded in user-data. This ensures binary files (SSH keys, compiled binaries, archives) are reproduced exactly.
Both v1 and v2 use the same unified interfaces format. The builder translates it automatically. Only network_version (default: 1) determines the output format.
"interfaces": [
{
"name": "ens18",
"type": "dhcp",
"dhcp4-overrides": {"use-routes": false}
},
{
"name": "ens19",
"type": "static",
"address": "10.0.0.10",
"prefix": 24,
"gateway": "10.0.0.1"
}
]Common fields:
| Field | Description |
|---|---|
name |
Interface name (required) |
type |
dhcp, static, physical, bond, bridge, vlan |
address |
IP address (for static) |
prefix |
CIDR prefix (for static, default: 24) |
netmask |
Dotted netmask (for static v1, default: 255.255.255.0) |
gateway |
Default gateway (for static) |
dns |
DNS servers (interface-level) |
domain |
Search domain (interface-level) |
Type behavior:
dhcp→ v1:subnets: [{type: dhcp}]; v2:dhcp4: truestatic→ v1:subnets: [{type: static, address, netmask, gateway}]; v2:addresses,gateway4physical/bond/bridge/vlan→ passthrough as-is
All other fields are copied verbatim.
Works on all distros.
DHCP:
"params": {
"network_version": 1,
"interfaces": [
{
"name": "ens18",
"type": "dhcp",
"post-up": "ip route del default dev ens18 || true"
}
]
}Static:
"params": {
"network_version": 1,
"interfaces": [
{
"name": "ens18",
"type": "static",
"address": "10.0.0.10",
"netmask": "255.255.255.0",
"gateway": "10.0.0.1"
}
]
}Bond:
"params": {
"network_version": 1,
"interfaces": [
{"name": "eth0", "type": "physical"},
{"name": "eth1", "type": "physical"},
{
"name": "bond0",
"type": "bond",
"bond_interfaces": ["eth0", "eth1"],
"params": {"mode": "active-backup"},
"subnets": [
{"type": "static", "address": "10.0.0.10", "netmask": "255.255.255.0"}
]
}
]
}Top-level routes:
"params": {
"network_version": 1,
"interfaces": [
{"name": "ens18", "type": "static", "address": "10.0.0.10"}
],
"routes": [
{"network": "0.0.0.0", "netmask": "0.0.0.0", "gateway": "10.0.0.1", "metric": 100}
]
}Raw passthrough:
"params": {
"network_config": {
"version": 1,
"config": [
{"type": "physical", "name": "ens18", "subnets": [{"type": "dhcp"}]},
{"type": "route", "network": "10.0.0.0", "netmask": "255.0.0.0", "gateway": "10.0.0.254"}
]
}
}Nameserver:
- Generated only if
dnsis explicitly set in params. - Suppress with
"no_auto_nameserver": true.
"params": {
"dns": "8.8.8.8,1.1.1.1",
"domain": "example.com",
"interfaces": [...]
}Extra config entries:
"params": {
"interfaces": [...],
"extra_config_entries": [
{"type": "route", "network": "192.168.100.0", "netmask": "255.255.255.0", "gateway": "10.0.0.254"}
]
}Full passthrough to Netplan. Any valid Netplan field works.
DHCP with overrides:
"params": {
"network_version": 2,
"interfaces": [
{
"name": "ens18",
"type": "dhcp",
"dhcp4-overrides": {"use-routes": false}
}
]
}Policy routing (multi-WAN):
"params": {
"network_version": 2,
"interfaces": [
{
"name": "ens18",
"type": "dhcp",
"routes": [
{"to": "0.0.0.0/0", "via": "192.168.10.1", "table": 200}
],
"routing-policy": [
{"from": "192.168.10.11", "table": 200}
]
}
]
}Advanced features:
"params": {
"network_version": 2,
"interfaces": [
{
"name": "ens18",
"type": "dhcp",
"mtu": 9000,
"wakeonlan": true,
"nameservers": {
"addresses": ["8.8.8.8", "2001:4860:4860::8888"]
}
}
]
}Important: Netplan fields must use correct types. dhcp4-overrides is an object:
// Correct
"dhcp4-overrides": {"use-routes": false}
// Wrong
"dhcp4-overrides": [{"use-routes": false}]To create a user, set ciuser in params:
{
"preset": "ubuntu-web",
"profile": "ubuntu",
"ids": ["default"],
"params": {
"hostname": "web-01",
"ciuser": "admin",
"password": "changeme123",
"ssh_pwauth": false
}
}Generated user-data:
users:
- name: admin
groups: sudo
shell: /bin/bash
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
ssh_authorized_keys: ["ssh-rsa AAAA..."]
plain_text_passwd: changeme123
lock_passwd: false
ssh_pwauth: falseWithout ciuser — no users block is generated. You control users entirely through scripts.
python3 build.py [OPTIONS]| Option | Description |
|---|---|
-a, --all |
Build all presets from config |
--config-json JSON |
Pass config as JSON string (highest priority) |
-c, --config FILE |
Main config file (default: main.json) |
--no-config |
Ignore main.json even if it exists |
--conf-d DIR |
Config directory to merge (default: conf.d) |
--conf-d-build PATH |
Build only from this conf.d file or directory |
| Option | Description |
|---|---|
--preset LIST |
Build specific preset(s), comma-separated |
--profile LIST |
Filter by profile, comma-separated |
--ids LIST |
Override IDs, comma-separated |
| Option | Type | Description |
|---|---|---|
--profile PROFILE |
string | Profile subdirectory in samples/ |
--ids LIST |
string | Script IDs to include, comma-separated |
--hostname HOST |
string | System hostname |
--ip IP |
string | IP with CIDR or dhcp |
--gateway IP |
string | Default gateway |
--dns SERVERS |
string | DNS servers, comma-separated |
--domain DOMAIN |
string | DNS search domain |
--interface IFACE |
string | Interface name (default: eth0) |
--ciuser USER |
string | Create user with sudo access |
--password PASS |
string | Plain-text password for ciuser |
--ssh-key PATH |
string | SSH public key path |
--ssh-pwauth BOOL |
bool | Enable SSH password auth (true/false) |
--disable-root BOOL |
bool | Disable root login (true/false) |
--packages LIST |
string | Packages to install, comma-separated |
--timezone TZ |
string | System timezone |
--growpart |
flag | Auto-expand root partition |
--package-update |
flag | Run package update |
--package-upgrade |
flag | Run package upgrade |
--network-version VER |
int | Network version: 1 or 2 |
--interfaces JSON |
string | Network interfaces as JSON string |
--script PATH |
path | Additional script (priority 99) |
--include PATH |
path | Include file to copy into VM |
--include-dest PATH |
path | Destination for --include |
--ignore-missing |
flag | Skip build if IDs not found |
| Option | Description |
|---|---|
--output-dir DIR |
Override output directory |
--no-iso |
Generate files only, skip ISO |
--no-clean |
Skip auto-clean of output dir |
--no-postbuild |
Skip postbuild scripts |
--postbuild SCRIPT |
Run postbuild script after ISO creation |
--dry-run |
Preview without generating |
-l, --list |
List available presets and exit |
-v, --verbose |
Verbose output |
-q, --quiet |
Quiet output (errors and summary only) |
# Show help (default, no args)
python3 build.py
# Build all presets from main.json + conf.d/
python3 build.py -a
# Pass config as JSON string (no file needed)
python3 build.py --config-json '{"builds":[{"preset":"web","profile":"ubuntu","ids":["default"],"params":{"hostname":"web1"}}]}'
# Ignore existing main.json, force inline mode
python3 build.py --no-config --profile ubuntu --ids default --hostname test
# Build from a specific conf.d directory only
python3 build.py -a --conf-d-build conf.d/production
# Build from a single conf.d file
python3 build.py -a --conf-d-build conf.d/routers.json# Build specific preset
python3 build.py --preset ubuntu-router
# Build multiple presets at once
python3 build.py --preset ubuntu-router,debian-db
# Build only Ubuntu and Debian presets
python3 build.py -a --profile ubuntu,debian
# Override IDs for all matching builds
python3 build.py -a --ids default,router
# Combine filters
python3 build.py -a --profile ubuntu --preset ubuntu-router,ubuntu-web
# Preview what would be built
python3 build.py -a --dry-run
# List all presets without building
python3 build.py --list# Minimal inline
python3 build.py --profile ubuntu --ids default --hostname myvm --ip 10.0.0.10/24 --gateway 10.0.0.1
# DHCP
python3 build.py --profile ubuntu --ids default --hostname myvm --ip dhcp
# With user and password
python3 build.py --profile ubuntu --ids default --hostname myvm --ciuser admin --password changeme123 --ip 10.0.0.10/24 --gateway 10.0.0.1
# Full inline with packages and custom script
python3 build.py --no-config --profile ubuntu --ids default --hostname router --ip dhcp --ciuser admin --password changeme --packages nginx,iptables-persistent --timezone Europe/Moscow --growpart --package-update --script ./custom-setup.sh --include ./motd --include-dest /etc/update-motd.d/99-custom
# Multiple interfaces via JSON
python3 build.py --profile ubuntu --ids default --hostname router --interfaces '[{"name":"ens18","type":"dhcp"},{"name":"ens19","type":"static","address":"192.168.50.10","netmask":"255.255.255.0"}]'
# Multiple custom scripts
python3 build.py --no-config --profile ubuntu --ids default --hostname test --script ./01-base.sh --script ./10-network.sh --script ./50-app.sh# Verbose: show merged params, resolved scripts/includes
python3 build.py -a -v
# Quiet: only errors, [OK], and summary
python3 build.py -a -q
# Files only, no ISO
python3 build.py -a --no-iso
# Skip auto-clean
python3 build.py -a --no-clean
# Skip postbuild even if configured
python3 build.py -a --no-postbuild
# Custom postbuild
python3 build.py -a --postbuild postbuild/deploy-to-proxmox.shPostbuild scripts run after ISO creation with environment variables. Priority: CLI > build > global.
Global postbuild:
{
"global": {
"postbuild": "postbuild/deploy-all.sh"
}
}Per-build postbuild:
{
"preset": "ubuntu-router",
"postbuild": "postbuild/deploy-router.sh"
}CLI postbuild:
python3 build.py -a --postbuild postbuild/deploy-to-proxmox.shDisable postbuild:
python3 build.py -a --no-postbuildPostbuild script example:
#!/bin/bash
# postbuild/deploy-to-proxmox.sh
echo "Deploying $PRESET_NAME ($PROFILE_NAME)"
echo "ISO: $ISO_PATH"
# scp "$ISO_PATH" root@proxmox:/var/lib/vz/template/iso/
# qm create 9000 --name "$PRESET_NAME" --memory 2048 --net0 virtio,bridge=vmbr0
# qm set 9000 --ide2 local:iso/$(basename "$ISO_PATH"),media=cdromEnvironment variables:
ISO_PATH— full path to generated ISOPRESET_NAME— build preset namePROFILE_NAME— profile from build definitionOUTPUT_DIR— output directory path
The generated ISO is a regular CD-ROM. Proxmox does not know it is cloud-init.
# Attach as IDE CD-ROM
qm set <vmid> --ide2 local:iso/{preset-name}.iso,media=cdrom
# Or SATA
qm set <vmid> --sata0 local:iso/{preset-name}.iso,media=cdromVia GUI: Hardware → Add → CD/DVD Drive → select ISO.
Inside the VM, cloud-init detects the cidata label and processes user-data, meta-data, and network-config.
cat /var/log/cloud-init.log
cat /var/log/cloud-init-output.log
cloud-init status --long
cloud-init single --name scripts_usermkdir /tmp/iso
mount -o loop {preset-name}.iso /tmp/iso
ls /tmp/iso/
cat /tmp/iso/user-data
cat /tmp/iso/network-config
ls /tmp/iso/scripts/bash /opt/provision/01-base.sh
bash /opt/provision/10-network.shnetplan generate
netplan applyScripts fail silently: Check set -e wrapping in user-data. The builder wraps each script automatically.
Network restart drops SSH: Use nohup or at for network restarts in scripts.
Packages not found: Ensure apt update succeeds before apt install. Use apt-get in scripts.
SSH key not applied: Ensure ciuser is set and default_ssh_key points to an existing file.
Include checksum mismatch: Include files are base64-encoded in user-data to ensure exact byte reproduction. If checksums differ, verify the source file in samples/{profile}/include/.