Automated Debian 13 VM Provisioning on Proxmox

Four playbooks. Zero manual steps. From bare node to configured VM.

← Back to main page  |  GitHub →

Overview

This pipeline takes a Proxmox VE node and a stock Debian 13 netinst ISO and produces a fully configured, SSH-ready VM — with user accounts, base packages, and hardened SSH — by running four Ansible playbooks in sequence. No clicking in the Proxmox web UI, no interactive installer prompts, no manual inventory edits.

build-debian-preseed-iso.yml     Remaster Debian ISO for unattended install
create-vm-from-iso-proxmox.yml   Create VM shell on Proxmox via API
auto-install-debian.yml         Boot, install, discover IP, verify SSH
setup-debian-base.yml           Packages, users, SSH hardening

The end result is a Debian 13.4 VM with a locked-down ansible automation user, a configurable interactive end-user account, a base tool set, and SSH password auth disabled.

Repository Layout

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)
    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

All VM specs — CPU, RAM, disk size, ISO file, whether to preseed — live in a single vms.yml file. Adding a new VM is one YAML block.

Prerequisites

Requirement Detail
Proxmox VE nodeAPI token with VM.Allocate, VM.Config.*, Datastore.AllocateSpace
Debian 13 netinst ISOdebian-13.1.0-amd64-netinst.iso already in the Proxmox ISO store
Ansible control nodeAnsible 2.15+, community.proxmox collection
SSH key pair~/.ssh/id_rsa and ~/.ssh/id_rsa.pub on the control node

The Pipeline

0 Clean Slate — Remove existing VM

create-vm-from-iso-proxmox.yml --tags removeVMs

Before starting fresh, this removes the old VM along with all its storage volumes and cleans up the inventory/hosts.ini entry automatically. The tag is marked never in the playbook so it only fires when explicitly requested — it will never run during a normal pipeline execution.

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

The delete goes through the raw Proxmox REST API with ?purge=1&destroy-unreferenced-disks=1 — the proxmox_kvm module's state: absent leaves storage volumes behind by default.

1 Build the Preseed ISO

build-debian-preseed-iso.yml

This playbook SSHs into the Proxmox node and runs entirely there. It remasters the stock Debian 13 netinst ISO into a custom version that installs Debian unattended without any human interaction.

What it does:

  • Extracts the ISO with xorriso into a temp working directory
  • Renders the Jinja2 preseed template with Ansible variables and injects preseed.cfg into the ISO root
  • Patches the GRUB config (EFI boot) — adds auto=true priority=critical file=/cdrom/preseed.cfg, sets timeout=1 and default=0
  • Patches the ISOLINUX config (BIOS boot) — same parameters, marks the plain text installer as menu default
  • Strips the spkgtk.cfg speech synthesis auto-timeout that would hijack the BIOS boot menu after 30 seconds
  • Repacks everything as a bootable BIOS+EFI hybrid ISO
