Post

From Hobby to Home Infrastructure: Building a Real Backup Strategy for My Homelab

From Hobby to Home Infrastructure: Building a Real Backup Strategy for My Homelab

A server room representing the homelab backup infrastructure

This post is a continuation of How My Homelab Went From One Pi to a Kubernetes Cluster, and Back to Docker Again, which covers how the lab evolved into the infrastructure described here.

Why I Suddenly Needed a Backup Strategy

For quite some time, my homelab was pure playground. Containers spun up and down, configs were rewritten on a whim, and if something broke, I’d just rebuild it. The beauty of a personal lab is that consequences are cheap, until they aren’t.

Then reality hit. Family started storing irreplaceable photos in Nextcloud. Friends relied on services I hosted. What began as a hobby had quietly become infrastructure, my sandbox has become a production environment. The thought of losing years of memories or disrupting services people depended on was no longer theoretical, it was terrifying. I needed a backup strategy.

The Architecture: Separation of Concerns

The first principle was simple but critical: primary storage and backup storage must never share a single point of failure. My Minix PC (4TB SSD) runs Dokploy and hosts all live data under /srv, Nextcloud files, Docker volumes, databases, configs. If that box died, everything would vanish. So I repurposed my Synology DS223 (2 * 4TB RAID 1) as a dedicated backup target, nothing else. No shared duties, no mixed responsibilities, just a clean separation between what serves data and what protects it.

Choosing the Right Tool: Restic Wins

I evaluated three options, each with trade-offs:

ToolProsCons
Synology Hyper BackupNative DSM integration, easy setupVendor lock-in, less portable
BorgBackupMature, excellent dedup/compressionNo native SFTP, more setup
ResticNative SFTP, built-in dedup/compression, clean CLIYounger project

Restic won for three reasons; Native SFTP support means no need for MinIO or S3-compatible layers, just point it at the Synology and go. Efficient storage means my 444 GiB of data compresses to ~249 GiB, saving space without tuning. And its systemd-friendly design means a simple CLI that integrates seamlessly with timers and services.

The SFTP Struggle: Synology’s Quirks

Getting SFTP to work was not plug-and-play. My first attempt failed with a cryptic Permission denied:

1
2
3
4
$ ssh backup@nas
Warning: Data should only be stored in shared folders...
Permission denied, please try again.
Connection to nas closed.

Turns out Synology has layers of restrictions. SSH access is user-specific, so even with SSH enabled globally, non-admin users like backup can be blocked from logging in via SSH entirely. ACLs override Unix permissions, as Synology’s synoacltool can block access even if chmod looks correct, which is why verifying with synoacltool -get /var/services/homes/backup/.ssh was key. SFTP must be enabled separately as a distinct toggle from FTP in DSM’s File Services settings. Non-admin SFTP users are also jailed into a virtual root, so they see /backups as their root rather than /volume1/backups, which can cause confusion when setting paths like the restic repository.

The fix? Enable SFTP in DSM, confirm the user’s SSH permissions, and use the virtual root path (/backups/minix instead of /volume1/backups/minix).

The Database Dilemma: Why Raw Volume Backups Aren’t Enough

Backing up live database volumes directly is risky. Postgres and MariaDB write continuously, while Restic reads files sequentially. It results in a “fuzzy” backup, files that never existed together at any single point in time. Restoring such a backup might work… or it might silently corrupt your data. Not a gamble I was willing to take.

The solution: A script that auto-discovers all Postgres and MariaDB containers, dumps them to SQL files before the restic backup runs, and requires zero manual updates. New database containers? Automatically picked up. No lists to maintain. I will have to adjust it if i will ever use a new database technology.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/bin/bash
set -euo pipefail

DUMP_DIR="${DUMP_DIR:-/srv/backups/db-dumps}"
mkdir -p "$DUMP_DIR"

docker ps --format '\t\t' | while IFS=$'\t' read -r id name image; do
  case "$image" in
    *postgres*)
      echo "DETECTED: $name (postgres, image=$image)"
      pguser=$(docker exec "$id" printenv POSTGRES_USER 2>/dev/null || echo postgres)
      target="$DUMP_DIR/${name}.sql"
      docker exec -u postgres "$id" pg_dumpall -U "$pguser" > "$target" \
        && echo "OK [postgres]: $name -> $target" || echo "FAILED [postgres]: $name"
      ;;
    *mariadb*|*mysql*)
      echo "DETECTED: $name (mariadb/mysql, image=$image)"
      rootpw=$(docker exec "$id" printenv MYSQL_ROOT_PASSWORD 2>/dev/null || docker exec "$id" printenv MARIADB_ROOT_PASSWORD 2>/dev/null || echo "")
      target="$DUMP_DIR/${name}.sql"
      dumpcmd="mysqldump"
      docker exec "$id" which mysqldump >/dev/null 2>&1 || dumpcmd="mariadb-dump"
      docker exec "$id" sh -c "$dumpcmd --all-databases -uroot -p'$rootpw'" > "$target" \
        && echo "OK [mariadb/mysql, using $dumpcmd]: $name -> $target" || echo "FAILED [mariadb/mysql]: $name"
      ;;
    *)
      ;;
  esac
done
echo "Done."

Newer MariaDB images use mariadb-dump instead of mysqldump. The script handles both automatically.

The Setup: Simple, Automated, Verified

The backup pipeline is driven by three systemd units, each with a single responsibility:

UnitScheduleJob
restic-backup.timerDaily at 02:00Dump databases → Run restic backup
restic-forget.timerWeekly (Sun 04:00)Prune old snapshots (keep daily 14 / weekly 8 / monthly 12)
restic-check.timerMonthly (1st at 05:00)Verify repository integrity

Exclusions

Not everything needs backing up. Docker runtime files (overlay2, containerd, etc.) and regenerable data (e.g., Nextcloud previews) are excluded to save space and time:

1
2
3
4
5
6
7
8
9
10
11
/srv/containerd
/srv/lost+found
/srv/docker/rootfs
/srv/docker/docker
/srv/docker/containers
/srv/docker/swarm
/srv/docker/buildkit
/srv/docker/tmp
/srv/docker/runtimes
/srv/docker/overlay2
/srv/nextcloud/*/appdata_*/preview

The Backup Script

Minimal and focused:

1
2
3
4
5
6
#!/usr/bin/env bash
set -euo pipefail
restic backup /srv /etc/dokploy /etc/docker \
  --exclude-file /etc/restic/excludes \
  --one-file-system \
  --tag nightly

The Service File

Ties it all together:

1
2
3
4
5
6
7
8
9
10
11
12
[Unit]
Description=Restic backup to NAS
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/env
ExecStartPre=/usr/local/bin/db_dump.sh
ExecStart=/usr/local/sbin/minix-backup.sh
Nice=10
IOSchedulingClass=idle

Results and Surprises

The first backup processed 444.65 GiB and stored 249 GiB in one hour and thirteen minutes. The restore test verified by restoring /etc/docker/daemon.json and it worked perfectly. What was very surprising is that the Synology DS223 with two 4TB drives in RAID 1 added only about 4 watts to the idle power draw, which is a remarkably small increase for a device now holding the safety net for both critical and not so critical data.

This post is licensed under CC BY 4.0 by the author.