A complete, hands-off pipeline for provisioning Debian 13 (Trixie) VMs on a Proxmox VE host using Ansible and a custom preseed system. From zero to a fully configured, SSH-ready VM — no clicking, no console interaction, no manual steps.
Four playbooks. One command each. Run them in order and walk away.
build-debian-preseed-iso.yml Remaster the Debian netinst ISO for unattended install
create-vm-from-iso-proxmox.yml Create the VM shell on Proxmox via API
auto-install-debian.yml Boot, install Debian, discover IP, verify SSH
setup-debian-base.yml Install base packages, create users, harden SSH
The end result is a Debian 13.4 VM with:
- A locked-down
ansibleautomation user (SSH key only, NOPASSWD sudo) - An interactive end-user account (configurable username, sudo group, SSH key)
- Base tool set:
htop,btop,vim,curl,wget,git,tmux,net-toolsand more - SSH password auth disabled — key-only login
qemu-guest-agentrunning for Proxmox integration
| Requirement | Detail |
|---|---|
| Proxmox VE node | API token with VM.Allocate, VM.Config.*, Datastore.AllocateSpace |
| Debian 13 netinst ISO | debian-13.1.0-amd64-netinst.iso already in the Proxmox ISO store |
| Ansible control node | Ansible 2.15+, community.proxmox collection installed |
| SSH key pair | ~/.ssh/id_rsa (private) and ~/.ssh/id_rsa.pub (public) on the control node |
proxmox-ve-vms-ansible/
build-debian-preseed-iso.yml Remaster Debian netinst ISO with preseed
create-vm-from-iso-proxmox.yml Create/delete VM shells via Proxmox API
auto-install-debian.yml Boot + wait for install + discover IP
setup-debian-base.yml Post-install base config and user creation
fetch-iso.yml Download a Debian ISO to Proxmox ISO store
preseed/
debian-preseed.cfg.j2 Jinja2 preseed template (fully unattended d-i)
group_vars/all/
main.yml Proxmox API credentials (Ansible Vault encrypted)
preseed_vars.yml All preseed + install variables
vms.yml VM definitions (specs, ISO, preseed flags)
inventory/
hosts.ini proxmox-bms group + auto-populated new-debian-vms
If a VM already exists from a previous run, this removes it along with all its storage volumes and cleans up the inventory entry automatically.
ansible-playbook -i inventory/hosts.ini \
create-vm-from-iso-proxmox.yml --tags "removeVMs"TASK [Stop VMs before removal]
ok: [localhost] => (item=ansible-debian-01)
TASK [Removing VMs and all associated disks]
ok: [localhost] => (item=ansible-debian-01)
TASK [Remove VMs from [new-debian-vms] inventory block]
ok: [localhost] => (item=ansible-debian-01)
PLAY RECAP
localhost : ok=4 changed=0 unreachable=0 failed=0 skipped=0
The removeVMs tag is tagged never so it only runs when explicitly requested — it will
never fire accidentally during a normal run.
The build-debian-preseed-iso.yml playbook SSHs into the Proxmox node and runs entirely
there. It:
- Extracts the stock Debian 13 netinst ISO into a temp working directory using
xorriso - Renders the Jinja2 preseed template with Ansible variables and injects
preseed.cfginto the ISO root - Patches the GRUB config (EFI boot) — adds
auto=true priority=critical file=/cdrom/preseed.cfgto everylinuxline, setstimeout=1, setsdefault=0 - Patches the ISOLINUX config (BIOS boot) — same preseed parameters on the
appendline, sets theinstalllabel asmenu default - Strips the
spkgtk.cfgspeech synthesis auto-timeout (which would hijack the BIOS boot menu after 30 seconds) - Repacks the whole thing as a bootable BIOS+EFI hybrid ISO using
xorriso
ansible-playbook -i inventory/hosts.ini build-debian-preseed-iso.ymlTASK [Install ISO remaster tools (xorriso, binutils)]
ok: [proxmox-vm-bm-machine]
TASK [Extract ISO content into working directory]
changed: [proxmox-vm-bm-machine]
TASK [Render and inject preseed.cfg into extracted ISO root]
changed: [proxmox-vm-bm-machine]
TASK [Patch GRUB - add preseed auto-install params to linux boot lines]
changed: [proxmox-vm-bm-machine]
TASK [Patch GRUB - set default to first non-graphical install entry]
changed: [proxmox-vm-bm-machine]
TASK [Patch ISOLINUX txt.cfg - add preseed params to append lines]
changed: [proxmox-vm-bm-machine]
TASK [Patch spkgtk.cfg - remove speech synthesis auto-timeout override]
changed: [proxmox-vm-bm-machine] => (item=^timeout\s+)
changed: [proxmox-vm-bm-machine] => (item=^ontimeout\s+)
changed: [proxmox-vm-bm-machine] => (item=^menu\s+autoboot\s+)
TASK [Patch txt.cfg - mark 'install' as default ISOLINUX menu entry]
changed: [proxmox-vm-bm-machine]
TASK [Repack ISO with BIOS + EFI boot support]
changed: [proxmox-vm-bm-machine]
TASK [Print output ISO info]
ok: [proxmox-vm-bm-machine] => {
"msg": [
"Preseed ISO built successfully.",
"Output: /var/lib/vz/template/iso/debian-13-amd64-preseed.iso",
"Size: 969.0 MiB"
]
}
PLAY RECAP
proxmox-vm-bm-machine : ok=24 changed=13 unreachable=0 failed=0 skipped=3
create-vm-from-iso-proxmox.yml talks to the Proxmox REST API from localhost (no SSH to
the node required). It creates the VM shell, provisions the disk on local-lvm, mounts
the preseed ISO as a CD-ROM on ide2, and sets the boot order to ISO-first.
All VM specs live in group_vars/all/vms.yml:
- name: "ansible-debian-01"
vmid: 510
boot: "order=ide2;scsi0;net0"
memory: 2048
cores: 2
disk_size_gb: 20
storage: "local-lvm"
iso_file: "debian-13-amd64-preseed.iso"
preseed_install: trueansible-playbook -i inventory/hosts.ini \
create-vm-from-iso-proxmox.yml --tags "createVMs,createDisks,mountIso,bootOrder"TASK [Create empty VM shells]
changed: [localhost] => (item=ansible-debian-01)
TASK [Create and or update system disks (scsi0)]
changed: [localhost] => (item=ansible-debian-01)
TASK [Mount ISO as CD-ROM (ide2)]
changed: [localhost] => (item=ansible-debian-01)
TASK [Set boot order from vms.yml (or default)]
changed: [localhost] => (item=ansible-debian-01)
PLAY RECAP
localhost : ok=5 changed=4 unreachable=0 failed=0 skipped=0
auto-install-debian.yml runs three plays entirely from localhost, talking to the Proxmox
API throughout. No SSH connection to the VM is needed until the very end.
PLAY 1 — Boot Starts the VM via the Proxmox API. The VM immediately boots from the preseed ISO.
PLAY 2 — Install and discover IP
The preseed installer runs fully unattended. At the end of late_command, after injecting
the SSH key and configuring sudo, the script calls poweroff -f — halting the VM
immediately without showing the d-i "Installation complete" dialog.
The playbook polls GET /api2/json/nodes/{node}/qemu/{vmid}/status/current every 30
seconds until status == stopped. When it detects the halt it:
- Ejects the ISO from
ide2via the Proxmox disk API - Sets boot order to
scsi0;net0(disk-first, no more ISO boot) - Powers the VM back on
- Uses the Proxmox guest exec API to run
hostname -Iinside the VM and retrieve its IP
PLAY 3 — Verify and configure inventory
Confirms the ansible user can SSH and run sudo id. Writes the VM's entry into
inventory/hosts.ini under [new-debian-vms] automatically using blockinfile — so
setup-debian-base.yml can run immediately with no manual edits.
ansible-playbook -i inventory/hosts.ini auto-install-debian.ymlPLAY [Boot VMs for preseed Debian installation]
TASK [Start VMs for preseed installation]
changed: [localhost] => (item=ansible-debian-01)
TASK [Print booted VMs]
ok: [localhost] => {
"msg": "Started VM: ansible-debian-01 (vmid=510) - IP will be auto-discovered via guest agent"
}
PLAY [Discover VM IPs via guest agent and wait for SSH]
TASK [Wait for install to complete - VM halts when done (up to 40 min)]
FAILED - RETRYING: (80 retries left).
FAILED - RETRYING: (79 retries left).
...
ok: [localhost] => (item=ansible-debian-01 (vmid=510))
TASK [Eject install ISO from ide2 (VM is halted - safe to remove)]
changed: [localhost] => (item=ansible-debian-01)
TASK [Set boot order to disk-first before powering on]
changed: [localhost] => (item=ansible-debian-01)
TASK [Power on VMs to boot from installed disk]
changed: [localhost] => (item=ansible-debian-01)
TASK [Start guest exec - hostname -I (via Proxmox agent API)]
ok: [localhost] => (item=ansible-debian-01 (vmid=510))
TASK [Fetch guest exec result - hostname -I]
ok: [localhost] => (item=ansible-debian-01)
TASK [Print discovered IPs]
ok: [localhost] => {
"msg": "ansible-debian-01: IP = 192.168.0.106"
}
TASK [Wait for SSH to respond on each VM]
ok: [localhost] => (item=ansible-debian-01 (192.168.0.106))
PLAY [Verify ansible SSH access and eject install ISO]
TASK [Verify SSH + sudo for ansible user on each installed VM]
ok: [localhost] => (item=ansible-debian-01)
TASK [Print SSH verification results]
ok: [localhost] => {
"msg": "ansible user SSH + sudo OK on ansible-debian-01 (192.168.0.106)"
}
TASK [Add discovered VMs to [new-debian-vms] in inventory]
changed: [localhost] => (item=ansible-debian-01)
TASK [Print completion summary]
ok: [localhost] => {
"msg": [
"Debian installation complete for: ansible-debian-01",
" IP: 192.168.0.106",
" User: ansible",
" SSH key: ~/.ssh/id_rsa.pub",
"inventory/hosts.ini has been updated automatically.",
"Next step: ansible-playbook -i inventory/hosts.ini setup-debian-base.yml"
]
}
PLAY RECAP
localhost : ok=25 changed=8 unreachable=0 failed=0 skipped=2
setup-debian-base.yml connects to the newly installed VM as the ansible user over SSH
and handles the post-install baseline:
- Runs
apt safe-upgrade - Installs:
htop,btop,vim,curl,wget,git,tmux,net-tools,bash-completion,unzip,jq,qemu-guest-agent - Sets
vimas the default system editor - Hardens SSH:
PasswordAuthentication no,ChallengeResponseAuthentication no - Confirms
ansibleuser NOPASSWD sudoers entry - Creates the end-user account (configurable in
preseed_vars.yml) with sudo access and the same SSH public key
ansible-playbook -i inventory/hosts.ini setup-debian-base.ymlTASK [Gathering Facts]
ok: [ansible-debian-01]
TASK [Update APT package index]
ok: [ansible-debian-01]
TASK [Install base tool set]
changed: [ansible-debian-01]
TASK [Set vim as default editor (update-alternatives)]
changed: [ansible-debian-01]
TASK [Disable SSH password authentication]
changed: [ansible-debian-01]
TASK [Create end-user account]
changed: [ansible-debian-01]
TASK [Set SSH authorized key for end-user]
changed: [ansible-debian-01]
TASK [Print base setup summary]
ok: [ansible-debian-01] => {
"msg": [
"Base setup complete on: ansible-debian-01",
" Hostname: debian-vm",
" OS: Debian 13.4",
" Kernel: 6.12.74+deb13+1-amd64",
" SSH: password auth OFF, key-only",
" Ansible user: ansible (NOPASSWD sudo, automation only)",
" End-user: cartman (groups: sudo)"
]
}
PLAY RECAP
ansible-debian-01 : ok=14 changed=8 unreachable=0 failed=0 skipped=0
Two accounts are created across the pipeline:
| Account | Created by | Password | Auth method | Purpose |
|---|---|---|---|---|
ansible |
preseed late_command |
locked (!) |
SSH key only, NOPASSWD sudo | Ansible automation — never log in as this user |
cartman (configurable) |
setup-debian-base.yml |
SHA-512 hash via Ansible Vault | SSH key + sudo password | Interactive day-to-day use |
To configure the end-user, set these in group_vars/all/preseed_vars.yml:
vm_enduser_name: "cartman"
vm_enduser_groups: "sudo"
vm_enduser_shell: "/bin/bash"
vm_enduser_ssh_pub_key_file: "~/.ssh/id_rsa.pub"To set a sudo password, generate a SHA-512 hash and vault-encrypt it:
openssl passwd -6 'yourpassword'
ansible-vault encrypt_string 'the-hash' --name 'vm_enduser_password_hash'Add the vault output to group_vars/all/main.yml.
preseed/debian-preseed.cfg.j2 is a Jinja2 template rendered by Ansible at ISO build
time. Every variable comes from preseed_vars.yml — nothing is hardcoded in the template.
Key sections:
- Locale / keymap / timezone —
preseed_locale,preseed_keymap,preseed_timezone - Networking — DHCP by default; set
preseed_ipper-VM invms.ymlfor a static IP - Partitioning — single root partition, atomic recipe, no LVM
- Package selection —
openssh-server sudo qemu-guest-agent curl wget vim late_command— runs on the installer's host OS (not inside the target):- Creates
/home/ansible/.ssh/authorized_keyswith the public key - Sets permissions:
700on.ssh,600onauthorized_keys - Writes
/etc/sudoers.d/ansiblewithNOPASSWD:ALL - Enables
qemu-guest-agentviasystemctl - Calls
poweroff -f— halts immediately, triggering the Ansible poll to proceed
- Creates
No static IP or DHCP reservation required. After the VM powers off post-install, the playbook:
- Detects
status == stoppedviaGET /api2/json/nodes/{node}/qemu/{vmid}/status/current - Ejects the ISO and sets disk-first boot order
- Powers the VM on
- Waits 30 seconds for the OS to boot
- Calls
POST /api2/json/nodes/{node}/qemu/{vmid}/agent/execwith["hostname", "-I"] - Retrieves the output via
GET .../agent/exec-status?pid=... - Parses the first IPv4 from the response and stores it in
discovered_ips
The discovered IP is written to inventory/hosts.ini under [new-debian-vms]
automatically — setup-debian-base.yml can run immediately afterward with no edits.
| Variable | File | Default | Description |
|---|---|---|---|
preseed_iso_src_file |
preseed_vars.yml |
debian-13.1.0-amd64-netinst.iso |
Source netinst ISO filename |
preseed_iso_dest_file |
preseed_vars.yml |
debian-13-amd64-preseed.iso |
Output preseed ISO filename |
preseed_debian_suite |
preseed_vars.yml |
trixie |
Debian suite for APT mirror |
preseed_ansible_user |
preseed_vars.yml |
ansible |
Automation user created during install |
preseed_ssh_pub_key_file |
preseed_vars.yml |
~/.ssh/id_rsa.pub |
SSH public key injected for ansible user |
preseed_ssh_priv_key_file |
preseed_vars.yml |
~/.ssh/id_rsa |
Private key path written to inventory |
preseed_ssh_wait_timeout |
preseed_vars.yml |
2400 |
Max seconds to wait for install (40 min) |
preseed_ssh_wait_delay |
preseed_vars.yml |
30 |
Polling interval in seconds |
preseed_boot_wait_seconds |
preseed_vars.yml |
30 |
Seconds after power-on before IP query |
debian_base_packages |
preseed_vars.yml |
htop btop vim ... |
Packages installed by setup-debian-base.yml |
vm_enduser_name |
preseed_vars.yml |
"" (disabled) |
End-user login account name |
vm_enduser_groups |
preseed_vars.yml |
sudo |
Groups for the end-user account |
vm_enduser_password_hash |
main.yml (vault) |
! (locked) |
SHA-512 password hash for end-user sudo |
This pipeline takes a bare Proxmox node and produces one or more fully configured Debian 13
VMs — with an ansible SSH user, passwordless sudo, and base packages — by running four
playbooks in sequence.
build-debian-preseed-iso.yml Build a custom unattended install ISO
create-vm-from-iso-proxmox.yml Create the VM shell on Proxmox
auto-install-debian.yml Boot, install, discover IP, verify SSH
setup-debian-base.yml Install base packages and harden SSH
| Requirement | Detail |
|---|---|
| Proxmox VE node | API token with VM.Allocate, VM.Config.*, Datastore.AllocateSpace |
| Debian 13 netinst ISO | debian-13.1.0-amd64-netinst.iso in the Proxmox ISO store |
| Ansible control node | Ansible 2.15+, community.proxmox collection |
| SSH key pair | Private key at ~/.ssh/id_rsa, public key injected into VMs by preseed |
proxmox-ve-vms-ansible/
build-debian-preseed-iso.yml Remaster Debian netinst ISO with preseed
create-vm-from-iso-proxmox.yml Create VM shells via Proxmox API
auto-install-debian.yml Boot + wait for install + discover IP
setup-debian-base.yml Post-install base config
fetch-iso.yml Download a Debian ISO to Proxmox ISO store
preseed/
debian-preseed.cfg.j2 Jinja2 preseed template (fully unattended d-i)
group_vars/all/
main.yml Proxmox API credentials (Ansible Vault)
preseed_vars.yml All preseed + install variables
vms.yml VM definitions (specs, ISO, preseed flags)
inventory/
hosts.ini proxmox-bms + new-debian-vms groups
The build-debian-preseed-iso.yml playbook runs directly on the Proxmox node over SSH.
It extracts the upstream Debian netinst ISO, injects a rendered preseed.cfg, patches the
GRUB and ISOLINUX boot menus for fully unattended boot, then repacks it as a hybrid
BIOS+EFI ISO.
Command:
ansible-playbook -i inventory/hosts.ini build-debian-preseed-iso.ymlOutput:
kenny@Nebula:~/git$ ansible-playbook -i /home/kenny/git/proxmox-ve-vms-ansible/inventory/hosts.ini /home/kenny/git/proxmox-ve-vms-ansible/build-debian-preseed-iso.yml 2>&1 | tail -15
changed: [proxmox-vm-bm-machine]
TASK [Print output ISO info] ***************************************************
ok: [proxmox-vm-bm-machine] => {
"msg": [
"Preseed ISO built successfully.",
"Output: /var/lib/vz/template/iso/debian-13-amd64-preseed.iso",
"Size: 969.0 MiB",
"Reference this ISO in vms.yml -> iso_file: debian-13-amd64-preseed.iso"
]
}
PLAY RECAP *********************************************************************
proxmox-vm-bm-machine : ok=24 changed=13 unreachable=0 failed=0 skipped=3 rescued=0 ignored=0
The create-vm-from-iso-proxmox.yml playbook talks to the Proxmox REST API from localhost
to create the VM shell (CPU, RAM, disk, net), mount the preseed ISO as a CD-ROM, and set
the boot order to ISO-first.
Command:
ansible-playbook -i inventory/hosts.ini create-vm-from-iso-proxmox.yml \
--tags "createVMs,createDisks,mountIso,bootOrder"Output:
kenny@Nebula:~/git$ ansible-playbook -i /home/kenny/git/proxmox-ve-vms-ansible/inventory/hosts.ini /home/kenny/git/proxmox-ve-vms-ansible/create-vm-from-iso-proxmox.yml --tags "createVMs,createDisks,mountIso,bootOrder" 2>&1
[WARNING]: Invalid characters were found in group names but not replaced, use -vvvv to see details
PLAY [Manage VMs via Proxmox API] **********************************************
TASK [Normalize/merge defaults into each VM item] ******************************
ok: [localhost]
TASK [Create empty VM shells] **************************************************
changed: [localhost] => (item=ansible-debian-01)
TASK [Create and or update system disks (scsi0)] *******************************
changed: [localhost] => (item=ansible-debian-01)
TASK [Mount ISO as CD-ROM (ide2)] **********************************************
changed: [localhost] => (item=ansible-debian-01)
TASK [Set boot order from vms.yml (or default)] ********************************
changed: [localhost] => (item=ansible-debian-01)
PLAY RECAP *********************************************************************
localhost : ok=5 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
The auto-install-debian.yml playbook handles the entire install lifecycle:
- PLAY 1 — Boot the VM via Proxmox API
- PLAY 2 — Wait for the VM to halt after install (poweroff triggered by
late_command), eject the ISO, set boot order to disk-first, power on, then poll the qemu-guest-agent API to discover the VM's IP address automatically - PLAY 3 — Verify
ansibleuser SSH + sudo access, updateinventory/hosts.iniautomatically
Command:
ansible-playbook -i inventory/hosts.ini auto-install-debian.ymlOutput:
[PASTE OUTPUT HERE]
The setup-debian-base.yml playbook connects to the new VM over SSH using the ansible
user and:
- Runs
apt safe-upgrade - Installs base packages:
htop btop vim curl wget git tmux net-tools bash-completion unzip jq qemu-guest-agent - Sets
vimas the default editor - Disables SSH password authentication
- Confirms the sudoers file for the
ansibleuser
Command:
ansible-playbook -i inventory/hosts.ini setup-debian-base.ymlOutput:
[PASTE OUTPUT HERE]
The preseed/debian-preseed.cfg.j2 Jinja2 template is rendered by Ansible at ISO build
time. Key sections:
- Locale / keymap / timezone — configured from
preseed_vars.yml - Networking — DHCP by default; static IP supported via
preseed_ipper-VM variable - Partitioning — single root partition, atomic recipe, full disk LVM removed
- Package selection —
openssh-server sudo qemu-guest-agent curl wget vim late_command— injects the SSH public key into/home/ansible/.ssh/authorized_keys, creates/etc/sudoers.d/ansiblewithNOPASSWD:ALL, enables qemu-guest-agent, then callspoweroff -fto immediately halt the system. This fires before the d-i "Installation complete" dialog can appear, giving the playbook a clean poweroff signal to detect.
The VM is defined in vms.yml with preseed_install: true and no ip_address field.
PLAY 2 of auto-install-debian.yml polls the Proxmox REST API endpoint:
GET /api2/json/nodes/{node}/qemu/{vmid}/status/current
until status == stopped (the poweroff -f in late_command triggers this). It then:
- Ejects the ISO from
ide2 - Sets boot order to
scsi0;net0 - Powers the VM on
- Polls the guest agent endpoint:
until the agent reports a non-loopback IPv4 address.
GET /api2/json/nodes/{node}/qemu/{vmid}/agent/network-interfaces
The discovered IP is stored in inventory/hosts.ini automatically under [new-debian-vms].
| Variable | File | Default | Description |
|---|---|---|---|
preseed_iso_src_file |
preseed_vars.yml |
debian-13.1.0-amd64-netinst.iso |
Source netinst ISO filename |
preseed_iso_dest_file |
preseed_vars.yml |
debian-13-amd64-preseed.iso |
Output preseed ISO filename |
preseed_debian_suite |
preseed_vars.yml |
trixie |
Debian suite for APT mirror |
preseed_ansible_user |
preseed_vars.yml |
ansible |
User created during install |
preseed_ssh_pub_key_file |
preseed_vars.yml |
~/.ssh/id_rsa.pub |
SSH public key injected for the ansible user |
preseed_ssh_wait_timeout |
preseed_vars.yml |
2400 |
Max seconds to wait for install to complete |
preseed_ssh_wait_delay |
preseed_vars.yml |
30 |
Polling interval in seconds |
preseed_boot_wait_seconds |
preseed_vars.yml |
30 |
Seconds to wait after VM power-on before polling guest agent |
debian_base_packages |
preseed_vars.yml |
htop btop vim ... |
Packages installed by setup-debian-base.yml |
Built and maintained by Thomas Mozdren — 2026