ansible-playbook -i inventory/hosts.ini build-debian-preseed-iso.yml
TASK [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 [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

The resulting ISO is placed directly in the Proxmox ISO store at /var/lib/vz/template/iso/. The ISO only needs to be rebuilt when preseed variables change (e.g. SSH key rotation, locale, extra packages).

2 Create the VM on Proxmox

create-vm-from-iso-proxmox.yml --tags createVMs,createDisks,mountIso,bootOrder

Talks to the Proxmox REST API from localhost — no SSH to the Proxmox node required. Creates the VM shell, provisions a 20 GB disk on local-lvm, mounts the preseed ISO as a CD-ROM on ide2, and sets boot order to ISO-first.

VM specs are defined 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: true
ansible-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

3 Automated Installation

auto-install-debian.yml

This is the core of the pipeline. Three plays run entirely from localhost via the Proxmox API — no SSH to the VM until the very end of PLAY 3.

PLAY 1 — Boot

  • Start VM via Proxmox API
  • VM boots from the preseed ISO
  • d-i installer starts unattended

PLAY 2 — Install & Discover IP

  • Poll VM status every 30s
  • Detect halt when install done
  • Eject ISO, fix boot order
  • Power on, query hostname -I

PLAY 3 — Verify & Inventory

  • SSH + sudo check as ansible user
  • Write entry to hosts.ini
  • Clean up temp files
  • Print summary with IP

IP Auto-Discovery

  • POST guest exec: hostname -I
  • GET exec-status?pid=...
  • No static IP needed
  • No DHCP reservation needed

The preseed's late_command ends with poweroff -f — the VM halts immediately after SSH key injection and sudo config, before the d-i "Installation complete" dialog can appear. This gives the playbook a clean, detectable signal to act on.

ansible-playbook -i inventory/hosts.ini auto-install-debian.yml
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" } TASK [Wait for install to complete - VM halts when done (up to 40 min)] FAILED - RETRYING: (80 retries left). FAILED - RETRYING: (79 retries left). ... (polling every 30s while Debian installs) ... 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 [Print discovered IPs] ok: [localhost] => { "msg": "ansible-debian-01: IP = 192.168.0.106" } TASK [Verify SSH + sudo for ansible user on each installed VM] ok: [localhost] => (item=ansible-debian-01) 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", "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

4 Base Package Setup & User Creation

setup-debian-base.yml

Connects to the new VM as the ansible user over SSH and handles the post-install baseline. This playbook is fully idempotent — safe to re-run at any time.

  • Runs apt safe-upgrade
  • Installs: htop, btop, vim, curl, wget, git, tmux, net-tools, bash-completion, unzip, jq, qemu-guest-agent
  • Sets vim as the default system editor via update-alternatives
  • Hardens SSH: PasswordAuthentication no, ChallengeResponseAuthentication no
  • Confirms the ansible user NOPASSWD sudoers entry
  • Creates the end-user account with sudo group membership and SSH key
ansible-playbook -i inventory/hosts.ini setup-debian-base.yml
TASK [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

How the Preseed Works

preseed/debian-preseed.cfg.j2 is a Jinja2 template rendered by Ansible at ISO build time. Every value comes from preseed_vars.yml — nothing is hardcoded in the template itself.

Key sections

The poweroff -f at the end of late_command is what makes the whole pipeline reliable. It fires before the d-i "Installation complete / Press Continue to reboot" dialog, giving the playbook a deterministic signal instead of having to guess when the installer is done.

User Accounts

Two accounts are created across the pipeline with different purposes:

Account Created by Auth Purpose
ansible preseed late_command SSH key only, NOPASSWD sudo, locked password Ansible automation — never log in interactively
cartman (configurable) setup-debian-base.yml SSH key + sudo password Interactive day-to-day use

Configuring the end-user account

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 with openssl and vault-encrypt it:

openssl passwd -6 'yourpassword'
ansible-vault encrypt_string 'the-hash-output' --name 'vm_enduser_password_hash'

Add the vault block to group_vars/all/main.yml. Without it the account has a locked password — SSH key login only, which is fine if you don't need console access.

Variable Reference

Variable File Default Description
preseed_iso_src_filepreseed_vars.ymldebian-13.1.0-amd64-netinst.isoSource netinst ISO filename
preseed_iso_dest_filepreseed_vars.ymldebian-13-amd64-preseed.isoOutput preseed ISO filename
preseed_debian_suitepreseed_vars.ymltrixieDebian suite for APT mirror
preseed_ansible_userpreseed_vars.ymlansibleAutomation user created during install
preseed_ssh_pub_key_filepreseed_vars.yml~/.ssh/id_rsa.pubSSH public key injected for ansible user
preseed_ssh_priv_key_filepreseed_vars.yml~/.ssh/id_rsaPrivate key path written to inventory
preseed_ssh_wait_timeoutpreseed_vars.yml2400Max seconds to wait for install (40 min)
preseed_boot_wait_secondspreseed_vars.yml30Seconds after power-on before IP query
vm_enduser_namepreseed_vars.yml"" (disabled)End-user login account name
vm_enduser_groupspreseed_vars.ymlsudoGroups for the end-user account
vm_enduser_password_hashmain.yml (vault)! (locked)SHA-512 password hash for end-user sudo

Source Code

The full repository is available on GitHub: