Software Engineering WikiSE Wiki

Files and directories

Find, copy, sync, archive and watch files on Linux with find, fd, rsync, tar, zstd, du, lsof and inotifywait, and handle permissions and hostile filenames safely.

Reviewed MarkdownEdit

On this page

Cheatsheet#

TaskCommand
Files by name, case-insensitivefind . -iname '*.log' or fd -i '\.log$'
Modified in the last dayfind . -mtime -1 or fd --changed-within 1d
Larger than 100 MiBfind . -size +100M or fd -S +100M
Delete matches safelyfind . -name '*.tmp' -type f -delete
Run a command per filefind . -name '*.png' -exec optipng {} \; or fd -e png -x optipng
Run once with all filesfind . -name '*.py' -exec ruff check {} + or fd -e py -X ruff check
NUL-safe pipelinefind . -print0 | xargs -0 cmd or fd -0 | xargs -0 cmd
Mirror a directoryrsync -a --delete src/ dst/
Preview a syncrsync -ain --delete src/ dst/
Copy over SSH with progressrsync -a --info=progress2 src/ host.example.com:/dst/
Archive with zstdtar --zstd -cf backup.tar.zst dir/
Extract into a directorytar -xf backup.tar.zst -C /restore/
What is using space heredu -xh --max-depth=1 . | sort -h or ncdu -x .
Free space and inodesdf -hT, df -i
Who has this file openlsof /var/log/app.log or fuser -v /var/log/app.log
Deleted files still held openlsof -nP +L1
Watch for changesinotifywait -m -r -e close_write,moved_to dir/
Permissions as octalstat -c '%a %U:%G %n' file
Make a directory treemkdir -p a/b/c
Resolve a symlink chainreadlink -f path
Hard link count and inodels -li file
Delete a file named -rfrm -- -rf or rm ./-rf
Safe temp filet=$(mktemp)

Commands assume GNU findutils 4.10, coreutils 9.x, rsync 3.2+, fd 10 and tar 1.35 on a Linux host. macOS ships BSD variants of find, du, stat and tar whose flags differ. References: GNU findutils, rsync, GNU tar.

A file is an inode: metadata plus data blocks, identified by a number unique within its filesystem. A directory is a table mapping names to inode numbers. rm removes a name and decrements the inode’s link count; the data is freed only when the count reaches zero and no process holds the file open. That single fact explains hard links, why df disagrees with du after deleting a log that a daemon still writes, and why mv within a filesystem is instant while mv across filesystems is a copy followed by a delete.

ls -li                     # inode number, link count, then the usual columns
stat file                  # inode, links, permissions, owner, three timestamps, filesystem block usage
stat -c '%i %h %s %y %n' file    # inode, link count, size, mtime, name
df --output=source,fstype,size,used,avail,pcent,target /var

Three timestamps: mtime (content changed), ctime (inode changed: content, permissions, owner or link count) and atime (read, updated lazily under relatime). touch -d and cp -p set mtime; nothing but the clock sets ctime, which makes it useful for spotting tampering.

find#

find walks a tree and evaluates an expression against each entry. The expression is a sequence of tests and actions joined by implicit -a (and); -o is or, ! negates and \( \) groups. Evaluation is left to right with short-circuiting, so order matters: -prune must come before what it protects, and -delete or -exec must come last.

find /var/log -type f -name '*.log' -mtime +30              # regular files, name glob, mtime older than 30 days
find . -type d -name node_modules -prune -o -type f -print  # skip node_modules trees, print everything else
find / -xdev -size +1G -printf '%s\t%p\n' 2>/dev/null | sort -n   # stay on one filesystem; size and path
find . -newer reference.txt                                  # mtime more recent than the reference file
find . -newermt '2026-09-01' ! -newermt '2026-09-08'         # a date window (GNU)
find . -mmin -60                                             # modified in the last 60 minutes
find . -type f -empty -delete                                # empty files; -delete implies -depth
find . -maxdepth 1 -type l ! -exec test -e {} \; -print      # dangling symlinks
find . -perm -4000 -type f                                   # setuid files (at least these bits)
find . -perm /o+w ! -type l                                  # world-writable (any of these bits), ignoring symlinks
find . -user nobody -o -nouser                               # owned by nobody, or by a deleted uid
find . -samefile file.txt                                    # every hard link to that inode
find . -regextype posix-extended -regex '.*/[0-9]{8}\.log'   # regex on the full path

