Skip to main content

Triple-Boot Arch + Ubuntu + Windows (Plain Btrfs, No Encryption)

arch linux
ubuntu
windows
linux
installation
triple-boot
btrfs
snapper
hibernation
grub
os-prober
mkinitcpio
zram
esp
bitlocker
secure boot
shared partition
repartition
no encryption
Rebuilding the Linux zone on a machine that already runs Windows 11: delete the old Ubuntu and swap partitions, reclaim the unallocated space, and carve a shared plain-Btrfs /data plus separate plain-Btrfs roots for Arch and Ubuntu — all without touching Windows, its ESP, or its recovery partitions. Both Linux roots reuse the existing EFI partition mounted at /boot/efi, with kernels on Btrfs (not on the ESP). Per-OS zram + NoCOW Btrfs swapfiles for hibernation, no shared swap partition. One Arch GRUB on the existing ESP, os-prober chainloading Windows, a hand-written chainload for Ubuntu. No passphrase prompts at boot. Secure Boot off, BitLocker off.
Author

Evanns Morales

Published

June 7, 2026

Disclaimer: a reasoned rebuild-in-place blueprint, not yet a live-tested run

This is the rebuild variant: Windows 11 already lives on the drive and stays exactly where it is. We delete the old Ubuntu and swap partitions, reclaim the unallocated space, and rebuild everything after Windows as a fresh Linux zone — two plain-Btrfs roots (Arch, Ubuntu) and one shared plain-Btrfs /data. The existing EFI partition is reused (mounted at /boot/efi), Windows’ own partitions and recovery/OEM partitions are left untouched. It’s a carefully-reasoned procedure, not a command-by-command log I’ve executed end-to-end (yet). The parts that need the most care are the destructive ones — deleting the right partitions and only the right ones — and the Windows-integration ones: reusing the ESP, the RTC clock fix, and Secure Boot / BitLocker.

Canonical sources to re-read as you go:

Treat it as a blueprint for the pattern, not a substitute for the tool docs.

What this is

The layout, rebuilding the Linux zone next to an existing Windows, with no encryption on the Linux side:

  • Windows 11 — already installed, on its own NTFS partition. We leave it completely alone, along with its ESP and its small recovery/MSR/OEM partitions.
  • The existing EFI partition is reused, not recreated, and mounted at /boot/efi. Each Linux distro keeps its kernels and initramfs on its own Btrfs root under /boot, not directly on the ESP.
  • Arch Linux and Ubuntu 24 LTS, each on its own new plain Btrfs root with the usual subvolumes (@, @home, @snapshots, @var_log, @swap) — the same root layout as the plain-Btrfs dual-boot.
  • One shared 125 GiB plain Btrfs /data partition the two Linux distros mount read-write. Windows stays out of it (it can’t read Btrfs natively), and there is no Windows-readable exchange partition in this build.
  • Per-OS zram for everyday swap and per-OS NoCOW Btrfs swapfile inside each Linux root for hibernation — no shared swap partition, no cross-OS resume collisions.
  • One GRUB menu — Arch’s GRUB, installed to the existing ESP and set as the EFI default. os-prober picks up the Windows boot manager automatically; Ubuntu gets a hand-written chainload entry (os-prober can’t see its Btrfs @-subvolume root).
  • Secure Boot off, BitLocker off, the simplest configuration that boots all three reliably.

It stops on the console for the Arch side; Windows and Ubuntu come up graphical out of the box.

Why a no-encryption triple-boot

Same reasoning as the plain-Btrfs dual-boot: for a desktop that never leaves the house, an auto-rebooting box, or a first attempt where you want the multi-boot working before adding crypto, full-disk encryption is friction without much payoff. If you want encryption, you’d wrap each Linux root and the shared /data in LUKS2 — a more involved build, out of scope here. This article is for when you’ve decided you don’t.

What you give up by dropping encryption

With no LUKS on the Linux roots or the shared /data, anyone who can boot a USB stick (or pull the SSD) reads everything on the Linux side, home directories, the shared /data, saved credentials. (Windows’ own files may still be protected if you keep its device encryption, but this article turns BitLocker off to keep the boot path simple, so treat the whole disk as readable-at-rest.) A lost or stolen machine is a data breach, not just a hardware loss. If that’s unacceptable for what lives here, build it with full-disk encryption (LUKS) instead.

Because Windows is already on the disk, the build has four concerns a Linux-only rebuild doesn’t:

  1. Windows is a bad bootloader citizen. It writes its boot manager to the ESP and reorders NVRAM to put itself first. Windows is already here, so we reuse its ESP, install GRUB after, make GRUB the EFI default, and re-assert that order if a Windows update steals it.
  2. Windows can’t read Btrfs. The shared /data is Linux-only. This build does not carve a Windows-readable exchange partition — move the occasional file Windows↔︎Linux over the network or a USB stick.
  3. The RTC clock war. Windows assumes the hardware clock is local time; Linux assumes UTC. We fix it by telling Windows to use UTC.
  4. Fast Startup, “device encryption,” and Secure Boot. Fast Startup leaves NTFS dirty, device encryption (BitLocker) re-prompts when the boot path changes, and our unsigned GRUB trips Secure Boot. We turn all three off.

This guide deletes partitions. Identify before you act.

Two partitions get deleted (the old Ubuntu root and the old swap), and three get created. Everything else — the EFI partition, Windows C:, and the tiny recovery/MSR/OEM partitions — must survive untouched. Partition numbers on your disk will not match any example here, and they can even change as you delete and recreate. So the guide never hardcodes p5/p6/p7: instead you’ll run lsblk, identify each partition by size and type, and set shell variables ($EFI, $DATA, $ARCH, $UBUNTU) once per environment. Every later command uses those variables. The placeholder device path is written /dev/nvme0n1pX on purpose — the X is invalid, so a blind paste fails loudly instead of touching the wrong partition.

How to read the commands in this guide

