Debian 13 unattended install: a preseeded netinst ISO for a Dell R730

Remaster the Debian 13 netinst ISO with xorriso so it installs unattended (partitions, user, SSH keys, packages), then test it in QEMU and write it to USB on macOS.

By the ailog editors · Published Oct 1, 2026 · 10 min read · How we work
In short
  • Don’t extract and rebuild the ISO. Add three files to the official netinst image with xorriso … -map … -boot_image any replay, and both its BIOS and UEFI boot paths are preserved.
  • Our preseed partitions the largest disk with LVM, creates a key-only SSH user with passwordless sudo, and installs a fixed package list. It refuses to run if no disk over about 2 TB is present.
  • Before touching hardware, we booted the ISO in a QEMU UEFI VM. It installed unattended, and the same run found four bugs in our post-install script.
  • Writing to USB on macOS needed a terminal with disk access. Afterwards we read the image back from the stick and compared SHA-256 hashes, and they matched.

We needed to install Debian 13 on a Dell PowerEdge R730 that would become a backup server. The install would be done once, by someone standing at the machine who isn’t a Linux person. So the goal was a USB stick with one obvious boot menu entry that does everything else on its own and is hard to misuse. This post walks through the files we changed, the preseed, the checks we ran, and the problems we hit on the real hardware. Hostnames, usernames and keys are replaced with placeholders.

Remastering the netinst ISO with xorriso

We started from the current netinst image, debian-13.7.0-amd64-netinst.iso, downloaded it, and checked it against Debian’s SHA512SUMS before doing anything else:

B=https://cdimage.debian.org/debian-cd/current/amd64/iso-cd
curl -fsSLO $B/SHA512SUMS
curl -fsSLO $B/debian-13.7.0-amd64-netinst.iso
grep ' debian-13.7.0-amd64-netinst.iso$' SHA512SUMS | shasum -a 512 -c -
brew install xorriso

The image has two boot menus. GRUB (/boot/grub/grub.cfg) is used when booting in UEFI mode, and ISOLINUX (/isolinux/txt.cfg) when booting in legacy BIOS mode. We pulled both out to edit:

xorriso -osirrox on -indev debian-13.7.0-amd64-netinst.iso \
  -extract /boot/grub/grub.cfg grub.cfg.orig \
  -extract /isolinux/txt.cfg   txt.cfg.orig

In grub.cfg we inserted a new first entry before the existing menuentry lines:

set default=0
set timeout=-1
menuentry --hotkey=v '>>> Unattended install (WIPES DISK) <<<' {
    set background_color=black
    linux    /install.amd/vmlinuz auto=true priority=critical preseed/file=/cdrom/preseed.cfg locale=en_US.UTF-8 keyboard-configuration/xkb-keymap=us netcfg/hostname=<hostname> --- quiet
    initrd   /install.amd/initrd.gz
}

set timeout=-1 is deliberate. The menu waits forever, so nothing is wiped until someone presses Enter. auto=true priority=critical tells the installer to ask only the questions the preseed doesn’t answer, and preseed/file=/cdrom/preseed.cfg points it at our file on the stick. Locale and keymap are also passed on the kernel command line. Those questions can come up before the preseed file is read, and this way they are answered either way. In txt.cfg we added the same append arguments as a new label marked menu default, so a BIOS-mode boot behaves the same way.

Then we built the new image. -boot_image any replay is the command the Debian wiki’s RepackBootableISO page recommends: it copies the original image’s boot setup (El Torito BIOS and UEFI images, MBR, GPT) without you having to spell out each option.

xorriso -indev debian-13.7.0-amd64-netinst.iso -outdev auto-install.iso \
  -map preseed.cfg /preseed.cfg \
  -map grub.cfg    /boot/grub/grub.cfg \
  -map txt.cfg     /isolinux/txt.cfg \
  -boot_image any replay

To check that the result would still boot both ways, we asked xorriso to report on its own output:

xorriso -indev auto-install.iso -report_el_torito plain | grep -iE 'boot img'
xorriso -indev auto-install.iso -report_system_area plain | grep -E 'MBR|GPT|isohybrid'