-mtime +30 means more than 30 whole days ago; -mtime 30 means exactly 30 to 31 days ago; -mtime -30 within the last 30 days. Use -mmin for minute resolution and -daystart to count from midnight. -size +100M rounds up to the unit; -size +100M excludes a 100.5 MiB file only when the unit is k and it is written as -size +102400k.

Actions:

find . -name '*.orig' -exec rm -v {} \;        # one rm per file; \; ends the command
find . -name '*.py' -exec ruff check {} +      # as many files per invocation as fit in ARG_MAX
find . -name '*.log' -execdir gzip {} \;       # run in the file's directory with a ./name argument
find . -name core -ok rm {} \;                 # prompt per file
find . -type f -print0 | xargs -0 -P4 sha256sum   # NUL-delimited names survive spaces and newlines; four parallel jobs
find . -type f -printf '%TY-%Tm-%Td %TH:%TM %10s %p\n' | sort   # mtime, size, path
find . -name '*.tmp' -delete                   # no confirmation; -delete forces -depth and refuses to run with ordering tests after it

-delete and -exec rm run whatever the tests select

Run the same expression with -print first. find . -name '*.tmp' -o -name '*.bak' -delete deletes only *.bak, because -delete binds to the second test; group with \( ... \).

Filenames may contain newlines, so the only safe way to hand find results to another program is -print0 with xargs -0, -exec, or while IFS= read -r -d '' f. Never for f in $(find ...).

fd#

fd searches by regex (or glob with -g) on the basename by default, ignores hidden files and anything matched by .gitignore, colours output, and runs in parallel. It is the everyday tool; find is for expressions fd cannot express and for hosts where it is not installed.

fd pattern                           # regex on the file name, current directory, recursive
fd -e log -e txt                     # by extension
fd -t f -t l                         # type: f file, d directory, l symlink, x executable, e empty
fd -H -I pattern                     # include hidden (-H) and ignored (-I) entries
fd -g '*.tar.*'                      # glob instead of regex
fd -p 'src/.*/test_'                 # match against the full path
fd -d 2 pattern                      # max depth
fd --changed-within 2h               # or 1d, 2weeks; --changed-before for the other side
fd -S +100M -S -1G                   # size between 100 MiB and 1 GiB
fd -E node_modules -E '*.min.js'     # exclude patterns
fd -e jpg -x convert {} {.}.webp     # one command per file; {} path, {.} without extension, {/} basename, {//} parent
fd -e py -X ruff check               # one command with all results as arguments
fd -0 -e log | xargs -0 gzip         # NUL-separated for pipelines
fd -o root -t f /etc                 # owned by root; -o :group and -o user:group also work
fd pattern --exec-batch rm -v        # long form of -X

-x runs jobs in parallel (-j limits it), so commands that write to the same output need -X or -j1. Use --no-ignore-vcs when a .gitignore hides what you are looking for and -u (unrestricted, twice for everything) when in doubt.

rsync#

rsync sends only the parts of files that differ, decided per file by size and mtime (-c compares checksums instead, reading every byte on both sides). It works locally, over SSH, or against an rsync daemon. Everything in the flags below is about which metadata to keep and what to do with files that exist only on the destination.