Every command sits in its own copy box, hit the copy icon, paste, run. But watch for angle-bracket placeholders like <USERNAME>, <HOSTNAME>, <TIMEZONE>, <SSID>, or <WIFI_PASSWORD>. Those are values only you know, and they’re written that way on purpose: <…> is invalid shell syntax, so if you blind-paste a line that still has one, it fails loudly instead of silently running with my example values. The same goes for the /dev/nvme0n1pX device placeholders you replace when you set $EFI/$DATA/$ARCH/$UBUNTU — fill those in from your own lsblk once per environment, then the variable-based commands paste cleanly.

Part 1 — The disk layout

You start with a drive that already boots Windows and (until now) an old Ubuntu. The exact partition order and numbering are whatever the previous installs produced; identify yours with:

lsblk -o NAME,SIZE,FSTYPE,PARTTYPENAME,LABEL /dev/nvme0n1   # substitute your real device

A representative before picture — yours will differ, match by size and type, not by number:

What it is Size Type / FS Plan
EFI System partition 584 MiB FAT32 Keep & reuse — mount at /boot/efi
Microsoft Reserved (MSR) 16 MiB (no FS) Keep — Windows bookkeeping
Windows C: 174.93 GiB NTFS Keep, do not touch
old Ubuntu root 419.1 GiB Btrfs/ext4 Delete
old swap / hibernation 32.6 GiB Linux swap Delete
unallocated 300.78 + 23.31 GiB Reclaim
recovery / MSR / OEM (tiny) 990 MiB / 1.48 GiB / 15 MiB (no FS / recovery) Keep — Windows recovery/OEM

The Linux zone to rebuild is the old Ubuntu (419.1 GiB) + old swap (32.6 GiB) + the two unallocated regions (300.78 + 23.31 GiB) ≈ 775.79 GiB. The after picture:

What it is Size FS Mounted on (Linux) Purpose
EFI System 584 MiB FAT32 /boot/efi Existing shared ESP (reused)
Windows C: 174.93 GiB NTFS (not mounted) Windows — untouched
shared data 125 GiB Btrfs /data Shared data — Arch + Ubuntu only
Arch root ~260 GiB Btrfs / Arch root
Ubuntu root ~390 GiB Btrfs / Ubuntu root
recovery/MSR/OEM tiny (not mounted) Kept — Windows recovery/OEM
Doing the math for your disk

Out of the ~775.79 GiB Linux zone we carve 125 GiB for the shared /data, leaving ~650.79 GiB to split between the two roots: 40 % to Arch ≈ 260 GiB, 60 % to Ubuntu ≈ 390 GiB (Ubuntu carries the desktop and the bulk of day-to-day installs here). Adjust every number to taste; nothing downstream depends on the exact counts, only on the partition types and which one each variable points at.

Leave the tiny partitions at the end alone

The small 990 MiB / 1.48 GiB / 15 MiB partitions are almost certainly Windows recovery / MSR / OEM partitions. The safe option is to not delete them unless you intentionally accept losing Windows recovery/OEM functionality. The Linux-zone math above already excludes them; deleting them would reclaim only ~2.5 GiB, which is not worth the risk. They are not in the delete list.

Why reuse the existing ESP, and why mount it at /boot/efi (not /boot)

Windows already created an ESP and put its boot manager there. We reuse it — never reformat it, never create a second one. We mount it at /boot/efi and keep each distro’s kernels and initramfs on its own Btrfs root under /boot. That keeps the (typically small) ESP from having to hold two distros’ kernels, and it means kernel files live on Btrfs — so Snapper snapshots of @ capture the matching /boot too. GRUB reads its config and kernels straight off the Btrfs root via its btrfs module; only the small grubx64.efi stub and the Windows boot manager live on the ESP.

Why there’s no Windows↔︎Linux exchange partition

Windows can’t read Btrfs natively, and the third-party drivers (WinBtrfs) are experimental, so the shared /data stays Linux-only. Earlier versions of this build added a plain exFAT “exchange” partition all three OSes could read; this rebuild drops it to keep the layout minimal. The handful of files you move between Windows and Linux go over a network share or a USB stick instead. If you decide you want a permanent exchange area later, you can shrink one of the roots and add an exFAT partition — but it isn’t part of this layout.


Part 2 — Rebuild the Linux zone from a live USB

Boot a live Linux USB and do the partition surgery: delete the two old Linux partitions, then create the three new ones in the reclaimed space. The Arch install itself happens later, in Part 4. Windows, its ESP, and the tiny recovery partitions are never touched.

Chapter 0: Verify the ISO, boot the live USB, confirm UEFI

Same bootstrap as every article in the series. Done it before? Skim to Chapter 1.

On a machine you trust, grab the ISO, its .sig, and sha256sums.txt from the Arch download page into one directory:

cd ~/Downloads/Arch
sha256sum -c sha256sums.txt
# archlinux-2026.05.01-x86_64.iso: OK          # bytes match the published checksum

gpg --auto-key-locate clear,wkd -v --locate-external-key pierre@archlinux.org
gpg --verify archlinux-2026.05.01-x86_64.iso.sig archlinux-2026.05.01-x86_64.iso
# gpg: Good signature from "Pierre Schmitz <pierre@archlinux.org>" [unknown]

The WARNING: The key's User ID is not certified line is expected. Flash the verified ISO with balenaEtcher or dd (mind of=). Boot it, interrupt boot (F2/F10/F12/Esc), pick the USB, choose “Arch Linux install medium.” Confirm UEFI:

cat /sys/firmware/efi/fw_platform_size
# 64   (32 on very old hardware; if the file is absent you booted BIOS — fix in firmware)
Turn off Secure Boot now, while you’re in firmware

Before leaving firmware setup, disable Secure Boot — our GRUB isn’t signed with a key your firmware trusts. While you’re there, confirm the firmware is UEFI-only (no CSM/legacy), matching the existing Windows/UEFI install.

