indi-allsky Backup on FRITZ!NAS Version 2.0: How to Back Up the Database, Configuration, and System Today

By the end of 2025, I had set up a two-step backup process for my indi-allsky installation on the Raspberry Pi: First, I backed up the database, configuration, and migrations; then, the image files and time-lapses. This worked in principle, but over time it became significantly more time-consuming than necessary.

Now, the timelapses are also posted to my YouTube channel. I no longer want to transfer the large image collections to the FRITZ!Box over the local network every day. What’s much more important to me is that, in the event of an NVMe SSD failure, I can restore the running indi-allsky installation with as little effort as possible.

That’s why I set up the backup system anew in October 2026. The previous articles explicitly describe the old method: Phase 1: Database, Configuration, and Migrations, and Phase 2: Files and Quality-Based Backup. I’ve since disabled both cron jobs.

Why I Changed the Backup

Among other things, the old solution wrote a daily SQLite dump directly to the SMB drive. When the external SSD was full, these backups failed. In addition, copying and deleting large amounts of images put a noticeable strain on the FRITZ!Box.

My new approach is simpler: indi-allsky creates a consistent SQLite backup on its own. A separate script monitors this backup and copies it to the FRITZ.NAS. A second, weekly job archives the important system files. The actual photos and time-lapses are intentionally not included in the backup.

My Setup

  • Raspberry Pi with Debian 13 and indi-allsky
  • System on an internal NVMe SSD
  • External SanDisk SSD connected to a FRITZ!Box 6690 Cable
  • Mounted via SMB/CIFS at /mnt/backup_allsky
  • Daily at 3:00 a.m.: Database and configuration
  • Sundays at 4:00 a.m.: System backup
  • Email notifications only in case of errors

The local indi-allsky database backup retains seven generations. On the FRITZ.NAS, daily backups are retained for 25 days; for the weekly system backup, I retain four generations.

1. Enable the built-in database backup

In the indi-allsky web interface, under the backup settings, I’ve set DB BACKUP FREQUENCY (DAYS) to 1. indi-allsky then regularly creates a compressed SQLite backup in /var/lib/indi-allsky/backup/. The built-in feature uses the SQLite backup API and is therefore preferable to a custom dump of the running database.

You can trigger a manual backup as follows:

cd ~/indi-allsky
./misc/indi-allsky-ctl backup-db

Important: The time of the internal backup is not fixed to the NAS cron job. Therefore, the external script checks whether the most recent local backup is no more than 48 hours old.

2. Connect to FRITZ.NAS and Create Directories

I had already described the SMB mount in the old guide. Here’s just a quick check: The mount must actually be active. Simply having a local folder isn’t enough; otherwise, you might accidentally back up to your own SSD.

mountpoint /mnt/backup_allsky
findmnt /mnt/backup_allsky

My new directory structure:

backup_allsky/
├── database/       # SQLite backups
├── configuration/  # Configuration and migrations
└── system/         # Weekly system backup

The FRITZ!Box may also create a “FRITZ” folder on its own. I’ll leave that alone. The actual directories can be created as follows:

sudo mkdir -p /mnt/backup_allsky/backup_allsky/{database,configuration,system}

3. The Daily NAS Backup

The following script searches for the latest internal indi-allsky backup, checks GZIP and SQLite, creates archives for configuration and migrations, and transfers everything to the FRITZ.NAS. Each copy is compared to the local original using ` cmp`. Only then are old backups purged. At least the most recent backup is retained for each category.

Please adjust the email recipient in the script. This requires msmtp sending to work under root. I use port 587 with STARTTLS; passwords belong exclusively in the protected mail configuration, not in this script.

Note on the code: The following version contains a minor improvement over my first version: The error alert is attached to EXIT so that even an explicit `exit 1` triggers a message. This improved version should be tested separately before production use.

Save the script as ` /usr/local/bin/backup_allsky_nas.sh`:

#!/usr/bin/env bash
set -Eeuo pipefail
umask 077

NAS="/mnt/backup_allsky"
ROOT="$NAS/backup_allsky"
SOURCE="/var/lib/indi-allsky/backup"
MAIL_TO="mail@BEISPIEL.DE"
LOG="/var/log/backup_allsky_nas.log"
RETENTION=25
WORK=""

exec >>"$LOG" 2>&1