rsync -a src/ dst/                           # archive: -rlptgoD (recursive, links, perms, times, group, owner, devices)
rsync -av --info=progress2 src/ dst/         # -v lists files; progress2 shows a single whole-transfer progress line
rsync -aHAX --numeric-ids src/ dst/          # also hard links, ACLs, xattrs; keep uid/gid numbers, for system backups
rsync -ain --delete src/ dst/                # -n dry run, -i itemise what would change; always do this before --delete
rsync -a --delete --delete-excluded --exclude='*.tmp' --exclude='/cache/' src/ dst/
rsync -a --exclude-from=exclude.txt --files-from=list.txt / dst/   # explicit file list; paths relative to the source
rsync -az -e 'ssh -p 2222' src/ user@host.example.com:/srv/dst/     # over SSH with compression (-z)
rsync -a --rsync-path='sudo rsync' src/ host.example.com:/etc/dst/  # elevate on the remote side
rsync -aP --bwlimit=5m src/ host.example.com:/dst/                 # -P = --partial --progress; 5 MiB/s cap
rsync -a --partial-dir=.rsync-partial src/ host.example.com:/dst/  # keep partial files out of the way until complete
rsync -a --remove-source-files src/ dst/     # move semantics for files; empty source directories remain
rsync -a --link-dest=../2026-09-23 src/ dst/2026-09-24/   # unchanged files become hard links to yesterday's copy
rsync -a --mkpath src/ dst/new/deep/path/    # create missing destination directories (3.2.3+)
rsync -a --chown=app:app src/ dst/           # remap ownership on the receiver

The trailing slash on the source decides whether the directory itself or its contents are copied. rsync -a src dst/ creates dst/src/...; rsync -a src/ dst/ creates dst/.... The trailing slash on the destination changes nothing. Think of src/ as “the contents of src”.

--delete removes files from the destination that are absent from the source, after considering excludes. With --exclude alone, excluded files already on the destination stay; --delete-excluded removes them too. Deletion happens during the transfer by default; --delete-after waits until every file has arrived, which is safer when the destination is served live. A dry run with -n combined with -i shows each planned action as a string such as >f.st...... (file transferred because size and time differ) or *deleting.

--delete with a wrong path empties the destination

rsync -a --delete empty-or-wrong-dir/ /srv/data/ deletes everything under /srv/data/. Run with -n first, and prefer variables that fail loudly: "${SRC:?}/".

Interrupted transfers: --partial keeps the received part of a file so the next run resumes instead of restarting; --append-verify extends a file that was only partly written, and is wrong for files that change in the middle. --inplace writes directly to the destination file instead of a temporary file plus rename, which suits large files on a full disk or block devices but leaves a corrupt destination if interrupted. Version 3.2.4 changed remote argument handling (--old-args restores the old behaviour when a wrapper depends on shell expansion of remote paths); use -s to send arguments through the protocol untouched.

Archives and compression#

tar bundles a tree into one stream with permissions, ownership and timestamps; compression is a separate filter. Modern GNU tar detects the compressor on extraction from the file content, so -xf works for .tar.gz, .tar.xz and .tar.zst alike.

tar -cf site.tar site/                        # no compression
tar -czf site.tar.gz site/                    # gzip: fast, everywhere
tar -cJf site.tar.xz site/                    # xz: smallest, slowest
tar --zstd -cf site.tar.zst site/             # zstd: near-gzip ratio at several times the speed
tar -I 'zstd -T0 -19' -cf site.tar.zst site/  # explicit compressor with options: all cores, level 19
tar -tf site.tar.zst | head                   # list
tar -xf site.tar.zst -C /restore/             # extract into a directory that must exist
tar -xf site.tar.zst --strip-components=1 -C /srv/site/   # drop the leading directory
tar -xf site.tar.zst 'site/config/*'          # extract a subset
tar -cf - dir/ | ssh host.example.com 'tar -xf - -C /dst/'   # stream over SSH; see ssh for options
tar --one-file-system --exclude='./proc' --exclude='./sys' -cf - / | zstd -T0 > root.tar.zst   # system snapshot
tar -czf - dir/ | split -b 2G - part.tar.gz.   # split into 2 GiB pieces; cat part.tar.gz.* | tar -xzf -