If you want to SSH in from another machine for comfort: bring up Wi-Fi with iwctl (or plug in Ethernet), then systemctl enable --now sshd, set a root password with passwd, and connect with ssh root@<ip>.

Chapter 1: Delete the old Linux partitions, create the new ones

Confirm the disk first, then look hard at the table — you are about to delete partitions:

lsblk -o NAME,SIZE,FSTYPE,PARTTYPENAME,LABEL /dev/nvme0n1

Pin down, by size and type, which partition is which: the EFI System partition, Windows C: (NTFS, ~174.93 GiB), the old Ubuntu root (~419.1 GiB), the old swap (Linux swap, ~32.6 GiB), and the tiny recovery/MSR/OEM partitions at the end. You will delete only the old Ubuntu root and the old swap.

Open cfdisk on the GPT disk (a graphical GParted session from an Ubuntu/GParted-Live USB does the same job if you prefer pointing and clicking):

cfdisk /dev/nvme0n1

Inside cfdisk, move the highlight with / (or vim’s j/k) and press Enter to choose. Then:

  1. Highlight the old Ubuntu root (~419.1 GiB) → Delete.
  2. Highlight the old swap (~32.6 GiB, type Linux swap) → Delete.
  3. Do not delete the EFI System partition, Windows C:, or the tiny recovery/MSR/OEM partitions.
  4. The deletions plus the pre-existing unallocated regions now show as Free space. From that free space create three partitions, each left as the default Linux filesystem type:
    • New125G → shared Btrfs /data.
    • New260G → Arch root.
    • Newrest → Ubuntu root (~390 GiB).
  5. Write, then type yes. Quit.
If the free space comes out fragmented

cfdisk can only carve a partition out of one contiguous Free space region. If your unallocated areas don’t sit next to the partitions you just deleted, you may see more than one free-space block. Create each new partition from the largest block first; if the geometry won’t give you all three at the sizes above, adjust the sizes (the exact counts don’t matter downstream) or use gdisk/GParted, which expose the gaps more clearly. The only hard rule: never overlap or delete EFI, Windows, or the tiny recovery partitions.

Chapter 2: Re-identify the new partitions and set your variables

Deleting and recreating can renumber things, so re-read the table and map each new partition to a shell variable. Run this in every environment where you’ll touch the disk (here, the Arch chroot in Part 4, and the Ubuntu live session in Part 6) — a fresh shell doesn’t remember them.

lsblk -o NAME,SIZE,FSTYPE,PARTTYPENAME,LABEL /dev/nvme0n1

Set the four variables from what you see (replace each X with the real number; the X is deliberately invalid so an un-edited line errors instead of hitting the wrong disk):

EFI=/dev/nvme0n1pX        # existing ~584 MiB EFI System — DO NOT format
DATA=/dev/nvme0n1pX       # new 125 GiB shared Btrfs
ARCH=/dev/nvme0n1pX       # new ~260 GiB Arch root
UBUNTU=/dev/nvme0n1pX     # new ~390 GiB Ubuntu root

Sanity-check before continuing — sizes must match, and $EFI must be the FAT32 EFI System partition you are keeping:

lsblk -o NAME,SIZE,FSTYPE,PARTTYPENAME "$EFI" "$DATA" "$ARCH" "$UBUNTU"
poweroff

We format nothing here: the ESP already exists and stays as-is, and the Btrfs filesystems get made during the Arch install in Part 4. Next stop is the existing Windows, to tame it before GRUB lands.


Part 3 — Tame the existing Windows

Windows is already installed; we don’t reinstall it. Boot into it and change three settings. Skipping these is the #1 source of “my clock is wrong” and “Linux won’t boot after a Windows update.”

Chapter 3: Three settings from inside Windows

Disable BitLocker / device encryption

Windows 11 silently turns on device encryption on a lot of modern hardware, and BitLocker prompts for a recovery key whenever the boot path changes — and installing GRUB is a boot-path change. Turn it off:

  • Settings → Privacy & security → Device encryption → Off (wait for decryption to finish), or
  • on Pro editions, Control Panel → BitLocker Drive Encryption → Turn off for C:.

Disable Fast Startup

Fast Startup leaves Windows in a hybrid-hibernated state on shutdown, marking the NTFS volume “dirty” and confusing the next OS to boot. Turn it off:

  • Control Panel → Power Options → Choose what the power buttons do → Change settings that are currently unavailable → untick “Turn on fast startup” → Save.

Make Windows use UTC for the hardware clock

Without this, your clock jumps by your UTC offset every time you switch OS. From an Administrator Command Prompt or PowerShell:

reg add "HKLM\SYSTEM\CurrentControlSet\Control\TimeZoneInformation" /v RealTimeIsUniversal /t REG_DWORD /d 1 /f

Reboot Windows once so it re-reads the clock. From here on, all three OSes agree the hardware clock is UTC.

Why we fix the clock on the Windows side

You could set Linux to localtime (timedatectl set-local-rtc 1), but the Arch wiki discourages it, localtime RTC breaks around DST transitions. One registry value on Windows is the clean fix.


Part 4 — Install Arch into the Arch partition (plain Btrfs)

Boot the Arch live USB again. From here the Arch install is the standard plain-Btrfs build — the same Btrfs subvolumes, the shared /data, per-OS hibernation — with two differences from a single-boot: the partitions already exist (so we don’t repartition), and the ESP is the existing one, mounted at /boot/efi.

First, re-set the partition variables in this fresh live session (Chapter 2):

lsblk -o NAME,SIZE,FSTYPE,PARTTYPENAME /dev/nvme0n1
EFI=/dev/nvme0n1pX        # existing EFI System — DO NOT format
DATA=/dev/nvme0n1pX       # 125 GiB shared
ARCH=/dev/nvme0n1pX       # ~260 GiB Arch root
UBUNTU=/dev/nvme0n1pX     # ~390 GiB Ubuntu root (formatted later)

