Migrating a Raspberry Pi 4B from Ubuntu 24.04 to Debian Trixie
June 13, 2026
The job. Move a headless Raspberry Pi 4B from Ubuntu 24.04 to a clean Debian Trixie on the same USB drive, and bring back everything it does: WiFi, a grown filesystem, the right users and SSH access, and the full web stack. Debian Trixie ships as a bare bones image, so nearly all of this is done by hand. The very first step happens before you wipe anything. Here is the order that worked.
1. Back up before you touch the drive. This is the one step you cannot undo if you skip it. While the old system is still running, save the things you do not want to lose, on every user account: the .ssh directory, the .bashrc, and the .bash_aliases. I gather them into a ~/hybx directory, name each one by account so I can tell them apart later, then copy that directory off to my Mac. From inside the webdude account's home:
mkdir ~/hybx
cp -rpd .ssh hybx/ssh
cp .bashrc hybx/hybx.webdude.bashrc
cp .bash_aliases hybx/hybx.webdude.bash_aliases
Do the same on the hybotix account, swapping webdude for hybotix in the file names. The -p on the .ssh copy matters: it preserves the permissions, which SSH is strict about. Once that directory is safely on the Mac, the drive is yours to wipe.
2. Find and flash the image. Finding it is the surprising part. Debian publishes an official Trixie image for the Raspberry Pi 4B, but it is not advertised anywhere obvious, so you have to go hunting for it. That is genuinely strange for a first party image of such a common board, but it is out there.
The download is an archive, not a ready to flash image. Unarchive it and you get a file named disk.raw. Raspberry Pi Imager will not pick that file up as it stands, so rename it with a .img extension first. I named mine debian-trixie-raspberry-pi-4b.img. With that, Raspberry Pi Imager finally sees it and writes it to the flash drive.
On first boot the bare image asks only for a timezone and a root password. You start from close to nothing, which is exactly the point.
3. Bring up the network. Bare Debian uses systemd-networkd, not the tooling Ubuntu hands you. For WiFi that means wpa_supplicant plus a small .network file. The full recipe is in its own entry near this one, WiFi on bare Debian. Get this working first, because everything after it is far easier over SSH than at the console.
4. Grow the root partition. A flashed image uses only a couple of gigabytes. Claim the rest of the drive with growpart and resize2fs. The details are in the growing the root partition entry.
5. Set the hostname.
sudo hostnamectl set-hostname hybx-test
6. Create the custom groups. These are the standard groups I add to every Linux I run, on any platform, so this step is identical on every machine I set up. The accounts belong to these groups, so the groups have to exist before the users, and their GID numbers have to match what the restored files expect. Edit /etc/group and add them:
robots:x:500:
webadmn:x:510:
rustdev:x:520:
7. Add the user accounts. With the groups in place, create the accounts with the exact UIDs, primary groups, shells, and home directories they had before, so the ownership of the restored files lines up:
useradd -m -u 2001 -g robots -s /bin/bash -d /home/hybotix hybotix
useradd -m -u 2002 -g webadmn -s /bin/bash -d /var/www webdude
Then add each user to any additional groups it needs for regular operation. Restore the files you saved in step 1: scp -r each backed up .ssh directory over from the Mac and mv it into place as .ssh in the right account's home, and put the .bashrc and .bash_aliases back as well. Fix ownership and permissions by numeric uid and gid so nothing ends up owned by the wrong account; a small permissions script run inside each home directory makes that repeatable, and it has its own entry, the permissions SSH demands on your .ssh directory.
Two things make this part just work. The key files themselves come across intact, so there is nothing to regenerate, and the permissions script sets the strict modes SSH insists on, 700 on the .ssh directory and 600 on the private keys. With both of those done, every SSH key came back for both accounts, not a single key had to be regenerated, and everything on Codeberg, the git remotes and the deploy keys, worked immediately, exactly as if the machine had never changed at all.
8. Install the web stack. Install nginx, plus a handful of basics a minimal Debian leaves out, git, curl, wget, and avahi-daemon:
apt install nginx avahi-daemon git curl wget -y
git, curl, and wget do not come on a stock minimal image, and you will want all three almost immediately: git to clone the sites, curl and wget to fetch things like the zola binary. avahi-daemon is what gives the machine its .local name on the network. A bare Debian image does not include it either, and without it hybx-test.local will not resolve, leaving you to reach the box by raw IP address. With it running, the machine and every site on it answer to hybx-test.local.
By design, I do not run nginx as the standard www-data user. I run it as my own webdude user and webadmn group, by setting the user directive in /etc/nginx/nginx.conf. Because webdude owns all of the web content, running the server as webdude makes every site automatically accessible to nginx, with no permission or traverse problems to chase later.
Install zola from the official aarch64 prebuilt binary into /usr/local/bin (it is not in apt, and cargo install does not work for it).
The sites live in webdude's home, which is /var/www. Make the web root and work from inside it:
mkdir html
cd html
From there, git clone each website repository from Codeberg (or wherever you keep your repos) into /var/www/html, so each site lands in its own directory. Add each repo's deploy key and point the repo at it, so it can pull and publish.
9. Serve the sites. Build each site with zola build, which produces a public directory. Then add an nginx server block per site, on its own port, and make sure each block's root points at that site's built output, /var/www/html/<site>/public. nginx will not serve a site it is not pointed at, so every site needs its own block aimed at its own directory. The full pattern for running many sites on one nginx, one server block per site, is its own entry, running several websites on one nginx. If a site ever returns a 404 even though the files are clearly present, that is a directory traverse permission problem up the path, the /var/www traverse trap, which has its own entry. Running nginx as webdude from step 8 is the structural way to keep that from happening at all, since the server already owns everything it serves.
When all of that is done, the machine is fully back, on Debian, answering to nothing but you. The bare image is more work up front. The reward is that you understand every line of what you built.