Cloning the OS Drive to a Smaller SSD (UEFI)

Cloning the OS Drive to a Smaller SSD (UEFI)

Search terms: clone OS drive · migrate to SSD · smaller target · dd won’t fit · rsync clone · UEFI · ESP · grub-install · chroot · fstab UUID · unbootable after clone · media server SSD

Written 2026-08-10 for migrating media-server from a 931 GB WD Black HDD to a 500 GB Samsung 870 EVO, over a USB-SATA enclosure.

dd cannot be used here — the source device is larger than the target, so a block copy refuses regardless of how little data is actually on it. This is a filesystem-level copy.


Source layout (media-server, recorded 2026-08-10)

/dev/sda  931.5G   GPT, UEFI
  ├─ sda1   512M  vfat  UUID=1FFA-656B                             → /boot/efi   (6.2M used)
  └─ sda2   931G  ext4  UUID=569e1db7-940a-496e-a90c-0bdd60e5122d  → /           (88G used)

Swap is a file (/swapfile), not a partition — it copies with everything else, or can be excluded and recreated. No separate /home. 88 GB of real data into a 500 GB target.


The four ways this goes wrong

Read these before starting. Each has a guard step below.

  1. Writing to the wrong device. USB enumeration is not stable — /dev/sdb today may be something else tomorrow. Always confirm by model and serial, never by device letter.
  2. fstab still pointing at the old UUIDs. New filesystems get new UUIDs. Miss this and the clone won’t boot.
  3. Bootloader not installed to the new ESP. The copy will be perfect and the machine still won’t start.
  4. Both drives connected at first boot. Two valid ESPs, and firmware picks whichever it likes — you may believe the clone booted when the original did.

Step 0 — Fingerprint the system BEFORE you touch anything

Do this first, on the old drive, while everything still works. Without a before-picture, “did the clone work?” is a judgement call made by someone who wants the answer to be yes.

sudo /home/aztechguy/.local/bin/system-fingerprint.sh capture ~/fingerprint-BEFORE.txt

Use sudo — without it, root’s crontab and the Plex database are unreadable, and the script will tell you so rather than quietly omitting them.

It records packages, enabled services (system and user), both crontabs, checksums of key /etc files, whether every fstab target is actually mounted, file counts per top-level directory, and Plex’s libraries with per-library file counts. Takes ~3 seconds, produces ~3,200 lines, changes nothing.

Copy it somewhere that isn’t the drive you’re about to replace:

scp ~/fingerprint-BEFORE.txt aztechguy@10.0.0.34:~/

Why this step exists. The 2026-08-11 media migration finished with 116 videos invisible to Plex — the folders were there, but had never been added as library roots. It went unnoticed for months because nothing had ever counted them. That was media. This is the operating system.


Step 1 — Identify the target, unambiguously

Plug the SSD into the USB-SATA enclosure, then:

lsblk -o NAME,SIZE,MODEL,SERIAL,TRAN,MOUNTPOINT

The actual target (received 2026-08-13, verified new before use)

Model:    Samsung SSD 870 EVO 500GB       465.8 GiB / 500 GB
Serial:   S6PXNL0W611817D
Firmware: SVT02B6Q
SMART:    PASSED · Power_On_Hours 0 · Power_Cycle_Count 3 · Total_LBAs_Written 0

Total_LBAs_Written: 0 is what proves a drive is genuinely new rather than a return or refurb — a used drive always carries writes, however it was wiped. Worth checking on any new disk before building on it:

sudo smartctl -a -d sat /dev/sdX | grep -iE 'Serial|Power_On_Hours|Power_Cycle|LBAs_Written|health'

ROTA=1 over USB is expected and not a fault. Most USB-SATA bridges don’t pass the rotational flag through, so Linux assumes spinning rust. It reports ROTA=0 only once the drive is on native SATA — which is why the ROTA check belongs in Step 8 (after the swap), not here.


⚠️ The device-letter trap, specific to this machine