Chapter 5: Format the two Arch-side Btrfs filesystems

$DATA is the shared data partition; $ARCH is the Arch root. Leave $UBUNTU for Ubuntu, and never run mkfs against $EFI:

mkfs.btrfs -L shared "$DATA"     # shared data
mkfs.btrfs -L arch   "$ARCH"     # Arch root

Chapter 6: Create the Btrfs subvolumes

mount "$ARCH" /mnt
btrfs subvolume create /mnt/@
btrfs subvolume create /mnt/@home
btrfs subvolume create /mnt/@snapshots
btrfs subvolume create /mnt/@var_log
btrfs subvolume create /mnt/@swap
umount /mnt

mount "$DATA" /mnt
btrfs subvolume create /mnt/@data
umount /mnt

Five subvolumes on the Arch root, one on the shared partition. @ and @home are split so a system rollback never reverts your home directory; @snapshots is where Snapper writes; @var_log keeps noisy logs out of root snapshots; @swap is isolated for the NoCOW hibernation swap file. @data is the single subvolume on the shared partition.

Chapter 7: Mount everything (with the ESP at /boot/efi)

mount -o noatime,compress=zstd:3,subvol=@ "$ARCH" /mnt
mkdir -p /mnt/{home,.snapshots,var/log,swap,data,boot/efi}

mount -o noatime,compress=zstd:3,subvol=@home      "$ARCH" /mnt/home
mount -o noatime,compress=zstd:3,subvol=@snapshots "$ARCH" /mnt/.snapshots
mount -o noatime,compress=zstd:3,subvol=@var_log   "$ARCH" /mnt/var/log
mount -o noatime,subvol=@swap                      "$ARCH" /mnt/swap

mount "$EFI" /mnt/boot/efi
mount -o noatime,compress=zstd:3,subvol=@data "$DATA" /mnt/data

@swap mounts without compression — a swap file must never sit on a compressed mount. The ESP mounts at /mnt/boot/efi (not /mnt/boot), so /boot itself stays on the Btrfs @ root and holds the kernels. We mount /data now so genfstab captures it automatically in the next step.

Chapter 8: Pacstrap, fstab, chroot

reflector --country 'United States' --protocol https --latest 20 --age 12 \
          --sort rate --save /etc/pacman.d/mirrorlist

pacstrap -K /mnt \
    base linux linux-firmware linux-headers intel-ucode \
    btrfs-progs \
    grub efibootmgr grub-btrfs os-prober \
    snapper snap-pac \
    networkmanager openssh sudo \
    neovim git base-devel \
    zram-generator inotify-tools reflector

genfstab -U /mnt >> /mnt/etc/fstab
arch-chroot /mnt

Use amd-ucode on AMD hardware. The one addition over a single-boot install is os-prober, so GRUB discovers Windows (Ubuntu gets a manual chainload entry in Part 7). After genfstab, cat /etc/fstab should show five Arch-root subvolume lines (the same UUID, different subvol=), one line for /data on the shared filesystem’s UUID, and a vfat line for /boot/efi on the ESP’s UUID.

Chapter 9: Configure the system inside the chroot

This chapter covers locale/user (with the pinned UID), the /data hardening, the hibernation swap file, zram, and mkinitcpio. The fresh chroot shell doesn’t know your variables, so re-declare them first.

EFI=/dev/nvme0n1pX        # same partitions you set in Chapter 4
DATA=/dev/nvme0n1pX
ARCH=/dev/nvme0n1pX

Locale, time, hostname, user, with a pinned UID 1000

ln -sf /usr/bin/nvim /usr/local/bin/vi
echo 'export EDITOR=nvim' >> /etc/profile

ln -sf /usr/share/zoneinfo/<TIMEZONE> /etc/localtime    # e.g. America/Chicago
hwclock --systohc

sed -i 's/^#en_US.UTF-8/en_US.UTF-8/' /etc/locale.gen
locale-gen
echo "LANG=en_US.UTF-8" > /etc/locale.conf
echo "KEYMAP=us"        > /etc/vconsole.conf

echo "<HOSTNAME>" > /etc/hostname    # e.g. arch-triple
cat > /etc/hosts <<'EOF'
127.0.0.1   localhost
::1         localhost
127.0.1.1   arch-triple.localdomain arch-triple
EOF

passwd

groupadd -g 1000 <USERNAME>
useradd -m -u 1000 -g 1000 -G wheel,video,audio,input <USERNAME>
passwd <USERNAME>

sed -i 's/^# %wheel ALL=(ALL:ALL) ALL/%wheel ALL=(ALL:ALL) ALL/' /etc/sudoers

The pinned UID/GID 1000 is what makes file ownership on the shared /data match between Arch and Ubuntu. Linux stores ownership as integers, not names: if the same person is UID 1000 on Arch but 1001 on Ubuntu, /data ownership can’t line up in both directions. Pin 1000 on both sides — use the same username and -u 1000 -g 1000 when you create the Ubuntu user later.

Harden the shared /data and hand it to your user

The shared partition is plain Btrfs that genfstab already recorded. Soft-fail the mount and fix ownership:

# Soft-fail the /data mount (boot continues even if it's briefly missing)
sed -i 's#subvol=/@data#subvol=/@data,nofail,x-systemd.device-timeout=10s#' /etc/fstab

# Hand the shared @data subvolume to your UID-1000 user — a fresh Btrfs subvolume is root-owned,
# so without this the first-boot probe (echo … > /data/probe-arch.txt as your user) is Permission denied.
chown 1000:1000 /data

Ownership is stored in the filesystem, so Ubuntu (also UID 1000) will see these files as its own user automatically — no second chown needed from Ubuntu. The numeric 1000:1000 (not a username) is deliberate: it’s the integer ownership that has to match across both OSes, and it works even though Ubuntu’s user doesn’t exist yet.

Hibernation swap file + zram

