This Lab Project documents a two-track backup system for a workstation or homelab server. It delivers encrypted, space-efficient snapshots of a LUKS-protected data volume alongside independent snapshots of the home directory. Tooling is intentionally boring – LUKS, ext4, rsync, cron – because boring is reliable.
Objectives
- Encrypt data at rest on both the live volume and the backup destination.
- Produce timestamped, browsable snapshots with rsync hard-link deduplication.
- Keep the backup destination dark – open only for the duration of a job.
- Separate concerns: one job for the LUKS plaintext, one job for
/home
. - Enforce retention so capacity stays under control.
High-level architecture
+-------------------------+ +--------------------------------------+
| Source - live system | | Destination A - encrypted container |
| | | file on MEDIA2 |
| /home/kevin/LUKS/LUKSVOL --LUKS--> | LUKSVOLUMEBACKUP (fixed size file) |
| | mapper /dev/mapper/LUKSVOLUME | -> /dev/mapper/LUKSVOLUMEBACKUP |
| | plaintext /media/kevin/LUKSVOLUME| -> mount /media/kevin/LUKSVOLUMEBACKUP |
+-------------------------+ | snapshots: YYYY-MM-DD_HHMM/ + current|
+--------------------------------------+
+---------------------------------------------------------+
| Destination B - plain snapshot tree on MEDIA2 |
| /media/kevin/MEDIA2/BACKUPS/HOME_BACKUPS |
| -> snapshots: YYYY-MM-DD_HHMM/ + current |
+---------------------------------------------------------+
Job A backs up the plaintext view of the live LUKS volume into an encrypted container. Job B backs up /home/kevin
into a separate plain snapshot tree. Both use rsync --link-dest
so unchanged files are hard-linked – snapshots look full but only deltas cost space.
Naming and paths
Source – live data
- LUKS file:
/home/kevin/LUKS/LUKSVOL
- Mapper:
LUKSVOLUME
→/dev/mapper/LUKSVOLUME
- Plaintext mount:
/media/kevin/LUKSVOLUME
- Keyfile:
/root/.keys/LUKSVOL.key
Destination A – encrypted backup container
- Container file:
/media/kevin/MEDIA2/BACKUPS/LUKS_BACKUPS/LUKSVOLUMEBACKUP
- Mapper:
LUKSVOLUMEBACKUP
→/dev/mapper/LUKSVOLUMEBACKUP
- Filesystem mount:
/media/kevin/LUKSVOLUMEBACKUP
- Keyfile:
/root/.keys/LUKSVOLUMEBACKUP.key
Destination B – home snapshots
- Root directory:
/media/kevin/MEDIA2/BACKUPS/HOME_BACKUPS
Exclusion policy
Never copy encrypted blobs or keys into snapshots. Avoid cache noise.
# /etc/rsync-home-kevin.exclude
LUKS/LUKSVOL
*.luks
/root/.keys/
.cache/
.thumbnails/
.Trash*/
.local/share/Trash/
node_modules/
npm-cache/
pip-cache/
__pycache__/
.DS_Store
Make a weekly variant as a copy and relax only if you mean it:
cp -a /etc/rsync-home-kevin.exclude /etc/rsync-home-kevin.weekly.exclude
Backup engines – what runs and where
Install four small scripts and two helpers in /usr/local/sbin
(owner root, mode 0755).
A. LUKS plaintext snapshots into encrypted container
- backup-luksvolume.sh – sources
/media/kevin/LUKSVOLUME/
, opens and mountsLUKSVOLUMEBACKUP
, rsyncs intoYYYY-MM-DD_HHMM/LUKSVOLUME/
, advancescurrent
, unmounts and closes. Refuses to run if source plaintext or MEDIA2 is not mounted. Logs asbackup-luksvolume
. - backup-prune-luksvolume.sh – opens and mounts the same destination, enforces retention, never touches
current
. Policy: keep 14 daily, 8 weekly, 6 monthly. Logs asbackup-prune-luksvolume
.
B. Home snapshots into plain tree
- backup-home-only.sh – sources
/home/kevin/
, writes to/media/kevin/MEDIA2/BACKUPS/HOME_BACKUPS/YYYY-MM-DD_HHMM/home_kevin/
, advancescurrent
. Uses the exclude list. Refuses to run if MEDIA2 is not mounted. Logs asbackup-home-only
. - backup-prune-home-only.sh – retention identical to the LUKS job, operating under
/media/kevin/MEDIA2/BACKUPS/HOME_BACKUPS
. Logs asbackup-prune-home-only
.
Restore helpers for Destination A
- restore-open-backup.sh – opens and mounts
LUKSVOLUMEBACKUP
read-only by default. SetREAD_ONLY=0
to mount rw. - restore-close-backup.sh – unmounts and closes the mapper.
--link-dest
for deduplication, --numeric-ids
for stable ownership, and flock lockfiles to prevent overlap.Scheduling
Stagger jobs to avoid contention. Use a single cron file: /etc/cron.d/backup-home-kevin
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# LUKS plaintext -> encrypted container
10 2 * * * root /usr/local/sbin/backup-luksvolume.sh && /usr/local/sbin/backup-prune-luksvolume.sh
# Home -> plain tree on MEDIA2
40 2 * * * root /usr/local/sbin/backup-home-only.sh && /usr/local/sbin/backup-prune-home-only.sh
# Home weekly with relaxed excludes
10 3 * * 0 root EXCLUDE_FILE=/etc/rsync-home-kevin.weekly.exclude /usr/local/sbin/backup-home-only.sh && /usr/local/sbin/backup-prune-home-only.sh
systemctl restart cron
Operations
Run a manual backup cycle
# Preconditions
mountpoint -q /media/kevin/LUKSVOLUME || { echo "Mount source plaintext"; exit 1; }
mountpoint -q /media/kevin/MEDIA2 || { echo "Mount MEDIA2"; exit 1; }
# LUKS job - run twice to prove dedup
sudo /usr/local/sbin/backup-luksvolume.sh
sudo /usr/local/sbin/backup-luksvolume.sh
journalctl -t backup-luksvolume -n 30 --no-pager
# Home job - run twice to prove dedup
sudo /usr/local/sbin/backup-home-only.sh
sudo /usr/local/sbin/backup-home-only.sh
journalctl -t backup-home-only -n 30 --no-pager
Verify dedup quickly
# Encrypted container
sudo restore-open-backup.sh
BASE=/media/kevin/LUKSVOLUMEBACKUP
A=$(ls -1d $BASE/20??-??-??_???? | head -n1)
B=$(ls -1d $BASE/20??-??-??_???? | tail -n1)
REL="LUKSVOLUME/path/to/unchanged.ext"
stat -c '%i %h %n' "$A/$REL" "$B/$REL"
sudo restore-close-backup.sh
# Home snapshot tree
BASE=/media/kevin/MEDIA2/BACKUPS/HOME_BACKUPS
A=$(ls -1d $BASE/20??-??-??_???? | head -n1)
B=$(ls -1d $BASE/20??-??-??_???? | tail -n1)
REL="home_kevin/path/to/unchanged.ext"
stat -c '%i %h %n' "$A/$REL" "$B/$REL"
Inspect snapshot growth
# Encrypted destination
sudo restore-open-backup.sh
ls -l /media/kevin/LUKSVOLUMEBACKUP/current
du -sh /media/kevin/LUKSVOLUMEBACKUP/* | sort -h
sudo restore-close-backup.sh
# Home destination
ls -l /media/kevin/MEDIA2/BACKUPS/HOME_BACKUPS/current
du -sh /media/kevin/MEDIA2/BACKUPS/HOME_BACKUPS/* | sort -h
Restore procedures
Open the encrypted backup volume
sudo restore-open-backup.sh # read-only by default
# or allow writes if absolutely necessary:
# sudo READ_ONLY=0 restore-open-backup.sh
Restore a single file
SNAP="/media/kevin/LUKSVOLUMEBACKUP/2025-09-24_1754"
SRC="$SNAP/LUKSVOLUME/path/to/file.ext"
DST="/home/kevin/restore/file.ext"
mkdir -p "$(dirname "$DST")"
rsync -aHAX "$SRC" "$DST"
Restore a directory or full tree to live plaintext
SNAP="/media/kevin/LUKSVOLUMEBACKUP/2025-09-24_1754"
rsync -aHAX --delete "$SNAP/LUKSVOLUME/" "/media/kevin/LUKSVOLUME/"
# Add -n for a dry run if you want a preview
Restore from the home snapshot tree
SNAP="/media/kevin/MEDIA2/BACKUPS/HOME_BACKUPS/2025-09-24_2200"
rsync -aHAX --delete "$SNAP/home_kevin/" "/home/kevin/"
Teardown
sudo restore-close-backup.sh
Security model
- Keyfiles live at
/root/.keys/LUKSVOL.key
and/root/.keys/LUKSVOLUMEBACKUP.key
, mode 0400, owner root. They never appear in plaintext backups. - Create LUKS header backups and store offline on encrypted media. If you lose headers, you lose data.
- The destination container is opened only for the job, then unmounted and closed. Keep it dark outside backup or restore windows.
- Exclusions prevent copying the raw encrypted blob
LUKS/LUKSVOL
or any key material into snapshots.
Capacity planning
Destination A is a fixed size container. Retention depth depends on used size of /media/kevin/LUKSVOLUME
after excludes and daily change rate. For multi-week retention, size the container at least 1.5x the protected dataset.
Destination B uses the outer MEDIA2 filesystem directly. Ensure the disk has headroom.
If space gets tight, reduce retention in the pruners or recreate Destination A with a larger size and migrate with a one-off rsync.
Health checks
- After each window, confirm new snapshot directories exist and that
current
points to the latest. - Review logs:
journalctl -t backup-luksvolume -n 50 --no-pager journalctl -t backup-prune-luksvolume -n 50 --no-pager journalctl -t backup-home-only -n 50 --no-pager journalctl -t backup-prune-home-only -n 50 --no-pager
- Check free space inside Destination A:
sudo restore-open-backup.sh df -h /media/kevin/LUKSVOLUMEBACKUP sudo restore-close-backup.sh
Troubleshooting – quick answers
- Dest disk not mounted – mount
/media/kevin/MEDIA2
. - Source plaintext not mounted – open and mount the live LUKS, then rerun.
- No key available with this passphrase when opening Destination A – wrong keyfile or keyfile not added. Add with
cryptsetup luksAddKey
using the container passphrase, or recreate the container with-d <keyfile>
if empty. - Snapshots ballooning – confirm
--link-dest
is used, review excludes, reduce churn, adjust retention. - Pruner over-eager – it does not delete
current
. Restore from another timestamp and adjust counts if needed.
Non-negotiables
- Never back up the raw encrypted blob
/home/kevin/LUKS/LUKSVOL
. A tiny change looks like a full-file rewrite. - Never copy
/root/.keys
or any LUKS header backups to plaintext destinations. - Keep naming consistent across scripts, mappers and mountpoints.
- Test a restore monthly. Backups you have not restored are hypotheses, not guarantees.
Closing note
This system hits the sweet spot of security and operability for a single host – encrypted at rest, deduplicated snapshots, human-readable layout, and no exotic dependencies. If you later need fleet-wide dedup or offsite sync, you can layer Borg or Restic over the same open-mount-close lifecycle without throwing any of this away.