# EXIT instead of just ERR: also explicitly report exit 1 and missing files.
finish() {
    rc=$?
    trap - EXIT
    if [[ -n "$WORK" && -d "$WORK" ]]; then
        rm -rf -- "$WORK"
    fi
    if (( rc != 0 )); then
        {
            printf 'To: %s\n' "$MAIL_TO"
            printf 'Subject: ALARM - Allsky NAS backup failed\n'
            printf 'Content-Type: text/plain; charset=UTF-8\n\n'
            printf 'Backup failed on %s at %s (Code %s).\n\n' \
                "$(hostname)" "$(date)" "$rc"
            tail -n 35 "$LOG" || true
        } | /usr/bin/msmtp -a default "$MAIL_TO" || \
            echo 'WARNING: Error email not sent'
    fi
    exit "$rc"
}
trap finish EXIT

echo "=== NAS Backup: $(date) ==="\n\
mountpoint -q "$NAS"\n\
[[ "$(findmnt -n -o FSTYPE "$NAS")" == "cifs" ]]\n\
for d in "$ROOT" "$ROOT/database" "$ROOT/configuration" "$ROOT/system"; do
    test -d "$d"
done

LATEST="$(find "$SOURCE" -maxdepth 1 -type f \
    -name 'backup_indi-allsky_*.sqlite.gz' -printf '%T@ %p\n' \
    | sort -nr | head -1 | cut -d' ' -f2-)"
test -n "$LATEST" && test -s "$LATEST"
NAME="$(basename "$LATEST")"
NOW="$(date +%s)"
MTIME="$(stat -c %Y "$LATEST")"
if (( NOW - MTIME > 172800 )); then
    echo 'ERROR: Local database backup is older than 48 hours'
    exit 1
fi

WORK="$(mktemp -d /var/tmp/allsky-backup-XXXXXXXX)"
gzip -t "$LATEST"
gzip -dc "$LATEST" > "$WORK/test.sqlite"
RESULT="$(sqlite3 -readonly "$WORK/test.sqlite" 'PRAGMA integrity_check;')"
[[ "$RESULT" == 'ok' ]] || { echo "ERROR: SQLite: $RESULT"; exit 1; }
rm -f -- "$WORK/test.sqlite"

tar -C /etc -czf "$WORK/configuration.tgz" indi-allsky
tar -tzf "$WORK/configuration.tgz" >/dev/null
tar -C /var/lib/indi-allsky/migrations \
    -czf "$WORK/migrations.tgz" versions
tar -tzf "$WORK/migrations.tgz" >/dev/null

transfer() {
    local src="$1" dst="$2" tmp="${2}.part"
    rm -f -- "$tmp"
    cp -- "$src" "$tmp"
    cmp -s -- "$src" "$tmp"
    mv -f -- "$tmp" "$dst"
    echo "Verified: $(basename "$dst")"
}
STAMP="$(date +%Y%m%d_%H%M%S)"
transfer "$LATEST" "$ROOT/database/$NAME"
transfer "$WORK/configuration.tgz" \
    "$ROOT/configuration/configuration_${STAMP}.tgz"
transfer "$WORK/migrations.tgz" \
    "$ROOT/configuration/migrations_${STAMP}.tgz"

