← All guides

The Boot USB Stick · Part 2

Your Own Debian Live Image: Boot It and SSH Straight In

A Debian live image that boots from a USB stick and lets you in over ssh with your keys: building with live-build, the live.local name, pitfalls and security.

· 9 min read

System administration

A closed laptop with a USB stick is linked by a glowing line with a key to a floating terminal window; Maxim watches calmly while the holographic cat walks along the line

Build the image with live-build: kernel and firmware from backports, your authorized_keys for root, sshd enabled at boot and the mDNS name live.local. Boot a machine from the stick and a minute later run ssh root@live.local from your laptop — no monitor or keyboard on the machine itself.

Stick in the port, power on — and you’re at that machine’s console from your own laptop.

Why bother

A stock Debian or Ubuntu live image boots a desktop and waits for someone at the keyboard. To reach it over the network you have to sit down at the machine, install and start an ssh server, set a password and find the address. Your own image does all of that by itself:

  • a machine without a monitor — a server, a mini PC, an x86 router: boot from the stick and it’s on the network;
  • a broken system — Windows or Linux won’t boot, and the disk needs to be mounted, repaired or copied over the network;
  • hardware checks — disks (SMART), memory, network cards, Wi-Fi, before you install a system;
  • comfort — working in your own terminal, with your clipboard and history, rather than at someone else’s keyboard.

What goes into the image:

  • Debian stable with the kernel and firmware from backports: new network cards and Wi-Fi are recognised where the stable installer doesn’t know them yet;
  • an ssh server started at boot; root gets in only with your keys;
  • the network name live.local (mDNS via Avahi): no need to know the IP;
  • the machine’s addresses on the login screen as soon as DHCP hands them out;
  • disk and network tools: mdadm, lvm2, cryptsetup, smartctl, nvme-cli, parted, testdisk, ddrescue, ntfs-3g, rsync, tmux, mc, iftop, tcpdump, mtr, iperf3;
  • a three-entry boot menu: a light XFCE desktop (the default, after 5 seconds), a console (less memory, faster) and a console in nomodeset safe mode for when the screen stays black. ssh works in all three.

The finished image goes onto the multiboot stick from part one of the series, next to the installers.

What you need to build it

  • a computer or virtual machine running Debian (Ubuntu will do too);
  • the live-build package, root and access to deb.debian.org;
  • about 10 GB free in the working folder, for example in /var/tmp;
  • time: about 25 minutes for the first build, about 10 for a rebuild with the package cache;
  • your public ssh keys in one authorized_keys file.

Below are the key steps of the build script. Each block is part of one bash script run as root from an empty working folder.

Building with live-build

1. The base

DIST=trixie                      # Debian 13
LIVE_USER=sysadmin               # example: use your own
LIVE_PASSWORD=sysadmin           # example: be sure to change it

lb config \
  --distribution "$DIST" \
  --archive-areas "main contrib non-free non-free-firmware" \
  --debian-installer none \
  --binary-images iso-hybrid \
  --bootloaders "grub-efi,syslinux" \
  --memtest none \
  --bootappend-live "boot=live components hostname=live username=$LIVE_USER locales=en_US.UTF-8 keyboard-layouts=us timezone=Etc/UTC"

grub-efi boots the image in UEFI, syslinux in BIOS, and iso-hybrid produces a file you can either write to a stick or drop into Ventoy. Set your own language, keyboard layouts and time zone in --bootappend-live.

2. Kernel and firmware from backports

A="deb http://deb.debian.org/debian $DIST-backports main contrib non-free non-free-firmware"
echo "$A" > config/archives/backports.list.chroot
echo "$A" > config/archives/backports.list.binary
for t in chroot binary; do cat > config/archives/backports.pref.$t <<PREF
Package: linux-image-* linux-headers-* linux-kbuild-* linux-base* linux-binary-* linux-modules-* firmware-* amd64-microcode intel-microcode
Pin: release n=$DIST-backports
Pin-Priority: 990
PREF
done

Without this step the image gets the stable kernel and simply won’t see network cards released after it. Priority 990 applies only to the kernel and firmware: the rest of the system stays stable.

3. Packages

Put your list in a separate file, for example config/package-lists/custom.list.chroot:

openssh-server avahi-daemon libnss-mdns network-manager sudo
firmware-linux firmware-linux-nonfree firmware-misc-nonfree firmware-realtek firmware-iwlwifi
mdadm lvm2 cryptsetup smartmontools nvme-cli parted gdisk ntfs-3g testdisk gddrescue
rsync tmux htop mc iftop tcpdump mtr-tiny dnsutils iperf3 ethtool lshw
xorg lightdm xfce4 xfce4-terminal network-manager-gnome firefox-esr gparted