On a laptop this SSD appears as /dev/sda because the OS lives on NVMe. On media-server, /dev/sda is already the OS drive you are replacing. Attached over USB the new SSD will land somewhere like /dev/sde — and a command aimed at sda out of habit destroys the source.

Match the serial, never the letter:

lsblk -dno NAME,MODEL,SERIAL,SIZE,TRAN | grep S6PXNL0W611817D

That must print exactly one line, and its device name is the only one to use below.


Find the row with MODEL = your SSD and TRAN = usb. Write that device name down and re-verify it before every destructive command:

TARGET=/dev/sdX        # <-- set this once, from the output above
lsblk -dno MODEL,SERIAL,SIZE "$TARGET"

If that doesn’t print your new SSD’s model and serial, stop. Every command below writes to $TARGET, and a wrong value destroys a drive holding your library.


Step 2 — Partition and format

sudo wipefs -a "$TARGET"
sudo parted -s "$TARGET" mklabel gpt
sudo parted -s "$TARGET" mkpart ESP fat32 1MiB 513MiB
sudo parted -s "$TARGET" set 1 esp on
sudo parted -s "$TARGET" mkpart root ext4 513MiB 100%
sudo parted -s "$TARGET" print
sudo mkfs.vfat -F32 "${TARGET}1"
sudo mkfs.ext4 -F "${TARGET}2"

Record the new UUIDs — Step 5 needs them:

lsblk -o NAME,UUID,FSTYPE "$TARGET"

Step 3 — Quiesce the system

Plex writes to its ~300 MB SQLite database constantly. Copying it live risks an inconsistent snapshot.

sudo systemctl stop plexmediaserver

Nothing else on this box writes heavily to the root filesystem. The trading crons and NAS mounts are unaffected.


Step 4 — Copy

sudo mkdir -p /mnt/newroot /mnt/newesp
sudo mount "${TARGET}2" /mnt/newroot
sudo mount "${TARGET}1" /mnt/newesp

Root filesystem. -x is the flag that matters most, and the one this runbook originally omitted — see the warning below:

sudo rsync -aAXHvx --info=progress2 \
  --exclude={"/dev/*","/proc/*","/sys/*","/run/*","/tmp/*","/mnt/*","/media/*","/lost+found","/swapfile"} \
  / /mnt/newroot/

⚠️ -x (–one-file-system) is not optional