Archive with relative paths (tar -C /var/www -cf site.tar site) so extraction lands where the operator chooses. GNU tar strips a leading / by default and warns; other tars do not. Verify with -t before extracting anything from an untrusted source, and extract as an unprivileged user; GNU tar refuses .. components unless -P is given.

zstd -T0 -3 big.log                  # writes big.log.zst, keeps the original; -19 for archival, --long for large similar files
zstd -d big.log.zst                  # decompress
zstdcat big.log.zst | grep ERROR     # stream without extracting
zstd --rm -T0 *.log                  # compress and delete originals
gzip -k file; xz -T0 file            # keep original (-k); xz threads (-T0)
zip -r site.zip site/ -x '*.git*'    # zip for Windows recipients; -e prompts for a password (weak legacy encryption)
unzip -l site.zip; unzip -o site.zip -d /restore/   # list; overwrite into a directory

zstd level 3 is the default and already beats gzip at any level for speed. -T0 uses every core for compression; decompression is single-threaded but fast. --long=31 allows a 2 GiB match window, which helps enormously on VM images and database dumps but requires the same flag to decompress.

Disk usage#

du counts allocated blocks under a path; df reports what the filesystem says is free. They disagree when deleted files are still open (see lsof below), when reserved blocks (5 % on ext4 by default) are counted, when bind mounts or other filesystems sit under the path, or when files are sparse or reflinked.

