backuptousb
Packs folders straight onto an external disk as one compressed archive per folder. Written because copying many small files to USB is slow in a way that makes no sense until you know why.
The problem it solves
Drag a folder of 50,000 small files onto a USB stick and you get 2 MB/s on a disk that can comfortably do 100. The disk is not the slow part. Every single file means a round-trip of open, write, close, and those round-trips are what you are waiting for, not the data itself.
One archive is a single sequential stream of bytes. That gets you full disk speed. This is the whole trick, and tar has been able to do it for forty years. This script just wraps it in something you can stand to use daily, with progress, safety rails and sensible defaults.
Getting started
Download the file, make it executable, and add an alias so it is available anywhere:
$ chmod +x ~/scripts/backuptousb $ echo "alias backuptousb='~/scripts/backuptousb'" >> ~/.bashrc $ source ~/.bashrc
Then open the file and set the default destination at the top, in the EDIT ME block. It is the only setting most people need to touch:
DEFAULT_DEST="/media/youruser/usbdiskname/backups" # where archives land COMPRESSION="zstd" # zstd | gzip | none ZSTD_LEVEL="3" # 1-19; 3 is fast and good DATESTAMP="no" # yes -> claude-20260716.tar.zst
Usage
Run it from the folder above whatever you are packing. The script insists on relative paths for exactly this reason: the archive then contains myproject/…, and you can extract it anywhere without dragging /home/you/ into the archive.
backuptousb folder/Packs one folder. Press Enter to accept the suggested filename.backuptousb a/ b/ c/One archive per folder. You are asked for the destination once, then it works through the list unattended.backuptousb notes.txtSingle files work just as well as folders.-yNo prompts. Uses the default folder. For cron and scripts.-o <path>Overrides the name and location for a single archive.-hPrints the whole comment block from the top of the file.A typical run
Here a project folder and a loose file are packed in one go. Note that node_modules is never counted: the folder held 53 files, the script packs 55 entries (files plus directories) and skips the rest:
$ cd ~ $ backuptousb myproject/ notes.txt 2 sources to back up: myproject/ -> myproject.tar.zst notes.txt -> notes.txt.tar.zst Destination folder (Enter to accept): /media/youruser/usbdiskname/backups [1/2] myproject/ Packing myproject/ -> /media/youruser/usbdiskname/backups/myproject.tar.zst Sizing... 55 files, 220K 100% 00:00 taken Flushing to disk... done Source 220K (55 files) Archive 689 -> /media/youruser/usbdiskname/backups/myproject.tar.zst Shrunk to 0.3% of original Time 0m 0s Speed (too fast to measure) [2/2] notes.txt Packing notes.txt -> /media/youruser/usbdiskname/backups/notes.txt.tar.zst Sizing... 1 files, 4.0K 100% 00:00 taken Flushing to disk... done Source 4.0K (1 files) Archive 102 -> /media/youruser/usbdiskname/backups/notes.txt.tar.zst Shrunk to 2.4% of original Time 0m 0s Speed (too fast to measure) ======================================== 2 of 2 sources packed 224K -> 791 in 0m 0s ======================================== Scripts on the backup disk: + /media/youruser/usbdiskname/backups/backuptousb + /media/youruser/usbdiskname/backups/extracttodisk To restore: /media/youruser/usbdiskname/backups/extracttodisk <archive.tar.zst>
On real data the numbers look different, of course. A 60 GB folder takes minutes, not seconds, and then it is the ETA in the progress bar you watch. The point of the output is that you get to see what actually happened: how much was read, how big the archive got, and where it is.
When the archive already exists
Run it again over an archive that already exists and you get the numbers for both before deciding. Not just "file exists, overwrite?", but how old the existing archive is and how big the source is now:
myproject.tar.zst already exists: existing archive 689 packed 2026-08-05 12:15 myproject/ source now 220K (55 files, uncompressed) Overwrite? [y/N] _
The safety rails
An interrupted run does not destroy what you had
tar normally writes straight into the target file. Kill the run halfway and you are left with a truncated file under the correct name: a backup that looks like a backup but is not one. This script always packs to archive.tar.zst.partial first and renames it only after tar exits cleanly. Ctrl-C, a power cut or a full disk means the half file is cleaned up and the previous, complete archive is left untouched.
One unreadable file does not stop a 60 GB backup
This is not theory. A root-owned file in the middle of the tree aborted a 60 GB run at 55 GB. Now tar runs with --ignore-failed-read: unreadable files are skipped, counted on screen, and listed in a .skipped.log next to the archive. The run completes, and you find out exactly what was missing.
Progress that is actually correct
The progress bar counts files, not bytes. Byte-based progress goes badly wrong on trees of many small files, because each file is rounded up to a 512-byte tar block. Measured on a tree of 20,000 tiny files, a byte-based bar jumped from 38% straight to 100%. Counting files is exact at any file size.
Database directories are skipped on purpose. db-data and mysql-data are in the exclude list. A live InnoDB directory copied out from under a running server is a torn snapshot that may well not restore at all. Take a mariadb-dump into the project folder instead, and the dump gets picked up as an ordinary file.
What is not included
There is no incremental logic. Every run re-reads the whole source. If you want "only what changed", rsync is the right tool alongside this, not tar.
The script writes straight to USB. If the cable goes mid-write you have a truncated file and no local copy. For anything genuinely critical: pack to local disk first, then copy the single finished file across.
The raw command behind it
If you would rather type it yourself, this is what the script effectively does:
# pack $ tar -I 'zstd -3 -T0' -cf /media/youruser/usbdiskname/backups/myproject.tar.zst myproject/ # unpack $ tar -I zstd -xf myproject.tar.zst # list the contents without extracting $ tar -I zstd -tf myproject.tar.zst | less
Not maintained, change it yourself
This script was built for my own machine and is published as-is. It will not be updated, and I do not take bug reports or feature requests.
If you need it to do something else, hand the whole file to Claude, ChatGPT or another AI and ask in plain words: "put a date in the filename", "use gzip instead of zstd", "write to two disks in sequence", "email me when it finishes". The file is densely commented precisely so an AI can read it and understand why the code is the way it is. That is how it was written.