Ours listed two El Torito boot images (one BIOS, one UEFI) and System area summary: MBR isohybrid cyl-align-on GPT APM, which means it can be written straight to a USB stick. We also extracted /preseed.cfg back out of the new ISO and ran cmp against our copy, to confirm the file inside is the one we edited.

The preseed: disk, user, packages

The preseed is 73 lines. These are the parts that need explaining. The full key list is documented in the Debian installation guide’s appendix and the trixie example preseed.

A safety guard before partitioning. The R730 boots from a USB stick, and the only large disk it should see is the PERC RAID virtual disk. If someone forgets to create the RAID, the stick might be the only disk the installer finds. partman/early_command runs just before the partitioner, so we pick the largest disk there and stop if it’s too small:

d-i partman/early_command string \
  D=""; S=0; for d in $(list-devices disk); do n=$(cat /sys/block/${d#/dev/}/size); \
  if [ "$n" -gt "$S" ]; then S=$n; D=$d; fi; done; \
  if [ "$S" -lt 4000000000 ]; then echo "no RAID virtual disk >2TB" > /dev/console; exit 1; fi; \
  debconf-set partman-auto/disk "$D"; debconf-set grub-installer/bootdev "$D"

/sys/block/*/size is counted in 512-byte sectors, so 4,000,000,000 sectors is about 2 TB. The script also tells both the partitioner and GRUB which disk to use, instead of trusting device order. The installation guide warns that disk names depend on the order drivers load.

An LVM recipe with one large XFS volume.

d-i partman-auto/method string lvm
d-i partman-auto-lvm/new_vg_name string backup-vg
d-i partman-auto-lvm/guided_size string max
d-i partman-auto/expert_recipe string \
  srv :: \
    1024 1024 1024 free $iflabel{ gpt } $reusemethod{ } method{ efi } format{ } . \
    1024 1024 1024 ext4 $primary{ } method{ format } format{ } use_filesystem{ } filesystem{ ext4 } mountpoint{ /boot } . \
    102400 102400 102400 ext4 $lvmok{ } lv_name{ root } method{ format } format{ } use_filesystem{ } filesystem{ ext4 } mountpoint{ / } options/noatime{ noatime } . \
    512000 512000 512000 ext4 $lvmok{ } lv_name{ var } method{ format } format{ } use_filesystem{ } filesystem{ ext4 } mountpoint{ /var } options/noatime{ noatime } . \
    32768 32768 32768 linux-swap $lvmok{ } lv_name{ swap } method{ swap } format{ } . \
    51200 51200 51200 ext4 $lvmok{ } lv_name{ tmp } method{ format } format{ } use_filesystem{ } filesystem{ ext4 } mountpoint{ /tmp } options/noatime{ noatime } . \
    307200 307200 307200 ext4 $lvmok{ } lv_name{ home } method{ format } format{ } use_filesystem{ } filesystem{ ext4 } mountpoint{ /home } options/noatime{ noatime } . \
    1000000 5000 18000000 xfs $lvmok{ } lv_name{ data } method{ format } format{ } use_filesystem{ } filesystem{ xfs } mountpoint{ /data } options/noatime{ noatime } .
d-i partman-auto/choose_recipe select srv
d-i partman-partitioning/choose_label select gpt
d-i partman-efi/non_efi_system boolean true

Each line is min priority max in MB, followed by flags. Every volume is a fixed size except /data, which is at least 1 TB and at most 18 TB. That maximum was set for the eight-disk array we planned. When we ended up with fewer disks, the cap was larger than the space left, so /data took the entire volume group and left no free space for LVM snapshots. The disk side of this is covered in the RAID6 layout post.

The trixie example preseed describes partman-efi/non_efi_system as forcing UEFI booting, with BIOS compatibility lost on the installed system. That was fine for us, because the server was set to UEFI boot mode. We also set grub-installer/force-efi-extra-removable boolean true. According to the Debian UEFI wiki, this also installs GRUB to the removable-media path for firmware that ignores boot entries, and from trixie on the installer enables it by default when that path is empty.

A user that can only log in with a key.

d-i passwd/root-login boolean false
d-i passwd/make-user boolean true
d-i passwd/username string <admin-user>
d-i passwd/user-fullname string <admin-user>
d-i passwd/user-password-crypted password <your-crypt-hash>

The password exists only for the local console in an emergency. We generated a random one, stored it in a password manager, and put only its hash in the file (mkpasswd -m sha-512 produces one). Remember that anyone holding the stick can read the preseed.

A late command that locks down SSH. preseed/late_command runs while the new system is still mounted at /target, and in-target runs a command inside it:

d-i preseed/late_command string \
  mkdir -p /target/home/<admin-user>/.ssh; \
  echo '<your-ssh-public-key>' > /target/home/<admin-user>/.ssh/authorized_keys; \
  in-target chown -R <admin-user>:<admin-user> /home/<admin-user>/.ssh; \
  in-target chmod 700 /home/<admin-user>/.ssh; in-target chmod 600 /home/<admin-user>/.ssh/authorized_keys; \
  echo '<admin-user> ALL=(ALL) NOPASSWD:ALL' > /target/etc/sudoers.d/<admin-user>; \
  chmod 440 /target/etc/sudoers.d/<admin-user>; \
  printf 'PasswordAuthentication no\nPermitRootLogin no\nKbdInteractiveAuthentication no\n' > /target/etc/ssh/sshd_config.d/10-hardening.conf

Packages. tasksel installs standard, ssh-server. pkgsel/include adds sudo curl rsync smartmontools lvm2 xfsprogs vim htop tmux ethtool lm-sensors irqbalance chrony jq git ca-certificates gnupg nvme-cli, and pkgsel/upgrade select full-upgrade installs the latest updates during installation. We also enabled non-free-firmware and contrib in apt-setup, and set hw-detect/load_firmware to true, so the installer can load non-free firmware if the NIC or controller asks for it.

Testing in a VM before touching the server

The R730 was in another room and the stick was going to someone else. So we booted the ISO first in QEMU on an Apple Silicon Mac, emulating x86 with UEFI firmware and a sparse 3 TB virtual disk (big enough to pass the 2 TB guard):

qemu-img create -f qcow2 disk.qcow2 3T
cp /opt/homebrew/share/qemu/edk2-i386-vars.fd vars.fd
qemu-system-x86_64 -machine q35 -accel tcg,thread=multi -cpu max -smp 4 -m 4096 \
  -drive if=pflash,format=raw,readonly=on,file=/opt/homebrew/share/qemu/edk2-x86_64-code.fd \
  -drive if=pflash,format=raw,file=vars.fd \
  -drive file=disk.qcow2,if=virtio,format=qcow2 \
  -cdrom auto-install.iso -boot d \
  -netdev user,id=n0,hostfwd=tcp:127.0.0.1:2222-:22 -device virtio-net-pci,netdev=n0 \
  -display none -vnc 127.0.0.1:7 -monitor unix:mon.sock,server,nowait

We captured the screen through the monitor socket (screendump), saw our entry at the top of the menu waiting, and sent Enter (sendkey ret). Then a loop tried SSH on port 2222 with our key every 30 seconds. When it got in, we checked the result: Debian 13.7, the hostname we passed, firmware mode UEFI, sshd -T reporting passwordauthentication no and permitrootlogin no, passwordless sudo working, and the LVM layout as designed with noatime on every volume.

The VM run was worth more for the post-install script. Its first run exited with status 1, and fixing that turned up four problems:

  • Its summary assumed the disk was called sda, but in the VM it was vda. We changed it to loop over whatever disks exist.
  • cpupower frequency-set fails where CPU frequency scaling isn’t available. Prefixing the systemd ExecStart= with - makes the unit ignore that failure.
  • The XFS logbsize=256k option in fstab was not applied by a remount. The mounted options still showed logbsize=32k. The script now unmounts and mounts /data again if it’s empty or unused.
  • sysctl isn’t on a normal user’s PATH on Debian, so our verification command used /sbin/sysctl.

The second run exited with status 0. Two things couldn’t be tested in a VM: SMART monitoring, which needs real disks, and the RAID controller tool, which needs a controller. We checked both on the server.

Writing the USB stick on macOS and verifying it

Before writing anything, we tested the stick with f3write/f3read, which fill the free space with data and read it back. The result was Data OK: 28.84 GB, Data LOST: 0.00 Bytes, with an average sequential read of 88.96 MB/s. That rules out a fake-capacity stick.

The write script checks it’s pointed at the right device before using dd. It refuses to run unless diskutil reports a removable disk of the exact expected byte size:

diskutil info disk4 | grep -q 'Removable Media: *Removable' || exit 3
diskutil info disk4 | grep -q '31037849600 Bytes' || exit 4
diskutil unmountDisk force disk4
dd if=auto-install.iso of=/dev/rdisk4 bs=4m
sync
SZ=$(stat -f %z auto-install.iso)
dd if=/dev/rdisk4 bs=4m count=$(( (SZ + 4194303) / 4194304 )) 2>/dev/null \
  | head -c $SZ | shasum -a 256

Running it through an AppleScript admin prompt failed twice. The first time, the script couldn’t be executed from ~/Downloads. The second time, dd failed with /dev/rdisk4: Operation not permitted even as root. It worked with sudo in a terminal app that has disk access: 792,002,560 bytes in 42.57 seconds. The SHA-256 read back from the stick matched the ISO.

macOS then shows a dialog saying it can’t read the disk and offers to initialize it. Click Ignore or Eject, not Initialize. Initializing erases what you just wrote. diskutil list showing an Apple_partition_scheme is normal: the hybrid image includes an Apple partition map alongside its MBR and GPT.

What the real hardware added

  • Plug into the right NIC port. The installer got no DHCP address at first. This R730’s network daughter card is Intel X520/I350. Ports 1–2 are 10G SFP+ cages, which an RJ45 cable won’t fit, and ports 3–4 are 1G RJ45. The BIOS showed port 1 as Disconnected. Moving the cable to port 3 (eno3 in Debian) fixed it.
  • A tester pass doesn’t prove a good cable. A hand-made cable passed a continuity tester but linked at only 100 Mbps. The likely cause is a split pair: the colours were in the same order at both ends, but that order wasn’t T568B. We never re-terminated the cable to confirm it. A continuity tester only checks that pin 1 reaches pin 1, but gigabit needs all four twisted pairs to be correct.
  • Set the static IP in /etc/network/interfaces, then check /etc/resolv.conf. After we switched from DHCP to a static address and rebooted, /etc/resolv.conf had a # Generated by dhcpcd header and no nameserver lines, so apt failed with Temporary failure resolving. We rewrote the file by hand. The dns-nameservers line in /etc/network/interfaces only takes effect if a resolvconf program is installed, according to the Debian NetworkConfiguration wiki, and the same page warns that DHCP clients can overwrite resolv.conf. If you see that header, run pgrep -a dhcpcd to look for a leftover client, and either install resolvconf or manage the file yourself.

Everything else (RAID detection, partitioning, packages, the key-only login) worked on the first boot. After that, the post-install script exited with status 0 on the server. What that script tunes is in the RAID6 backup server post, and the server’s network isolation is in the RouterOS DMZ post.

Checklist for your own preseeded ISO

  • Verify the official ISO against SHA512SUMS before remastering.
  • Use -map plus -boot_image any replay, then confirm both El Torito images are still there.
  • Make the menu entry wait (timeout=-1) and make the preseed abort if the target disk looks wrong.
  • Never put a cleartext password in the preseed. Use a hash, and use keys for SSH.
  • Boot it in a UEFI VM, log in with the key, and run your post-install script there too.
  • After dd, read the stick back and compare hashes, and don’t let macOS initialize it.
Sources
  1. Debian GNU/Linux Installation Guide (trixie) — Contents of the preconfiguration file
  2. Debian trixie example preseed file
  3. Debian Wiki — RepackBootableISO
  4. Debian Wiki — UEFI (force-efi-extra-removable)
  5. Debian Wiki — NetworkConfiguration
  6. f3 — Fight Flash Fraud documentation

Related