du -xsh /var/*  2>/dev/null | sort -h        # per top-level directory, one filesystem (-x), human sizes, sorted
du -xh --max-depth=2 /var | sort -h | tail   # deepest offenders two levels down
du -xa /var/log | sort -n | tail -20         # every file (-a), largest last
du -sh --apparent-size file                  # logical size rather than blocks: shows sparse files as their full length
du -xsh --time /home/*                       # with last modification time per entry
df -hT                                       # every mounted filesystem with type
df -h /var/log                               # the filesystem holding a path
df -i /                                      # inodes; a full inode table looks like a full disk with free space
ncdu -x /                                    # interactive; d deletes (irreversibly), i shows info, n/s/C sort
ncdu -x -o /root/scan.json / && ncdu -f /root/scan.json   # scan once, browse later or elsewhere

Open files and busy mounts#

lsof /var/log/app.log                 # processes with that file open
lsof +D /srv/data                     # everything open under a tree (slow on large trees)
lsof -nP -p 1234                      # every fd of one process; -n no DNS, -P no port names
lsof -nP +L1                          # open files whose link count is 0: deleted but still consuming space
lsof -nP -iTCP -sTCP:LISTEN           # listening sockets, since it is often the same investigation
fuser -vm /mnt/backup                 # processes using anything on that mount (why umount says busy)
fuser -k /mnt/backup                  # send SIGKILL to them; -TERM for a gentler signal, -i to confirm
fuser -v /dev/ttyUSB0                 # who holds a device

A deleted-but-open file shows in lsof as (deleted) and in /proc/<pid>/fd/ as a symlink. Truncating it through that path (: > /proc/1234/fd/5) returns the space without restarting the process; restarting the process or asking it to reopen logs (kill -HUP, logrotate with copytruncate) is the tidy fix.

Watching for changes#

inotifywait (package inotify-tools) blocks until a filesystem event, or with -m streams events forever. It uses the kernel inotify API, which watches directories and the files in them but does not recurse on its own; -r adds a watch per subdirectory, bounded by fs.inotify.max_user_watches.

inotifywait -e close_write /etc/app/config.yaml       # wait for one save, then exit
inotifywait -m -r -e close_write,moved_to,create,delete --format '%T %w%f %e' --timefmt '%F %T' /srv/uploads/
inotifywait -m -e modify /var/log/app.log | while read -r _ _ f; do echo "changed: $f"; done
inotifywait -m -r --exclude '\.(swp|tmp)$' -e close_write src/ | while read -r dir ev file; do make; done   # rebuild on save

close_write is the event to act on for “file finished being written”; modify fires for every write call. Editors that save by writing a temporary file and renaming it produce moved_to, not close_write, on the watched name. For watches that must survive reboots, use a systemd.path unit (systemd) instead of a long-running inotifywait; for huge trees or whole-filesystem watching use fanotify or watchman.

Permissions, ownership and umask#

Each file carries an owner, a group and nine mode bits in three triplets: user, group, other, each rwx. On a directory, r lists names, w creates or removes entries (regardless of the entries’ own permissions) and x allows traversal into it; a directory with w but not x is useless. Three extra bits: setuid (4) runs an executable as its owner, setgid (2) on a directory makes new files inherit the directory’s group, and the sticky bit (1) on a directory restricts deletion to the entry’s owner, as on /tmp.

chmod 640 file                     # rw- r-- ---
chmod u+x,go-w script.sh           # symbolic: add x for user, remove w for group and other
chmod -R u=rwX,go=rX dir/          # X: execute only for directories and files already executable; safe for recursion
chmod 2775 shared/                 # setgid directory: files created inside get the directory's group
chmod 1777 scratch/                # sticky: users delete only their own files
chown app:app file; chown -R app:app dir/; chgrp -R web dir/
chown --reference=other file       # copy owner and group from another file
umask                              # 0022: new files 644, new directories 755
umask 077                          # new files 600, directories 700, for the rest of this shell
install -m 0640 -o app -g app config.yaml /etc/app/config.yaml   # copy with mode and ownership in one step
stat -c '%A %a %U %G %n' /etc/shadow      # ----------  0 root root: symbolic, octal, owner, group

The umask is subtracted from the mode a program requests (usually 666 for files and 777 for directories), so umask 022 gives 644 and 755. It is inherited per process; set it in the service unit (UMask= in systemd), in /etc/login.defs or in a profile script, not by hoping.

ACLs extend the nine bits to named users and groups, and default ACLs on a directory propagate to new entries. A + after the mode in ls -l means an ACL is present; getfacl shows it.

setfacl -m u:deploy:rwx /srv/app          # grant one user
setfacl -m d:g:web:rx /srv/app            # default ACL: new entries inside get it
setfacl -R -m g:web:rX /srv/app           # recursive
setfacl -b /srv/app                       # remove all ACL entries
getfacl /srv/app

chattr +i file makes a file immutable even to root until chattr -i; +a allows append only, useful for audit logs. lsattr lists these. On SELinux hosts the security label is a fourth axis: ls -Z, restorecon -Rv dir/ after moving files into a labelled location, and chcon or semanage fcontext for persistent exceptions.

A hard link is a second name for the same inode: same permissions, same data, same filesystem, indistinguishable from the original. Deleting one name leaves the other intact. Directories cannot be hard-linked. A symlink is a small file containing a path; it can cross filesystems and point at things that do not exist, and permissions on it are ignored in favour of the target’s.

ln file.txt hard.txt               # hard link; ls -li shows the same inode and link count 2
ln -s /srv/app/releases/42 /srv/app/current      # symlink to an absolute path
ln -sfn releases/43 /srv/app/current             # replace atomically-ish: -f overwrite, -n treat existing symlink as a file not a directory
ln -s ../shared/config.yaml config.yaml          # relative target: survives moving the whole tree
readlink current                   # the stored target text
readlink -f current                # fully resolved absolute path, every component
realpath --relative-to=. /srv/app/releases/42
find . -type l -xtype l            # broken symlinks (GNU: -xtype tests the target)
find /srv -links +1 -type f        # files with more than one name
cp -a dir/ copy/                   # preserves symlinks as symlinks; cp -L follows them
rsync -a --copy-links src/ dst/    # follow symlinks and copy the targets instead

ln -sfn without -n on an existing symlink to a directory creates the new link inside the target directory rather than replacing the link. Atomic switch of a current symlink: create current.tmp then mv -T current.tmp current; mv is a single rename(2).

Relative symlinks inside a tree that gets copied or bind-mounted keep working; absolute ones point back at the original location. cp -a and rsync -a preserve the link text, so a relative link is copied relative.

Odd filenames#

Any byte except / and NUL is legal in a name, including newlines, leading dashes, spaces, tabs, glob characters and terminal escape sequences. Scripts that handle names they did not create must assume all of them.

ls -b                              # C escapes for non-printables; ls -q replaces them with ?
ls -li                             # find the inode of the unmanageable one
find . -inum 1234567 -delete       # delete by inode; also: find . -inum 1234567 -exec mv {} sane-name \;
rm -- -rf                          # -- ends option parsing; or rm ./-rf
rm -i -- *                         # confirm each; leading-dash names cannot become options after --
printf '%q\n' "$name"              # show what the shell would need to type it
for f in *; do mv -- "$f" "$(printf '%s' "$f" | tr -c 'A-Za-z0-9._-\n' '_')"; done   # sanitise; check for collisions first
find . -name $'*\n*'               # names containing a newline
find . -depth -name '* *' -execdir rename 's/ /_/g' {} +   # perl rename, package prename or perl-File-Rename; -n previews
detox -r -n dir/                   # detox package: normalise names recursively; -n dry run
iconv -f latin1 -t utf-8 <<< "$name"   # decode a name written under another locale; convmv -r -f latin1 -t utf-8 --notest dir/ renames in place

Quote every expansion ("$f"), separate with NUL between programs (-print0, -0, -z for sort, xargs, grep, sed), and pass -- before filenames in any command that accepts options. Iterating a glob (for f in *) is safe; iterating $(ls) is not. See the quoting section of Bash.

Oneliners#

# Biggest 20 files on this filesystem
find / -xdev -type f -printf '%s\t%p\n' 2>/dev/null | sort -n | tail -20 | numfmt --field=1 --to=iec

# Directories over 1 GiB, one filesystem
du -xh --threshold=1G / 2>/dev/null | sort -h

# Files modified in the last 10 minutes under /etc, newest first
find /etc -type f -mmin -10 -printf '%TF %TT %p\n' | sort -r

# Count files per extension in a tree
fd -t f | sed -E 's/.*\.//' | sort | uniq -c | sort -rn | head

# Delete logs older than 14 days, showing each
find /var/log/app -name '*.log' -type f -mtime +14 -print -delete

# Compress rotated logs that are not yet compressed
find /var/log -name '*.[0-9]' -type f -mtime +1 -exec zstd --rm -T0 -q {} +

# Remove empty directories bottom-up
find /srv/uploads -mindepth 1 -type d -empty -delete

# Duplicate files by content (size first, then hash)
find . -type f -printf '%s %p\n' | sort -n | uniq -Dw10 | awk '{print $2}' | xargs -d '\n' sha256sum | sort | uniq -Dw64

# Same, with fdupes or jdupes when installed
jdupes -r .

# Total size of files matching a glob, NUL-safe
find . -name '*.mp4' -type f -print0 | du -ch --files0-from=- | tail -1

# Sync a tree to a host, deleting extras, showing per-file actions
rsync -aHi --delete --info=progress2 /srv/site/ deploy@host.example.com:/srv/site/

# Pull a remote directory, resumable, capped at 10 MiB/s
rsync -aP --bwlimit=10m host.example.com:/var/backups/ /mnt/backups/

# Copy a directory across hosts preserving everything, root on the far side
rsync -aHAX --numeric-ids --rsync-path='sudo rsync' /etc/ admin@host.example.com:/srv/etc-copy/

# Verify two trees match by checksum, listing differences only
rsync -rcn --delete -i src/ dst/ | grep -v '^\.'

# Stream a directory through zstd over SSH without a temporary archive
tar -C /srv -cf - data | zstd -T0 | ssh host.example.com 'zstd -d | tar -xf - -C /restore'

# Extract a single file from an archive to stdout
tar -xOf backup.tar.zst etc/hosts

# Deleted files holding space, sorted by size
lsof -nP +L1 2>/dev/null | awk 'NR>1 {print $7, $1, $2, $10}' | sort -n | tail

# Which process is writing to this directory right now
inotifywait -m -e modify,create /var/lib/app 2>/dev/null | head -20; lsof +D /var/lib/app

# Wait until a file appears, up to five minutes
timeout 300 inotifywait -q -e create --include 'done\.flag' /srv/jobs/ 

# World-writable files and directories outside /tmp and /proc
find / -xdev \( -path /tmp -o -path /var/tmp \) -prune -o -perm -o+w ! -type l -print 2>/dev/null

# Setuid and setgid binaries, with owner
find / -xdev -type f \( -perm -4000 -o -perm -2000 \) -printf '%M %u %p\n' 2>/dev/null

# Files changed (ctime) in the last day, for spotting unexpected edits
find /usr /etc -xdev -ctime -1 -printf '%CF %CT %p\n' | sort

# Make a directory tree with the same layout but no files
find src -type d -exec mkdir -p dst/{} \;

# Copy with reflink on btrfs or xfs (instant, shares blocks until modified)
cp -a --reflink=auto big.img big-copy.img

# Fill a file with zeros quickly, or allocate sparse
fallocate -l 10G swapfile-candidate; truncate -s 10G sparse.img

# Byte-exact compare two files and show the first difference
cmp file-a file-b

# Rename by regex, previewing first (perl rename)
rename -n 's/\.jpeg$/.jpg/' *.jpeg

Scripts#

Back up a directory into dated zstd archives and keep the newest N, deleting older ones.

#!/usr/bin/env bash
# usage: backup-rotate.sh <source-dir> <backup-dir> [keep=7]
set -euo pipefail
src=${1:?source dir} dst=${2:?backup dir} keep=${3:-7}
[[ -d $src ]] || { printf 'no such directory: %s\n' "$src" >&2; exit 2; }
mkdir -p -- "$dst"
name=$(basename -- "$src")
archive="$dst/$name-$(date +%Y%m%dT%H%M%S).tar.zst"
tmp="$archive.partial"
trap 'rm -f -- "$tmp"' EXIT
tar -C "$(dirname -- "$src")" --one-file-system -cf - -- "$name" | zstd -T0 -q -o "$tmp"
mv -- "$tmp" "$archive"
trap - EXIT
printf 'wrote %s (%s)\n' "$archive" "$(du -h -- "$archive" | cut -f1)"
# Rotation: newest first by name (timestamps sort lexically), delete beyond $keep
mapfile -t old < <(ls -1 -- "$dst"/"$name"-*.tar.zst 2>/dev/null | sort -r | tail -n +"$((keep + 1))")
for f in "${old[@]}"; do rm -v -- "$f"; done

Report the largest files and directories on a filesystem, plus deleted-but-open files, in one pass. Run as root for a full view.

#!/usr/bin/env bash
# usage: disk-report.sh [mountpoint=/] [top=15]
set -euo pipefail
mnt=${1:-/} top=${2:-15}
printf '== %s ==\n' "$mnt"; df -hT -- "$mnt" | tail -1
printf '\n== inodes ==\n'; df -i -- "$mnt" | tail -1
printf '\n== largest directories (one filesystem) ==\n'
du -x --max-depth=3 -- "$mnt" 2>/dev/null | sort -n | tail -n "$top" | numfmt --field=1 --from-unit=1024 --to=iec
printf '\n== largest files ==\n'
find "$mnt" -xdev -type f -printf '%s\t%TF\t%p\n' 2>/dev/null | sort -n | tail -n "$top" | numfmt --field=1 --to=iec
printf '\n== deleted but open ==\n'
lsof -nP +L1 2>/dev/null | awk 'NR>1 && $7 > 1048576 {printf "%s\t%s\t%s\t%s\n", $7, $1, $2, $10}' | sort -n | numfmt --field=1 --to=iec || true

Mirror a directory to a remote host with a lock, a dry-run summary on demand and a bandwidth cap, suitable for a systemd timer.

#!/usr/bin/env bash
# usage: mirror.sh [-n] <src/> <user@host:/dst/>
set -euo pipefail
dry=(); [[ ${1:-} == -n ]] && { dry=(-n -i); shift; }
src=${1:?source} dst=${2:?destination}
exec 9>"/run/lock/mirror-$(basename -- "${src%/}").lock"
flock -n 9 || { echo 'another mirror is running' >&2; exit 0; }
rsync -aH --delete --delete-after --partial-dir=.rsync-partial --bwlimit=20m --timeout=120 \
      --exclude='.rsync-partial/' --exclude='*.tmp' "${dry[@]}" -- "$src" "$dst"
rc=$?
case $rc in
  0) ;;
  24) echo 'warning: some source files vanished during transfer' >&2 ;;   # rsync exit 24 is benign on live trees
  *) echo "rsync failed with $rc" >&2; exit "$rc" ;;
esac

Troubleshooting#

SymptomCauseFix
df shows the disk full but du finds far lessDeleted files still open, or a mount hiding data underneathlsof -nP +L1; mount --bind / /mnt/root && du -xsh /mnt/root/var to see under mounts
No space left on device with free space in df -hInodes exhausteddf -i; find the directory with millions of small files: find / -xdev -printf '%h\n' | sort | uniq -c | sort -n | tail
umount: target is busyA process has a file open or its cwd on the mountfuser -vm /mnt/x; lsof +D /mnt/x; stop it, or umount -l for a lazy detach
Argument list too longGlob expanded past ARG_MAXfind ... -exec cmd {} +, fd -X, or xargs -0
find: paths must precede expressionUnquoted glob expanded by the shell before find saw itQuote the pattern: -name '*.log'
rsync: connection unexpectedly closedRemote rsync missing, SSH login printing output, or sudo needing a TTYssh host rsync --version; silence shell startup output; use --rsync-path='sudo rsync' with passwordless sudo
rsync copies everything every timeTimes not preserved (-t missing) or a filesystem with coarse timestamps (FAT, some SMB)Use -a; --modify-window=1 for 2 s FAT resolution; --size-only as a last resort
rsync created dst/src/ instead of syncing contentsMissing trailing slash on the sourcersync -a src/ dst/
rsync: failed to set times or chown errorsDestination not owned by you, or no CAP_CHOWNDrop -o -g (rsync -rlptD), or run as root; --no-perms --no-owner --no-group on foreign filesystems
tar: Removing leading '/' from member namesAbsolute paths in the archiveExpected; use -C and relative paths when creating
tar: Cannot open: Permission denied on extractExtracting as a user into a root-owned directory, or setuid filesExtract as root with --same-owner, or into a directory you own
inotifywait: Failed to watch; upper limit on inotify watches reachedfs.inotify.max_user_watches too low for -rsysctl fs.inotify.max_user_watches=524288, persist in /etc/sysctl.d/
Permission denied despite rwx on the fileMissing x on a parent directory, an ACL, SELinux, or chattr +inamei -l /path/to/file; getfacl; ls -Z and ausearch -m avc -ts recent; lsattr
A file cannot be deleted even by rootImmutable or append-only attributelsattr file; chattr -i file
ls output looks corrupted, names contain control charactersNames with escapes or a wrong localels -b; LC_ALL=C ls; rename by inode
Symlink to a directory ends up inside the targetln -sf without -nln -sfn target link, or mv -T a fresh link over the old one
mv across filesystems is slow and non-atomicIt is a copy plus deletersync -a --remove-source-files for resumability; keep staging and final directories on one filesystem for atomic renames

Further reading#