Lab Project: Dual backup strategy on Linux with LUKS + rsync snapshots

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.

Contents

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 mounts LUKSVOLUMEBACKUP, rsyncs into YYYY-MM-DD_HHMM/LUKSVOLUME/, advances current, unmounts and closes. Refuses to run if source plaintext or MEDIA2 is not mounted. Logs as backup-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 as backup-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/, advances current. Uses the exclude list. Refuses to run if MEDIA2 is not mounted. Logs as backup-home-only.
  • backup-prune-home-only.sh – retention identical to the LUKS job, operating under /media/kevin/MEDIA2/BACKUPS/HOME_BACKUPS. Logs as backup-prune-home-only.

Restore helpers for Destination A

  • restore-open-backup.sh – opens and mounts LUKSVOLUMEBACKUP read-only by default. Set READ_ONLY=0 to mount rw.
  • restore-close-backup.sh – unmounts and closes the mapper.
Implementation notes: Scripts are small, auditable bash. They use --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

  1. Never back up the raw encrypted blob /home/kevin/LUKS/LUKSVOL. A tiny change looks like a full-file rewrite.
  2. Never copy /root/.keys or any LUKS header backups to plaintext destinations.
  3. Keep naming consistent across scripts, mappers and mountpoints.
  4. 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.


© kevwells.com – Lab Projects