4. Keys and ssh

install -d -m 700 config/includes.chroot/root/.ssh config/includes.chroot/etc/skel/.ssh
install -m 600 authorized_keys config/includes.chroot/root/.ssh/authorized_keys
install -m 600 authorized_keys config/includes.chroot/etc/skel/.ssh/authorized_keys

install -d config/includes.chroot/etc/ssh/sshd_config.d
cat > config/includes.chroot/etc/ssh/sshd_config.d/10-live.conf <<'CONF'
PasswordAuthentication yes          # the user: key or password
PermitRootLogin prohibit-password   # root: key only
CONF

Everything under config/includes.chroot/ lands in the image as is. Through /etc/skel the keys also reach the user that live-config creates at boot. If you don’t need password logins over the network, write PasswordAuthentication no.

5. Hooks: services, host keys and the password

cat > config/hooks/normal/9000-live-ssh.hook.chroot <<'HOOK'
#!/bin/sh
set -e
systemctl enable ssh.service avahi-daemon.service NetworkManager.service
rm -f /etc/ssh/ssh_host_*    # host keys are generated anew on every boot
HOOK

# live-config hard-codes the password "live" in its script — swap in the hash of yours
openssl passwd -6 "$LIVE_PASSWORD" > config/includes.chroot/etc/live-password.hash
cat > config/hooks/normal/9010-live-password.hook.chroot <<'HOOK'
#!/bin/sh
set -e
f=/usr/lib/live/config/0030-user-setup
h=$(cat /etc/live-password.hash); rm -f /etc/live-password.hash
sed -i "s|^\(\s*_PASSWORD=\).*|\1'${h}'|" "$f"
grep -qF "$h" "$f"
HOOK
chmod +x config/hooks/normal/*.hook.chroot

install -d config/includes.chroot/etc/sudoers.d
echo "$LIVE_USER ALL=(ALL) NOPASSWD:ALL" > config/includes.chroot/etc/sudoers.d/live-user
chmod 440 config/includes.chroot/etc/sudoers.d/live-user

Hooks run inside the future system during the build. The last line of the password hook is a check: if live-config changes the file format, the build fails instead of shipping an image with the old password.

6. The address on the login screen

install -d config/includes.chroot/etc/NetworkManager/dispatcher.d
cat > config/includes.chroot/etc/NetworkManager/dispatcher.d/90-live-issue <<'DISP'
#!/bin/sh
mkdir -p /run/issue.d
{ echo "  ssh root@live.local"; ip -4 -br addr show scope global | awk '{print "  "$1"  "$3}'; echo; } \
  > /run/issue.d/50-live-ip.issue
agetty --reload 2>/dev/null || true
DISP
chmod 755 config/includes.chroot/etc/NetworkManager/dispatcher.d/90-live-issue

When NetworkManager gets an address, the script rewrites the console greeting, so the addresses appear right on the login screen. Put the same ip -4 -br addr into /etc/profile.d/ to print them on console login too.

7. The boot menu

Copy the boot loader templates into config/bootloaders/ and edit them:

cp -r /usr/share/live/build/bootloaders/{grub-pc,syslinux_common,isolinux} config/bootloaders/
sed -i 's/^timeout .*/timeout 50/' config/bootloaders/isolinux/isolinux.cfg    # 5 seconds
sed -i 's/^set default=0$/set default=0\nset timeout=5/' config/bootloaders/grub-pc/config.cfg

In config/bootloaders/grub-pc/grub.cfg, three menu entries take the place of @LINUX_LIVE@:

menuentry "Live — XFCE desktop (ssh on)" {
	linux	@KERNEL_LIVE@ @APPEND_LIVE@
	initrd	@INITRD_LIVE@
}
menuentry "Live — console (ssh on)" {
	linux	@KERNEL_LIVE@ @APPEND_LIVE@ systemd.unit=multi-user.target
	initrd	@INITRD_LIVE@
}
menuentry "Live — console, safe mode (nomodeset)" {
	linux	@KERNEL_LIVE@ @APPEND_LIVE@ nomodeset systemd.unit=multi-user.target
	initrd	@INITRD_LIVE@
}

For BIOS, the same three entries go into config/bootloaders/syslinux_common/live.cfg.in in syslinux syntax (label, menu label, linux, initrd, append).

8. Build and check

lb build 2>&1 | tee build.log
for p in live-boot live-config openssh-server; do
  chroot chroot dpkg -s "$p" >/dev/null || { echo "$p is missing from the image"; exit 1; }