btrfs filesystem mkswapfile --size 36g --uuid clear /swap/swapfile     # size ≥ your RAM
echo '/swap/swapfile none swap defaults,pri=-2 0 0' >> /etc/fstab
btrfs inspect-internal map-swapfile -r /swap/swapfile                   # save the integer → <RESUME_OFFSET>

cat > /etc/systemd/zram-generator.conf <<'EOF'
[zram0]
zram-size = ram / 2
compression-algorithm = zstd
swap-priority = 100
fs-type = swap
EOF

Don’t swapon from inside the chroot — activating the swap file from the live USB’s kernel blocks clean unmounting later, and /etc/fstab picks it up on first boot anyway. The two-tier arrangement: zram (priority 100) is compressed swap living in RAM, so it absorbs nearly all everyday memory pressure with zero SSD writes; the NoCOW Btrfs swap file (priority -2) is touched only on hibernation, when the kernel writes the full RAM image out to disk. mkswapfile creates it NoCOW and unfragmented, which is what makes a single, stable resume offset possible.

mkinitcpio

Open the mkinitcpio config:

nvim /etc/mkinitcpio.conf

Set these two lines:

MODULES=(btrfs)
HOOKS=(base systemd autodetect microcode modconf kms keyboard sd-vconsole block filesystems fsck)

MODULES=(btrfs) lets the initramfs read a Btrfs root. The HOOKS line is the standard systemd set; the root mounts directly from the Arch Btrfs partition at boot. Save, then rebuild:

mkinitcpio -P

Chapter 10: GRUB, cmdline, os-prober, install

The cmdline needs two machine-specific values: the Arch root Btrfs filesystem UUID and the swap-file resume offset. Print both now so you can paste them straight in without scrolling back.

Arch root Btrfs filesystem UUID:

blkid -s UUID -o value "$ARCH"

Resume offset — the byte offset (in pages) of the swap file’s first extent, the same integer you saved when you created the swap file earlier in this part. Re-print it here; the swap file already exists, so this is just a read:

btrfs inspect-internal map-swapfile -r /swap/swapfile

Now open the GRUB defaults file:

nvim /etc/default/grub

Set the GRUB_CMDLINE_LINUX_DEFAULT line, pasting in the two values you just printed (no angle brackets in the file):

GRUB_CMDLINE_LINUX_DEFAULT="loglevel=3 quiet zswap.enabled=0 resume=UUID=<ARCH_BTRFS_UUID> resume_offset=<RESUME_OFFSET>"

The resume target is the only thing you add by hand — grub-mkconfig auto-detects the root partition and subvol=@ for you.

Enable os-prober so GRUB discovers Windows — its boot manager is reliably detected on the ESP. (Ubuntu doesn’t exist yet; when it does, its Btrfs root won’t auto-detect, so it gets a chainload entry in Part 7.)

sed -i 's/^#GRUB_DISABLE_OS_PROBER=false/GRUB_DISABLE_OS_PROBER=false/' /etc/default/grub
grep '^GRUB_DISABLE_OS_PROBER=' /etc/default/grub      # GRUB_DISABLE_OS_PROBER=false

Install GRUB to the existing ESP (mounted at /boot/efi) and generate the config:

grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=GRUB
grub-mkconfig -o /boot/grub/grub.cfg

os-prober already has something to find, Windows:

Found Windows Boot Manager on /dev/nvme0n1pXX@/EFI/Microsoft/Boot/bootmgfw.efi

…and grub.cfg will contain an Arch entry plus a Windows Boot Manager entry that chainloads bootmgfw.efi. Ubuntu doesn’t exist yet.

Chapter 11: Enable services, sanity-check, reboot

systemctl enable NetworkManager sshd fstrim.timer reflector.timer \
                 snapper-timeline.timer snapper-cleanup.timer grub-btrfsd.service

# quick sanity pass
grep '^MODULES='                    /etc/mkinitcpio.conf
grep '^HOOKS='                      /etc/mkinitcpio.conf
grep '^GRUB_CMDLINE_LINUX_DEFAULT=' /etc/default/grub
grep '^GRUB_DISABLE_OS_PROBER='     /etc/default/grub
grep -E 'subvol=|swap|/data|/boot/efi' /etc/fstab

Leave the chroot and reboot:

exit
umount -R /mnt
reboot

Pull the USB. Expected first boot: firmware → GRUB menu (listing Arch Linux and Windows Boot Manager) → pick Arch → kernel/initramfs → @ mounts as //data mounts, zram comes up → login. If you drop into emergency mode, jump to the 🚨 emergency section.


Part 5 — Arch first boot: verify partitions, snapper, hibernation

Log in as your user:

findmnt /            # subvol=/@, on your Arch partition
findmnt /home        # subvol=/@home
findmnt /boot/efi    # vfat, on the existing EFI partition
findmnt /data        # subvol=/@data, on the shared partition
swapon --show        # zram0 (pri 100) + /swap/swapfile (pri -2)

Reconnect Wi-Fi, re-enable NTP, verify the timezone:

nmcli radio wifi on
nmcli device wifi connect "<SSID>" password "<WIFI_PASSWORD>"
ping -c 3 ping.archlinux.org
sudo timedatectl set-ntp true
timedatectl status      # correct Time zone, "System clock synchronized: yes", "RTC in local TZ: no"

Verify the shared data partition is owned by you

echo "from arch" > /data/probe-arch.txt                # Linux-only (Windows can't read Btrfs)
stat -c '%u %g' /data/probe-arch.txt                   # 1000 1000 — confirms the pinned UID

Configure snapper for /

A plain create-config collides with the @snapshots we already mounted; the Arch-recommended dance avoids it:

sudo umount /.snapshots
sudo rm -rf /.snapshots
sudo snapper -c root create-config /
sudo btrfs subvolume delete /.snapshots
sudo mkdir /.snapshots
sudo chmod 750 /.snapshots
sudo mount -a