Learned the hard way 2026-08-14. The first version of this runbook listed excludes for /mnt/* and /media/* — which correctly stopped the NAS and the data drives — but nothing stopped /snap.

Every snap revision is its own read-only squashfs mount. This machine has 68 of them. Without -x, rsync descends into every one and expands compressed images into real files:

du -sh --one-file-system /snap    152K     <- the real directory structure
du -sh /snap                       41G     <- crossing into all 68 mounts

A 54 GB root copied as 97 GB. It fits on a 500 GB target, so nothing fails loudly — you just get 41 GB of shadow files hidden under mount points that snapd will later mount over, and a snap refresh that eventually fails trying to rmdir a non-empty revision directory.

-x makes the /mnt/* and /media/* excludes redundant — it stops rsync at every filesystem boundary, including ones you didn’t think to enumerate. Enumerating mounts by hand is the mistake; -x covers the ones you forgot and the ones added later.

If you already ran it without -x

--delete will not clean this up. rsync only deletes extraneous files in directories whose contents it actually transferred — and with -x it never descends into a mount point, so it never looks inside and never deletes there. Re-running with -x --delete fixes future copies but leaves the existing junk untouched.

Empty the mount-point directories directly. This touches only paths derived from real source mount points, and only two levels below /snap:

sudo bash -c '
findmnt -rno TARGET /mnt/newroot >/dev/null || { echo "ABORT: not mounted"; exit 1; }
echo "before: $(du -sh /mnt/newroot/snap | cut -f1), depth3+=$(find /mnt/newroot/snap -mindepth 3 | wc -l)"
findmnt -rno TARGET | grep "^/snap/" | while read m; do
  t="/mnt/newroot$m"
  case "$t" in /mnt/newroot/snap/*/*) [ -d "$t" ] && rm -rf "$t"/* "$t"/.[!.]* 2>/dev/null ;; esac
done
echo "after : $(du -sh /mnt/newroot/snap | cut -f1), depth1-2=$(find /mnt/newroot/snap -maxdepth 2 -mindepth 1 | wc -l)"
echo "source: $(du -sh --one-file-system /snap | cut -f1), depth1-2=$(find /snap -maxdepth 2 -mindepth 1 | wc -l)"'

The number that proves it worked is depth1-2, and it must match the source — 153 on this machine. The mount-point directories themselves must survive, because snapd mounts each squashfs over them at boot. You are emptying them, not removing them.

Then the ESP:

sudo rsync -aAXv /boot/efi/ /mnt/newesp/

Recreate the excluded mount points and swapfile:

sudo mkdir -p /mnt/newroot/{dev,proc,sys,run,tmp,mnt,media}
sudo chmod 1777 /mnt/newroot/tmp
sudo fallocate -l 2G /mnt/newroot/swapfile && sudo chmod 600 /mnt/newroot/swapfile && sudo mkswap /mnt/newroot/swapfile

(Match -l to your existing swapfile size — check with ls -lh /swapfile.)


Step 5 — Point fstab at the new UUIDs

The step most clones fail on. Get the new values:

NEW_ROOT=$(sudo blkid -s UUID -o value "${TARGET}2")
NEW_ESP=$(sudo blkid -s UUID -o value "${TARGET}1")
echo "root=$NEW_ROOT   esp=$NEW_ESP"

Edit the copy, not the live one:

sudo nano /mnt/newroot/etc/fstab

Replace only the two OS lines:

UUID=<NEW_ROOT>  /           ext4  errors=remount-ro  0  1
UUID=<NEW_ESP>   /boot/efi   vfat  umask=0077         0  1

Leave the data-drive lines exactly as they are4ce8b8b4…, 9c6a2a5e…, and the CIFS mounts all live on other disks and their UUIDs do not change.

Verify no stale references survive:

grep -E "569e1db7|1FFA-656B" /mnt/newroot/etc/fstab && echo ">>> OLD UUIDs STILL PRESENT — fix before continuing" || echo "clean"

Step 6 — Install the bootloader

for d in dev dev/pts proc sys run; do sudo mount --bind /$d /mnt/newroot/$d; done
sudo mount --bind /mnt/newesp /mnt/newroot/boot/efi

sudo chroot /mnt/newroot /bin/bash -c '
  grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=ubuntu --recheck &&
  update-grub &&
  echo "--- grub.cfg root reference ---" &&
  grep -m2 "root=UUID" /boot/grub/grub.cfg
'

That last grep should show the new root UUID. If it shows the old one, update-grub read a stale fstab — go back to Step 5.

Then unmount cleanly:

sudo umount -R /mnt/newroot/boot/efi /mnt/newroot/{run,sys,proc,dev/pts,dev} 2>/dev/null
sudo umount /mnt/newroot /mnt/newesp
sudo systemctl start plexmediaserver

Step 7 — Swap the hardware

  1. Shut down.
  2. Disconnect the old drive. Not optional — with both attached, firmware may boot the original and you’ll wrongly conclude the clone works.
  3. Mount the SSD in the ORICO 2.5→3.5 bracket, connect SATA + power.
  4. Boot. Enter firmware setup if needed and confirm the boot entry points at the new disk.

Expect it to take a few attempts — and why

It did on 2026-08-14. Two things went wrong, both mundane, neither a clone failure:

1. Cables. Obvious in hindsight, easy to get wrong reaching into a case. Check SATA and power are properly seated on the new drive before closing anything up.

2. The firmware fell through to PXE / network boot. This is the one that looks catastrophic and isn’t. Removing the old drive invalidates the NVRAM boot entry that pointed at it. With no valid entry, the firmware works down its list and lands on network boot — so you get a PXE screen and assume the clone didn’t work.

The clone is fine. The boot order isn’t. Go into setup and explicitly select the Samsung SSD as the first boot device.

This is also why Step 6 uses --no-nvram and why both EFI paths matter. --no-nvram avoids writing an entry pointing at a USB device path that would be wrong after the swap, and /EFI/BOOT/BOOTX64.EFI is the fallback the firmware tries once you point it at the right disk. Setting boot order by hand, once, is the intended final step — not a sign of trouble.

The new drive inherits /dev/sda once the old one is gone. Any note referring to sda on this machine now means the SSD.

Keep the old drive intact until verification passes. It is your rollback — reconnect it and you’re back where you started.


Step 8 — Verify after booting

lsblk -o NAME,SIZE,MODEL,MOUNTPOINT
findmnt / /boot/efi
df -h /
systemctl status plexmediaserver --no-pager | head -5
systemctl --failed --no-pager
findmnt -t cifs -o TARGET,OPTIONS | grep -oE "/mnt/nas[0-9.]*|vers=[0-9.]+"

Expected: root on the SSD, ~88 GB used of ~460 GB, Plex active, no failed units, all three CIFS mounts back at vers=3.0.

Confirm the SSD is genuinely being used:

lsblk -dno NAME,ROTA,MODEL   # ROTA=0 means SSD
sudo smartctl -A /dev/sda | grep -iE "Percentage Used|Data Units Written"

The check that actually decides it — diff against Step 0

sudo /home/aztechguy/.local/bin/system-fingerprint.sh capture ~/fingerprint-AFTER.txt
/home/aztechguy/.local/bin/system-fingerprint.sh compare ~/fingerprint-BEFORE.txt ~/fingerprint-AFTER.txt

Read the output carefully — “no differences” is not automatically the pass condition here.

Section Expected after a good clone
PACKAGES identical. Any difference means the copy was incomplete
SERVICES-ENABLED identical, system and user both
CRON-USER / CRON-ROOT identical
PLEX-LIBRARIES identical root counts and file counts
MOUNTS-FSTAB every target still MOUNTED
cfg …/etc/fstab MUST DIFFER — see below
FILECOUNT under /home, /etc small drift is normal between boots

An /etc/fstab checksum that matches byte-for-byte is a FAILURE, not a pass. The clone needs new root and ESP UUIDs; an unchanged fstab means Step 5 never happened and the new disk is still mounting the old drive’s filesystems. It will appear to boot fine — right up until you disconnect the original.

Any !! UNAVAILABLE line means that section was never compared. Re-run the capture with sudo rather than accepting an unverified section as a match.


Notes

  • Do not delete the old drive’s contents until the clone has run for a few days. It costs nothing to leave it on a shelf and it is the only complete rollback.

  • -aAXH preserves ACLs, extended attributes and hardlinks. Dropping -A or -X silently loses file capabilities and SELinux/AppArmor labels — subtle and hard to diagnose later.

  • An alternative is restoring the Timeshift backup on sdd1 to the new disk from live media. That handles partitioning and bootloader automatically and is less error-prone, at the cost of restoring a snapshot rather than the current state.

    Only snapshots from 2026-08-10 onward are complete. Every earlier one was taken while Timeshift excluded /home — restoring one would produce a booting server with an empty home directory: no SSH keys, no Discord webhook, no smart-health-check.sh. Check before trusting a snapshot as a clone source:

    S=/media/aztechguy/TimeshiftBackup/timeshift/snapshots/<snapshot>
    du -sh "$S/localhost/home/aztechguy"     # ~516M = complete;  4.0K = home was excluded
    

    Timeshift generates /home/<user>/** and a /home/*/** catch-all at snapshot time regardless of the config’s exclude array — deleting the home line does nothing. The fix is an include, "+ /home/aztechguy/**", which is what the GUI’s Users tab writes. Its own cache rules (/home/*/.cache and friends) sit earlier in the generated exclude.list and still win, which is why a complete home reads 516M rather than the 735M on disk.

  • Related: System Update Runbook — run post-update-smoke-test.sh after the swap to confirm nothing broke.