done
sha256sum *.hybrid.iso

Before putting the image on a stick, test it in QEMU in both BIOS and UEFI mode (OVMF firmware from the ovmf package), forwarding ssh to port 2222:

qemu-system-x86_64 -m 4G -enable-kvm -cdrom live-image-amd64.hybrid.iso \
  -netdev user,id=n0,hostfwd=tcp::2222-:22 -device virtio-net,netdev=n0
# UEFI: add  -bios /usr/share/ovmf/OVMF.fd
ssh -p 2222 root@127.0.0.1      # should let you in with your key

live-build pitfalls

  • live.list.chroot is taken. live-build keeps its own packages (live-boot, live-config) in it. Name your list that way and it overwrites the internal one: the image builds but won’t boot as a live system. Use any other name.
  • The menu waits forever. The isolinux template has timeout 0, i.e. no automatic boot, and the grub template has no timeout at all. For a machine without a monitor that means “never boots” — set the timeout explicitly.
  • Not every placeholder is substituted in grub.cfg. @KERNEL_LIVE@, @INITRD_LIVE@ and @APPEND_LIVE@ work, but there are no failsafe variants as in syslinux. Build the grub safe mode yourself: @APPEND_LIVE@ plus nomodeset.
  • live-config turns off password logins for ssh. It writes PasswordAuthentication no itself. Your own file in sshd_config.d is read before the main config and therefore wins.
  • The password “live” is hard-coded in a live-config script (0030-user-setup). The hook from step 5 substitutes yours.
  • \4 in /etc/issue shows 127.0.0.1. agetty takes the address when it draws the screen, i.e. before DHCP. That’s why the NetworkManager script from step 6 writes the address.

Using it

  1. Boot the machine from the stick and pick the image in the Ventoy menu.

  2. A minute or two later, from a laptop on the same network:

    ssh root@live.local
    

    With two such machines on the network, Avahi names the second one live-2.local. If the name doesn’t resolve, the address is on the machine’s screen; on the machine itself ip -br a shows it and nmtui sets up Wi-Fi or a static address.

  3. The live system gets new host keys on every boot, so ssh will complain that the key changed. To avoid cleaning known_hosts every time, give the live system its own block in ~/.ssh/config:

    Host live.local live-*.local
      User root
      StrictHostKeyChecking no
      UserKnownHostsFile /dev/null
    

The image has all the main disk tools. How to read smartctl output for an NVMe drive — data written and Percentage Used — is covered in the guide on SSD wear: it’s about Macs, but NVMe SMART fields are the same everywhere.

Security

  • The default password is just an example. The sysadmin / sysadmin pair above shows where the name and password are set; use different ones in your build. If an image was already built with a simple password, change it right after boot with passwd, especially on someone else’s network: the user has passwordless sudo, so that password equals root access.
  • root: keys only. root has no password, and PermitRootLogin prohibit-password won’t let root in with a password even if one is set.
  • Host keys are new on every boot. That way the image carries no private keys shared by every copy. The flip side: ssh can’t tell your live machine from an impostor. Use it on your own network and don’t enable agent forwarding (ssh -A) to the live system.
  • The image holds only public keys. They aren’t secret, but they decide who gets in: before building, check that authorized_keys has nothing extra.

A case from practice

A Windows 11 laptop stopped booting after a botched disk cleanup. The folder left over from a previous update was being deleted after taking ownership of it recursively (takeown and icacls with /r and /t). Inside were junctions pointing to real system folders, and the commands followed them: permissions on ProgramData, the default profile and part of the user’s profile were replaced with “this user only”. The SYSTEM account lost access, and the services the shell depends on stopped starting. No files were lost.

The repair was done from the live image over ssh, all of it from a terminal on another computer:

  1. boot from the stick, ssh root@live.local from another computer;
  2. the NTFS volume was marked dirty, so ntfsfix first, then mounting read-write with ntfs-3g;
  3. before any change, a backup of every folder’s current permissions so it could be rolled back;
  4. permissions on system folders restored by inheritance from the parent, top down, and the special ones per Microsoft’s documentation;
  5. reboot into Windows, DISM and sfc checks — clean.

Fixing this from Windows is hard: it no longer starts, and the recovery environment only offers a blunt permission reset that loses the special settings. The live image with ssh gave a full Linux with access to the disk and the chance to work from one’s own terminal with one’s own scripts.

The lesson for the future: delete leftover Windows update folders ($WINDOWS.~BT, Windows.old) with the built-in Disk Cleanup, not by changing permissions and deleting by hand.

Next in the series: Windows without a Microsoft account — which edition to put on the stick so that setup gives you an ordinary local account.