# Retain for a maximum of 25 days after successful transfer;
# the most recent file in each case is retained even after the expiration period.
cleanup_old() {
    local dir="$1" pattern="$2"
    local -a files=()
    mapfile -d '' -t files < <(find "$dir" -maxdepth 1 -type f \
        -name "$pattern" -printf '%T@ %p\0' | sort -zrn)
    local i path entry
    for ((i=1; i<${#files[@]}; i++)); do
        entry="${files[$i]}"
        path="${entry#* }"
        if (( $(date +%s) - $(stat -c %Y "$path") > RETENTION * 86400 )); then
            rm -f -- "$path"
        fi
    done
}
cleanup_old "$ROOT/database" 'backup_indi-allsky_*.sqlite.gz'
cleanup_old "$ROOT/configuration" 'configuration_*.tgz'
cleanup_old "$ROOT/configuration" 'migrations_*.tgz'
echo "=== NAS BACKUP SUCCESSFUL: $(date) ==="
sudo chmod 700 /usr/local/bin/backup_allsky_nas.sh
sudo bash -n /usr/local/bin/backup_allsky_nas.sh
sudo flock -n /var/lock/backup_allsky_nas.lock /usr/local/bin/backup_allsky_nas.sh
sudo tail -n 35 /var/log/backup_allsky_nas.log

If the check is successful, ” NAS BACKUP SUCCESSFUL” will appear at the end. Only then should the cron job be added using ` sudo crontab -e `:

0 3 * * * /usr/bin/flock -n /var/lock/backup_allsky_nas.lock /usr/local/bin/backup_allsky_nas.sh

4. Additional: Weekly System Backup

Although the database contains many settings, it does not include everything that makes up my Raspberry Pi. In particular, YouTube OAuth, custom scripts, ADS-B, systemd services, cron jobs, and boot configuration should not be lost in the event of an SSD failure.

That’s why I back up a targeted system archive every Sunday. I exclude Python virtual environments, build caches, images, and videos. The indi-allsky Git version used, as well as installed packages and cron jobs, are documented. This is not a bootable SSD image: In an emergency, I would reinstall Debian and indi-allsky and restore the configuration.

Save the script as /usr/local/bin/backup_allsky_system.sh:

#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
NAS="/mnt/backup_allsky"
DEST="$NAS/backup_allsky/system"
LOG="/var/log/backup_allsky_system.log"
MAIL="mail@BEISPIEL.DE"
WORK=""

finish() {
    rc=$?
    trap - EXIT
    if [[ -n "$WORK" && -d "$WORK" ]]; then
        rm -rf -- "$WORK"
    fi
    if (( rc != 0 )); then
        {
            printf 'Subject: ALARM - Allsky system backup failed\n\n'
            printf 'Host: %s\nTime: %s\nExit Code: %s\n\n' \
                "$(hostname)" "$(date)" "$rc"
            tail -n 30 "$LOG" || true
        } | /usr/bin/msmtp -a default "$MAIL" || true
    fi
    exit "$rc"
}
trap finish EXIT
exec >>"$LOG" 2>&1

echo "=== System backup started: $(date) ==="\n\n
mountpoint -q "$NAS"\n\n[[ "$(findmnt -n -o FSTYPE "$NAS")" == 'cifs' ]]
test -d "$DEST"
WORK="$(mktemp -d /var/tmp/allsky-system-XXXXXXXX)"
STAMP="$(date +%Y%m%d_%H%M%S)"
ARCHIVE="allsky-system_${STAMP}.tar.gz"
mkdir -p "$WORK/restore-info"

dpkg-query -W -f='${binary:Package} ${Version}\n' \
    > "$WORK/restore-info/packages.txt"
crontab -l > "$WORK/restore-info/root-crontab.txt"
crontab -u dante -l > "$WORK/restore-info/dante-crontab.txt"
git -C /home/dante/indi-allsky rev-parse HEAD \
    > "$WORK/restore-info/indi-allsky-commit.txt"
systemctl list-unit-files --state=enabled --no-pager \
    > "$WORK/restore-info/system-services.txt"
find /home/dante/.config/systemd/user -maxdepth 2 \
    \( -type f -o -type l \) -print \
    > "$WORK/restore-info/user-services.txt"
/home/dante/Projects/adsb-project/venv/bin/python -m pip freeze \
    > "$WORK/restore-info/adsb-requirements.txt"

tar -C / -czf "$WORK/$ARCHIVE" \
    --exclude='home/dante/indi-allsky/virtualenv' \
    --exclude='home/dante/indi-allsky/.git' \
    --exclude='home/dante/Projects/adsb-project/venv' \
    --exclude='home/dante/Projects/adsb-project/logs' \
    --exclude='home/dante/Projects/adsb-project/out' \
    --exclude='home/dante/.cache' \
    --exclude='home/dante/indi-allsky/log' \
    --exclude='home/dante/indi-allsky/html' \
    --exclude='home/dante/indi-allsky/content' \
    --exclude='home/dante/indi-allsky/testing' \
    --exclude='home/dante/indi-allsky/examples' \
    boot/firmware etc root \
    home/dante/.config home/dante/.indi \
    home/dante/indi-allsky-watchdog.sh \
    home/dante/youtube-client-secret.json \
    home/dante/indi-allsky \
    home/dante/Projects/adsb-project \
    usr/local/etc usr/local/bin usr/local/lib

tar -C "$WORK" -czf "$WORK/restore-info.tar.gz" restore-info
tar -tzf "$WORK/$ARCHIVE" >/dev/null
tar -tzf "$WORK/restore-info.tar.gz" >/dev/null

transfer() {
    local src="$1" dst="$2" tmp="${2}.part"
    rm -f -- "$tmp"
    cp -- "$src" "$tmp"
    cmp -s -- "$src" "$tmp"
    mv -f -- "$tmp" "$dst"
}
transfer "$WORK/$ARCHIVE" "$DEST/$ARCHIVE"
transfer "$WORK/restore-info.tar.gz" \
    "$DEST/allsky-restore-info_${STAMP}.tar.gz"

# Keep the four most recent complete backup sets.
mapfile -t archives < <(find "$DEST" -maxdepth 1 -type f \
    -name 'allsky-system_*.tar.gz' -printf '%f\n' | sort -r)
for ((i=4; i<${#archives[@]}; i++)); do
    old="${archives[$i]}"
    suffix="${old#allsky-system_}"
    suffix="${suffix%.tar.gz}"
    rm -f -- "$DEST/$old"
    rm -f -- "$DEST/allsky-restore-info_${suffix}.tar.gz"
done
echo "=== SYSTEM BACKUP SUCCESSFUL: $(date) ==="
sudo chmod 700 /usr/local/bin/backup_allsky_system.sh
sudo bash -n /usr/local/bin/backup_allsky_system.sh
sudo flock -n /var/lock/backup_allsky_system.lock /usr/local/bin/backup_allsky_system.sh
sudo tail -n 35 /var/log/backup_allsky_system.log

After a successful test, add the following entry using ` sudo crontab -e`:

0 4 * * 0 /usr/bin/flock -n /var/lock/backup_allsky_system.lock /usr/local/bin/backup_allsky_system.sh

5. Error Email Instead of a Daily Success Notification

I don’t need a success email every morning. What matters is that a failure doesn’t go unnoticed. The scripts write to /var/log/backup_allsky_nas.log and /var/log/backup_allsky_system.log, respectively, and send an email with the latest log lines in case of errors.

The system-wide file /etc/msmtprc must be protected, especially if it contains SMTP credentials:

sudo chown root:root /etc/msmtprc
sudo chmod 600 /etc/msmtprc

A simple test of email delivery:

printf 'Subject: Allsky Backup Test\n\nSMTP Test.\n' | sudo msmtp -a default mail@BEISPIEL.DE

In my case, the SMTP server was accessible, but the stored password was initially out of date. Only after a test email actually arrived did I trust the error notification. I also tested the alert mechanism itself once using a simulated error.

6. Checking Backups—and Restoring a File

For me, the most important thing isn’t that a script successfully writes a backup, but that the files are actually usable. For database backups, I therefore check the GZIP integrity and run `PRAGMA integrity_check ` on a temporarily extracted SQLite copy. The transfer to the FRITZ.NAS is compared byte-for-byte.

For the system backup, I extracted the Apache configuration from the TAR archive into a temporary directory as a test and compared it with the production file. The result: RESTORE SUCCESSFUL. Nothing on the running system was overwritten.

The following test explicitly extracts only one file:

NAS="/mnt/backup_allsky/backup_allsky/system"
BACKUP="$(find "$NAS" -maxdepth 1 -type f -name 'allsky-system_*.tar.gz' \
    -printf '%f\n' | sort -r | head -1)"
[[ -n "$BACKUP" ]] || { echo 'No backup found'; exit 1; }
TESTDIR="$(mktemp -d)"
sudo tar -xzf "$NAS/$BACKUP" -C "$TESTDIR" etc/apache2/apache2.conf
if sudo cmp -s /etc/apache2/apache2.conf "$TESTDIR/etc/apache2/apache2.conf"; then
    echo 'RESTORE SUCCESSFUL'
fi
sudo rm -rf -- "$TESTDIR"

7. What I Deliberately No Longer Back Up

In the earlier Phase 2, images and timelapses were evaluated based on star count and moon brightness and copied to the external SSD. While this is technically possible, it is no longer part of my NAS backup.

My camera collects large amounts of image data. The time-lapses are now also published on YouTube; the original images remain stored locally and are managed by indi-allsky according to the retention rules configured there. YouTube does not replace a complete backup of the original images. I consciously accept this residual risk because I want to reduce network load and storage consumption.

Conclusion

Instead of two extensive backup phases, I now have a manageable workflow: indi-allsky backs up its own database, a daily job transfers the database, configuration, and migrations to the FRITZ.NAS, and on Sundays, a compact system backup is added. Both jobs have been successfully tested manually; retrieving a single file also worked.

I have not yet performed a full restore test to a new SSD. Equally important: Some of the archives contain login credentials and are not additionally encrypted on my NAS. Therefore, the share must be protected against unauthorized access.

For me, this is the much more practical solution: less data traffic, traceable checks, and a notification if something goes wrong.

As of October 2026. Paths, username, SMTP recipient, and installed components must be adapted to your own indi-allsky installation.

Enjoyed this post?

You can support allsky-rodgau.de with a small coffee on BuyMeACoffee.

Buy me a coffee!