Portlin writes a real, upgradable Debian + Xfce install onto a USB stick that boots on any x86_64 machine, BIOS or UEFI, optionally with a LUKS2-encrypted root.
Not a live ISO with a persistence overlay. Kernel upgrades work. apt full-upgrade works.
Think Rufus' "Windows To Go", for Linux.
sudo portlin create --target /dev/sdb --encrypt
Boot the result anywhere and a first-run wizard asks for an account, keyboard, language and time zone, then offers to grow the system to fill the drive.
x86_64 Linux host, root, and these packages:
sudo apt install debootstrap gdisk parted dosfstools e2fsprogs cryptsetup zstd
git clone https://github.com/sleep/Portlin && cd Portlin
sudo python3 -m portlin doctor
doctor reports everything missing at once, and names the package that provides it.
portlin doctor # check the host
portlin devices # list candidate targets
portlin build -o rootfs.tar.zst # slow half, 20-40 min
portlin write -t /dev/sdb --rootfs rootfs.tar.zst --encrypt # fast half, ~2 min
portlin create -t /dev/sdb --encrypt # both, in one step
build produces a hardware-agnostic, identity-free tarball. Making a second stick, or redoing one
after a mistake, does not mean waiting for another debootstrap.
| Flag | Effect |
|---|---|
--encrypt |
LUKS2 over root. Prompts for a passphrase on the terminal |
--minimal |
No desktop. Boot, system, storage and network only |
--groups desktop,apps |
Pick package groups explicitly |
--extra tmux --exclude firefox-esr |
Adjust the package set |
--suite bookworm |
Build a different Debian release |
--target stick.img |
Write to an image file instead of a device |
--dry-run |
Print every command that would run, and stop |
There is deliberately no --passphrase flag: it would be readable from /proc by every user on
the machine and would land in shell history.
GPT, four partitions:
| # | Size | Type | Purpose |
|---|---|---|---|
| 1 | 1 MiB | EF02 |
BIOS boot, holds GRUB's core.img |
| 2 | 512 MiB | EF00 |
ESP, FAT32, /boot/efi |
| 3 | 1 GiB | ext4 | /boot, plaintext |
| 4 | rest | ext4 or LUKS2 | root |
The image ships at 8 GB no matter how big the stick is, so one image fits every drive and the flash is fast. On first boot it offers to expand into the rest.
The Xfce desktop is dark out of the box: Numix across GTK, window decorations, the LightDM greeter
and the terminal, with Papirus-Dark icons over it. First boot offers three widget themes and five
icon sets, all of them installed in the image, because first boot has no network. The defaults live
in /etc/xdg/xdg-portlin, which the session adds to XDG_CONFIG_DIRS, so Settings > Appearance
still changes them and the change sticks -- including a change made by the wizard, since everything
that names a theme is a conffile. They sit in a directory of their own because dpkg lets only one
installed package own a path, and Xfce's own packages already own the canonical /etc/xdg
locations.
One panel, along the top, with a searchable applications menu under the portlin mark. At the right end sits a readout of what the machine is doing:
cpu 14% mem 3.1G/15.5G gpu 22% disk 6.1G/6.5G of 119.2G ip 192.168.1.42 bat 87%
It is one program rather than a row of plugins, so it has one font and one spacing rule, and so it
can report things no stock plugin does. disk is the stick's own story: two capacities mean the
image has not been expanded into the drive yet, and the tooltip says so and names portlin-expand.
luks appears in the disk field on an encrypted stick, in crimson, which is the only crimson on
the desktop. Clicking anything in the line opens About Portlin, which names the machine the stick
is plugged into today: model, CPU, memory, graphics and whether it booted UEFI or BIOS.
The readout would rather say nothing than guess. cpu --% is the first couple of seconds of a
session, before there are two samples to compare; a machine with no battery has no bat field
rather than an empty one. Intel graphics expose a clock and no utilisation counter, so on those
machines the field reads gpu 350MHz and the tooltip says in words that it is a frequency.
A coffee cup sits in the panel: click it and the machine stops sleeping, blanking and dimming until
you click it again, or for a span you pick from Activate for. It holds a logind lock over
idle:sleep:handle-lid-switch, so a closed lid does not suspend either. Right-click for
preferences; untick it in Settings > Session and Startup to stop it appearing at all.
Software, in the applications menu, installs the things people go looking for on a fresh
system: Chrome, Brave, Chromium, Tor Browser, Pale Moon, Signal, Telegram, Discord, VLC, OBS,
LibreOffice, GIMP, VS Code, Zed, Cursor, Claude Desktop, Claude Code, Kimi Code, Docker,
RustDesk, AnyDesk, Mullvad, Tailscale, qBittorrent, Deluge and more. Each entry says where it
comes from, because they are not all the same kind of thing: Debian's archive, the vendor's apt
repository, a .deb the vendor publishes, a tarball unpacked into /opt, or an installer that
runs as you, under your own home directory.
It also looks at the machine the stick is plugged into. The Drivers page names the graphics
and wifi hardware it found and offers what fits: NVIDIA's proprietary driver, picked for that
exact card by nvidia-detect, video acceleration and Vulkan for Intel and AMD, the Broadcom STA
driver for the chips the open ones do not cover, and printing and scanning. A stick travels, so
the NVIDIA entry says plainly what installing it does to the next machine, and how to undo it
from a text console.
Everything privileged goes through one command, portlin-install, which the window runs under
pkexec, or under sudo if first boot was told sudo needs no password. So the program that can
be asked to act as root is one command with a fixed set of verbs rather than a window, and the
same verbs work from a terminal:
portlin-install list # the catalog, and what is already installed
portlin-install install mullvad # or remove, or status
portlin-install scan # what this machine needs
portlin-install upgrade # what Update everything runs
Update everything in the window's header is that last one: apt full-upgrade with its
output in the log pane, for the system and everything installed from here alike. A full upgrade
is occasionally allowed to remove a package to resolve a transition, so it asks apt what it
would do first and stops with the list in front of you rather than removing anything from a
button press.
Entries that need Debian's non-free component get it through a drop-in under
/etc/apt/sources.list.d/, so sticks written before that component was enabled by default are
not left out. Deleting that file takes it away again.
The Debian system updates itself: it is a real install, so apt full-upgrade
and kernel upgrades work.
Portlin's own contribution to the stick is split in two. The desktop theme,
the wallpapers, the caffeine applet, the Software app and its catalog, the About Portlin menu
entry and the portlin-info, portlin-expand, portlin-encrypt and portlin-install
commands are Debian packages, and will update from portlin's archive like
anything else once that archive is published; until then they stay at
whatever version the stick was written with. The bootloader, the initramfs,
fstab and crypttab are written once and stay put, because an update that
breaks one of those is a stick that will not boot. Moving those forward means
writing the stick again.
write erases whatever you point it at. Before it does:
- non-removable devices are refused unless you pass
--force - devices under 8 GiB are refused outright,
--forceor not - devices with mounted filesystems are refused outright
- the confirmation prompt requires typing the device path, not
y
Any failure mid-write unwinds in exact reverse: unmount, close the LUKS mapping, detach the loop device.
make image # build a real image, with progress
make test # unit tests, no root, no Linux, ~1s
make dryrun # print the full command plan
make check # tests plus shellcheck
make harness # shipped scripts against real loop devices, needs Docker
make image runs the whole pipeline and shows where it is: a bar per stage, the current package,
and an ETA. On a host that cannot build directly (anything but x86_64 Linux as root, which includes
every Mac) it re-runs itself inside a privileged linux/amd64 container and renders the same
display from in there, so the same command works everywhere.
One stage cannot be drawn: the container has to install python3 before anything can draw at all,
and on an emulated host that is among the slowest minutes of a build. Those steps narrate
themselves instead, with the same elapsed-time format the display uses ( [4s] installing python3), and a cold image pull is announced before it starts rather than looking like a stall.
make image ARGS=--verbose gives back the full apt output for debugging the bootstrap itself.
Every percentage comes from the tool doing the work rather than from a guess about phases: apt's
APT::Status-Fd stream, debootstrap's package lines, tar's checkpoints. The overall ETA is the one
estimate, weighted by how long each stage took on this machine last time; the first build has no
history and says so.
Unit tests run anywhere, including macOS, because every external command goes through one Runner
that can record instead of execute. That makes build_rootfs and write_stick assertable as
ordered command lists, which is where the real risk lives: a crypttab written after
update-initramfs produces a stick that cannot unlock itself, and no type checker finds that.
How it boots on machines it has never seen
- Both firmware families. GRUB is installed twice:
i386-pcinto the MBR gap, andx86_64-efi --removable --no-nvramfor UEFI.--removablewritesEFI/BOOT/BOOTX64.EFI, the fallback path every UEFI implementation probes on removable media.--no-nvramkeeps grub-install out of the build machine's firmware. MODULES=mostin the initramfs, so every storage and USB controller driver is present.GRUB_DISABLE_OS_PROBER=true, so the menu does not advertise the build host's operating systems and os-prober never mounts a stranger's internal disks.- UUIDs everywhere.
/dev/sda4is correct on exactly one machine. RESUME=none, so the initramfs does not stall hunting for someone else's hibernation image.- Firmware and microcode for everyone: iwlwifi, realtek, atheros, brcm80211, plus both
intel-microcodeandamd-microcode. - A capped LUKS KDF. cryptsetup sizes argon2id by benchmarking whichever machine formats the container, so a stick formatted on a workstation can be unopenable on a netbook. Portlin caps it at 256 MiB.
- Flash-aware defaults:
noatime,commit=120, and zram instead of swap.
Why /boot is not encrypted
GRUB can only read LUKS2 with the old PBKDF2 derivation, and unlocking in GRUB means typing the
passphrase twice at every boot. The cost of a plaintext /boot is that someone with physical
access can tamper with your kernel. The benefit is one prompt, a modern KDF, and the same
arrangement the Debian installer itself produces.
How expansion works
Each layer can only grow into space the layer beneath it has already claimed, so the order is not a preference:
growpartgrows the last partition, via the kernel's live partition-resize ioctl.cryptsetup resizegrows the LUKS mapping into the enlarged partition.resize2fsgrows the filesystem into the enlarged mapping.
On an unencrypted stick, step 2 is skipped. Declining is safe and repeatable: nothing depends on having expanded, and the same three commands work later by hand.
First boot in detail
The image ships with no user, an empty machine-id, no SSH host keys and a locked root account.
portlin-firstboot.service runs on tty1 before LightDM and collects the account, hostname, locale,
keyboard, time zone and whether sudo should ask that account for a password. On an encrypted stick it also offers to change the LUKS passphrase, so the
person holding the stick owns the key rather than whoever built it.
If the wizard is cancelled or crashes its sentinel stays in place and it runs again next boot,
rather than stranding you at a login screen with no accounts. On a stick encrypted during that boot
but never finished, the initramfs recognises the situation and asks for the passphrase itself.
Otherwise no crypttab would exist yet, nothing would unlock the root, and cancelling a wizard
would leave an unbootable drive.
Verification tiers
| Tier | Needs | Command |
|---|---|---|
| Unit | nothing | make test |
| Real-device harnesses | Docker, privileged | make harness |
| Structural | Linux, root | sudo scripts/verify-image.sh stick.img |
| Integration | Linux or Docker, privileged | ROOTFS=... scripts/integration-test.sh |
| End to end | qemu | make prove |
make harness earns its keep. It runs the shipped scripts and packages against real block
devices, a real dpkg and a real X server, in about three minutes. Seven harnesses, ten runs:
| Harness | What only a real device shows |
|---|---|
test-encrypt-hook.py |
the initramfs encryption script end to end: prompts, fsck, shrink, encrypt, unlock, mount, and a canary file proving the data survived |
test-expand.py |
the expansion path against a real mounted filesystem. Four runs: the wizard's apply_expand and the packaged portlin-expand are separate implementations that can drift, each tested encrypted and not |
test-stash-passphrase.py |
the crypttab keyscript, whose stdout is key material, and that the passphrase reaches the wizard without ever becoming a file on the stick |
test-package-conflicts.py |
portlin's packages installing where something else already owns /etc/xdg |
test-package-upgrade.py |
conffiles surviving an upgrade, against a real dpkg |
test-caffeine.py |
the applet actually moving the screen settings, against a real X server |
test-software.py |
portlin-install installing and removing real packages from Debian's archive and from a vendor repository, every package name in the catalog resolving, both privilege refusals, and the Software window against a real X server |
Between them they caught a malformed partx argument, lsblk -o PKNAME returning empty for
device-mapper volumes, an assumption that udev had created /dev/mapper symlinks, a filesystem
left larger than its container, and cryptsetup resize silently prompting for a passphrase with no
terminal. Every one had already reached a USB stick, because the only test covering them was a
fifteen-minute emulated boot.
That last one is now fixed rather than merely survived: the volume key is unreachable once the
initramfs has exited, so whatever saw the passphrase there leaves it in /run for the wizard to
finish the expansion with, and the wizard deletes it on first use. On an ordinary boot that is a
crypttab keyscript; on the boot that creates the container it is the encryption hook itself, which
runs before any crypttab exists to name a keyscript. The wizard also gives every command it runs a
pipe on stdin rather than the tty1 it was handed, so cryptsetup cannot take the console and ask for
the passphrase over the top of the dialogs.
verify-image.sh is the one to run after any change to the write path: it loop-mounts a finished
image and checks the things that only show up as "this machine won't boot it" - the UEFI fallback
file, GRUB in the MBR, the BIOS boot partition type, UUID-only fstab, an empty machine-id, an armed
wizard.
qemu-boot-test.sh boots the image under both legacy BIOS and UEFI/OVMF and asserts GRUB reaches
its menu on each. Those are genuinely different code paths and a stick can work on one while being
invisible to the other.
Building on an arm64 Mac
Not natively, since debootstrap runs amd64 maintainer scripts. A privileged linux/amd64 container
works end to end, at emulation speed:
docker run --rm --privileged --platform linux/amd64 \
-v "$PWD:/src" -v "$PWD/out:/out" -w /src debian:trixie bash -c '
apt-get update -qq
apt-get install -y -qq --no-install-recommends python3 debootstrap gdisk \
parted dosfstools e2fsprogs cryptsetup-bin util-linux zstd tar mount
python3 -m portlin build --minimal -o /out/rootfs.tar.zst
python3 -m portlin write -t /out/stick.img --image-size 12G \
--rootfs /out/rootfs.tar.zst --yes
bash scripts/verify-image.sh /out/stick.img'
A container has no udev and mounts /dev as a plain tmpfs, so partition nodes are never created
automatically. Portlin handles that itself: it waits for the nodes, nudges the kernel with partx,
and finally creates them from /sys/class/block/<name>/dev the way udev would.
Then boot the result natively, since qemu on the Mac emulates x86_64 fine:
brew install qemu
scripts/qemu-boot-test.sh out/stick.img
A shared FAT32/exFAT partition readable from Windows, Secure Boot signing, and architectures other than amd64.
GNU General Public License v3.0 or later. The Debian system portlin installs onto the stick carries its own licenses, unaffected by this one.