sudo snapper -c root set-config \
    TIMELINE_LIMIT_HOURLY=5 TIMELINE_LIMIT_DAILY=7 \
    TIMELINE_LIMIT_WEEKLY=2 TIMELINE_LIMIT_MONTHLY=1 TIMELINE_LIMIT_YEARLY=0
sudo snapper -c root list      # at least one row

Test hibernation before installing Ubuntu

sudo systemctl hibernate

Press power to resume; expect your exact session restored (no passphrase prompt). If it boots fresh, re-check resume=/resume_offset= and re-run mkinitcpio -P && grub-mkconfig -o /boot/grub/grub.cfg.

Verify hibernation now, while only Arch + Windows exist

Fixing hibernation after Ubuntu has rewritten parts of the boot path is meaningfully harder. Confirm it works here.


Part 6 — Install Ubuntu 24 LTS into the Ubuntu partition

We build Ubuntu’s root by hand with debootstrap, into $UBUNTU. Windows being present changes nothing about the Ubuntu steps; the unified menu gets built from Arch’s GRUB in Part 7.

From the Arch side, snapshot the current NVRAM so you can compare after Ubuntu reorders it:

efibootmgr -v > ~/efi-before-ubuntu.txt

Boot a verified Ubuntu 24.04 LTS Desktop USB and pick “Try Ubuntu”, then build Ubuntu’s root by hand from the live session with debootstrap.

Driving it remotely over SSH?

The Ubuntu Desktop live image has no SSH server and logs you in as a passwordless ubuntu user, so set it up first: sudo apt install -y openssh-server, passwd ubuntu (give the live user a password), sudo systemctl enable --now ssh, then from the other machine ssh ubuntu@<ip> and sudo -i to become root.

Open a terminal, sudo -i, apt update && apt install -y debootstrap, then set the partition variables in this Ubuntu live session (their numbers are the same as before; re-confirm with lsblk):

lsblk -o NAME,SIZE,FSTYPE,PARTTYPENAME,LABEL /dev/nvme0n1
EFI=/dev/nvme0n1pX        # existing EFI System — do NOT reformat
DATA=/dev/nvme0n1pX       # 125 GiB shared (LABEL=shared)
UBUNTU=/dev/nvme0n1pX     # ~390 GiB Ubuntu root
# 1 — format the Ubuntu partition as Btrfs + subvolumes
mkfs.btrfs -L ubuntu "$UBUNTU"
mount "$UBUNTU" /mnt
btrfs subvolume create /mnt/@ && btrfs subvolume create /mnt/@home && umount /mnt

# 2 — mount target + the existing shared ESP
mount -o noatime,compress=zstd:3,subvol=@ "$UBUNTU" /mnt
mkdir -p /mnt/home /mnt/boot/efi
mount -o noatime,compress=zstd:3,subvol=@home "$UBUNTU" /mnt/home
mount "$EFI" /mnt/boot/efi                            # existing ESP — do not reformat

# 3 — bootstrap + fstab (root, home, ESP at /boot/efi, shared /data)
debootstrap --arch amd64 noble /mnt http://archive.ubuntu.com/ubuntu
UB=$(blkid -s UUID -o value "$UBUNTU")
ESP=$(blkid -s UUID -o value "$EFI")
SHARED=$(blkid -s UUID -o value "$DATA")
printf 'UUID=%s / btrfs noatime,compress=zstd:3,subvol=@ 0 0\nUUID=%s /home btrfs noatime,compress=zstd:3,subvol=@home 0 0\nUUID=%s /boot/efi vfat umask=0077 0 1\nUUID=%s /data btrfs rw,noatime,compress=zstd:3,subvol=@data,nofail,x-systemd.device-timeout=10s 0 0\n' "$UB" "$UB" "$ESP" "$SHARED" >> /mnt/etc/fstab
mkdir -p /mnt/data

# 4 — chroot
cp /etc/resolv.conf /mnt/etc/resolv.conf
for d in dev dev/pts proc sys run; do mount --rbind /$d /mnt/$d 2>/dev/null || mount --bind /$d /mnt/$d; done
chroot /mnt /bin/bash

Inside the chroot, widen the sources list, install the system (a plain grub-install onto the existing ESP at /boot/efi), and create the UID-1000 user:

cat > /etc/apt/sources.list <<EOF
deb http://archive.ubuntu.com/ubuntu  noble           main restricted universe multiverse
deb http://archive.ubuntu.com/ubuntu  noble-updates   main restricted universe multiverse
deb http://security.ubuntu.com/ubuntu noble-security  main restricted universe multiverse
EOF
apt update
apt install -y ubuntu-standard linux-image-generic grub-efi-amd64 btrfs-progs network-manager sudo locales tzdata
update-initramfs -u -k all
grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=Ubuntu --recheck
update-grub                                          # builds Ubuntu's own GRUB menu
groupadd -g 1000 <USERNAME>; useradd -m -u 1000 -g 1000 -s /bin/bash -G sudo,adm,cdrom,dip,plugdev <USERNAME> && passwd <USERNAME>
apt install -y ubuntu-desktop-minimal

The shared /data line is already in fstab from step 3, so there’s nothing more to add. Exit and reboot:

exit
sync
reboot
Why reboot instead of unmounting by hand

The mount --rbind /run /mnt/run from entering the chroot is recursive, it pulls /run/user/0 and the live session’s snap mount namespaces in under /mnt/run, so umount -R … tends to fail with target is busy. Everything’s on disk; sync flushes and reboot tears down every mount the hard way.

Ubuntu reorders NVRAM to boot its own GRUB first

We undo that in Part 7 — booting Arch via the firmware menu, then setting Arch’s GRUB first with efibootmgr — because Arch’s GRUB is the one that gets the unified menu.

Chapter 12: Add Ubuntu’s hibernation swap file

Boot Ubuntu from the GRUB menu, log in, and mirror Arch’s per-OS hibernation:

findmnt /                                                    # your Ubuntu partition
sudo btrfs subvolume create /swap
sudo btrfs filesystem mkswapfile --size 36g --uuid clear /swap/swapfile
echo '/swap/swapfile none swap defaults,pri=-2 0 0' | sudo tee -a /etc/fstab
sudo btrfs inspect-internal map-swapfile -r /swap/swapfile   # save → <UBUNTU_RESUME_OFFSET>

Point Ubuntu’s cmdline and initramfs at its own Btrfs filesystem (never Arch’s). The resume offset is the integer you printed just above with map-swapfile; print the Ubuntu root UUID too:

findmnt -no SOURCE /     # the device under your Ubuntu root, e.g. /dev/nvme0n1pXX
sudo blkid -s UUID -o value "$(findmnt -no SOURCE / | sed 's/\[.*\]//')"   # this is <UBUNTU_BTRFS_UUID>

Open the GRUB defaults file as root. sudoedit uses your $EDITOR, and a fresh Ubuntu hasn’t set one — so it opens nano (edit, then Ctrl-O, Enter to save and Ctrl-X to exit). Prefer nvim? Run sudo apt install -y neovim && export EDITOR=nvim first.

sudoedit /etc/default/grub

Ubuntu’s GRUB_CMDLINE_LINUX_DEFAULT typically reads quiet splash. Add resume= and resume_offset=, pasting in the two values (no angle brackets in the file):

GRUB_CMDLINE_LINUX_DEFAULT="quiet splash resume=UUID=<UBUNTU_BTRFS_UUID> resume_offset=<UBUNTU_RESUME_OFFSET>"

Tell Ubuntu’s initramfs about the resume device, then rebuild both the initramfs and GRUB:

echo "RESUME=UUID=<UBUNTU_BTRFS_UUID>" | sudo tee /etc/initramfs-tools/conf.d/resume
sudo update-initramfs -u -k all
sudo update-grub
Expected: update-initramfs warns “no matching swap device is available”

You’ll see this, and it’s harmless with a swap file:

W: initramfs-tools configuration sets RESUME=UUID=<uuid>
W: but no matching swap device is available.

initramfs-tools validates RESUME= by looking for a swap partition whose UUID matches. Your swap is a file inside the Btrfs filesystem <uuid>, not a separate swap device, so there’s nothing for it to match. What actually performs the resume is the kernel cmdline resume=UUID=… resume_offset=… you set in /etc/default/grub: the kernel resolves the UUID and seeks to the offset. The real test is to actually hibernate (next step) — the swap file also isn’t active in this just-created session yet, fstab activates it on the next boot.

Confirm Ubuntu sees the shared partition, then test hibernation:

findmnt /data
swapon --show                    # zram + /swap/swapfile
ls -l /data                      # files written from Arch appear, owned by user
sudo systemctl hibernate         # resumes into Ubuntu, separate from Arch's image

Part 7 — Build the unified GRUB menu from Arch

All three OSes now exist, but Ubuntu’s install made its GRUB the EFI default — and neither distro’s os-prober reliably auto-detects the other Linux (a Btrfs @-subvolume root isn’t seen on a bare mount). Windows, though, is detected reliably. So we drive the menu from Arch’s GRUB: os-prober supplies the Windows entry, and we add a chainload entry for Ubuntu by hand.

1 — Boot Arch. Ubuntu’s menu won’t list Arch, so reach Arch’s bootloader directly: at power-on, open the firmware one-time boot menu (F12/Esc, vendor-dependent) and pick the GRUB entry (Arch’s; Ubuntu’s is Ubuntu, Windows’ is Windows Boot Manager).

2 — Confirm Ubuntu’s EFI path, then add the chainload entry. The directory name on the ESP matches the --bootloader-id you used (Ubuntu/EFI/Ubuntu/). Confirm it, since case matters in the chainloader line:

ls /boot/efi/EFI            # look for the Ubuntu directory (e.g. Ubuntu or ubuntu)
ESP_DEV=$(findmnt -no SOURCE /boot/efi)
ESP_UUID=$(blkid -s UUID -o value "$ESP_DEV")
echo "$ESP_UUID"            # sanity: a short FAT id like 7F6E-D6AA

sudo tee /etc/grub.d/40_custom >/dev/null <<EOF
#!/bin/sh
exec tail -n +3 \$0
menuentry "Ubuntu 24.04 LTS" {
    insmod part_gpt
    insmod fat
    insmod chain
    search --no-floppy --fs-uuid --set=root $ESP_UUID
    chainloader /EFI/Ubuntu/grubx64.efi
}
EOF
sudo chmod +x /etc/grub.d/40_custom

cat /etc/grub.d/40_custom to confirm the search … line ends with your UUID on one line, the chainloader path matches what ls /boot/efi/EFI showed, and the block closes with } (a wrapped paste is the one thing that breaks it), then regenerate so os-prober also picks up Windows:

sudo grub-mkconfig -o /boot/grub/grub.cfg

You should see os-prober report Windows:

Found Windows Boot Manager on /dev/nvme0n1pXX@/EFI/Microsoft/Boot/bootmgfw.efi

grub.cfg now lists Arch (direct), Windows Boot Manager (os-prober), and Ubuntu 24.04 LTS (your chainload entry). There’s no “Found Ubuntu” line, and that’s expected — Ubuntu comes from 40_custom, not os-prober.

3 — Make Arch’s GRUB the EFI default:

sudo efibootmgr                            # find the BootNNNN for "GRUB", "Ubuntu", "Windows Boot Manager"
sudo efibootmgr -o <GRUB_NUM>,<UBUNTU_NUM> # Arch's GRUB first; Windows stays reachable via the GRUB menu

Reboot. Arch’s GRUB lists all three: Arch boots directly, Ubuntu chainloads its own GRUB, Windows chainloads its boot manager.

Why Ubuntu is a chainload but Windows isn’t

os-prober finds Windows fine — its boot manager sits on the FAT ESP where os-prober looks. But it detects another Linux by mounting that partition bare and looking for an OS root, and a Btrfs @-subvolume root isn’t there on a bare mount (you get a folder of @, @home, … instead). So Ubuntu gets a hand-written chainload entry, which also never goes stale on a Ubuntu kernel update. To make selecting Ubuntu boot straight through (skip its second GRUB), set GRUB_TIMEOUT=0 + GRUB_TIMEOUT_STYLE=hidden in Ubuntu’s /etc/default/grub, then sudo update-grub.


Part 8 — Final sanity checks across three OSes

The pass condition

All three boot from one GRUB menu; Arch and Ubuntu each hibernate into themselves and mount the shared /data with identical ownership; Windows boots, keeps correct time, and never prompts for a BitLocker key on a normal boot.

From Arch / Ubuntu (run on each):

findmnt / /home /boot/efi /data
swapon --show                       # zram + that OS's own /swap/swapfile
sudo systemctl hibernate            # resumes into the same OS
stat -c '%u %g %n' /data/*          # all 1000 — pinned-UID parity

From Windows: confirm the clock is correct after switching from Linux (proves the RealTimeIsUniversal fix), that Windows boots normally with no BitLocker prompt, and that its own partitions and recovery options are intact.


🚨 Emergency: triple-boot-specific failure modes

If Part 8 passed, skip this. A few Linux-side issues can bite either distro:

  • /data doesn’t mount (you still reach a normal login thanks to nofail; findmnt /data prints nothing): the UUID in /etc/fstab doesn’t match the partition — blkid the shared partition and compare, then sudo mount /data.
  • Boot drops to emergency mode: usually a typo on a non-nofail fstab line (/, /home, or /boot/efi). Boot the Arch USB, mount -o subvol=@ "$ARCH" /mnt, mount "$EFI" /mnt/boot/efi, arch-chroot /mnt, fix /etc/fstab, reboot.
  • Hibernation resumes into the other OS: each OS’s resume= must point at its own Btrfs filesystem UUID (Arch root vs. Ubuntu root), never the other’s.

The Windows-specific failures are below.

1. GRUB menu is missing Windows or Ubuntu

No Windows entry (Windows comes from os-prober):

  • os-prober installed and enabled? grep ^GRUB_DISABLE_OS_PROBER= /etc/default/grubfalse; pacman -S os-prober.
  • ESP mounted at /boot/efi when you ran grub-mkconfig? os-prober finds bootmgfw.efi on the EFI partition. findmnt /boot/efi, then re-run.
  • Windows installed in UEFI mode? Confirm \EFI\Microsoft\Boot\bootmgfw.efi exists: ls /boot/efi/EFI/Microsoft/Boot/.

No Ubuntu entry (Ubuntu comes from your 40_custom chainload, not os-prober): cat /etc/grub.d/40_custom and confirm the search … line ends with the ESP UUID on one line and the block closes with }; make it executable (sudo chmod +x /etc/grub.d/40_custom), then sudo grub-mkconfig -o /boot/grub/grub.cfg. If selecting Ubuntu lands at grub>, Secure Boot is blocking the unsigned chainload (disable it in firmware), or the path is wrong: re-check ls /boot/efi/EFI/Ubuntu/grubx64.efi against the case in your chainloader line.

2. A Windows update stole the boot order (boots straight to Windows)

Boot Linux (force the firmware boot menu once), then:

sudo efibootmgr                                  # note the GRUB and Windows BootNNNN numbers
sudo efibootmgr -o <GRUB_NUM>,<WINDOWS_NUM>      # put GRUB first again

Nothing was lost, only the order changed. GRUB still chainloads Windows fine.

3. Clock is wrong after switching OS

The RealTimeIsUniversal registry value (Part 3) wasn’t set or didn’t take. Re-apply it from an Administrator prompt in Windows and reboot Windows once. Do not set Linux to localtime, that just moves the bug.

4. Windows demands a BitLocker recovery key after you installed GRUB

Device encryption was still on when the boot path changed. Enter the recovery key (from aka.ms/myrecoverykey), then turn device encryption off (Part 3) so it stops re-prompting.

5. You deleted the wrong partition

If you removed Windows C: or a recovery/MSR/OEM partition by mistake, stop — don’t write anything else to the disk. Partition-table deletions (not the data) are often recoverable with testdisk from the live USB, which can rewrite the GPT entries if the filesystem still exists. This is exactly why Part 2 has you identify every partition by size and type before deleting, and only ever delete the old Ubuntu root and old swap.


Part 9 — Closing the loop

You now have a single drive that still boots the Windows you started with, plus a freshly rebuilt Linux zone: two plain-Btrfs roots (Arch and Ubuntu), a shared /data the two Linux distros mount as one home for documents, per-OS hibernation that never collides, and one GRUB menu — all without touching Windows, its ESP, or its recovery partitions, and without a single passphrase prompt or a single byte of LUKS to go wrong at boot.

The tradeoff you made is the one Part 1 spelled out: nothing on the Linux side of this disk is encrypted at rest. That’s the right call for a stationary, physically-secured machine and the wrong one for a laptop. If the machine’s role changes, adding encryption means rebuilding on LUKS2 — the same layout, but with a LUKS2 container around each Linux root and the shared /data, plus the extra boot-path setup an encrypted disk needs. There’s no in-place way to add it; decide up front.

The desktop layer for the Arch side is still a separate choice, a custom Hyprland is the direction I’m taking (upcoming), and it doesn’t touch any of the multi-boot machinery below it.


Attribution

The Arch Linux logo is a trademark of the Arch Linux project and is used here under Arch Linux’s branding terms for editorial purposes only. The Ubuntu wordmark and Circle of Friends are trademarks of Canonical Ltd.; the Windows name and logo are trademarks of Microsoft Corporation, references here are editorial and follow each owner’s trademark policy.