SECURITY

Back up a website to a NAS with Restic

Build a non-server copy, encrypted, monitored and actually restoreable.

Serveur web sauvegardé vers un NAS distant en trois étapes : copier, chiffrer et vérifier

One volume Docker persistent or an archive stored on the same VPS is not enough. A true backup must remain available after the loss of the main server and its restoration must be verified.

The chosen architecture

The server first produces a consistent copy of the database, creates an archive controlled by SHA-256, and then sends it to a Restic encrypted repository on a remote NAS. A heartbeat confirms success only after the remote copy.

Architecture complète de sauvegarde entre un VPS et un NAS distant

1. Identify data to be protected

List databases, files sent by users, non-reproducible configurations and secrets kept outside the repository. Container images and Git code are generally reconstructable; the application data are not.

Comparaison entre un volume persistant sur le même serveur et une sauvegarde sur une autre machine

2. Create a consistent SQLite copy

Copying an active SQLite database directly can ignore the entries in the WAL log. Use the SQLite backup API, then check the source and copy.

python3 - <<'PY'
import sqlite3
source = sqlite3.connect("file:/data/database.sqlite?mode=ro", uri=True)
target = sqlite3.connect("/tmp/database.sqlite")
source.backup(target)
assert target.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
target.close()
source.close()
PY

3. Archive and check locally

Create a date archive and its sum SHA-256. This local copy facilitates quick recovery, but it remains insufficient alone.

tar -czf "/var/backups/site/site-$STAMP.tgz" -C /tmp database.sqlite
cd /var/backups/site
sha256sum "site-$STAMP.tgz" > "site-$STAMP.tgz.sha256"
sha256sum -c "site-$STAMP.tgz.sha256"

4. Connect the server to the NAS without exposing SSH

A private network mesh like Tailscale allows the VPS to join the NAS without public redirection of port 22. Create a dedicated SFTP account on the NAS, limited to the backup folder, and use a separate SSH key.

5. Initialize Restic encrypted repository

Keep the Restic password and private key out of the Git repository, in files accessible only to root. Also keep a copy of the password outside the saved server.

restic --repo 'sftp:backup@nas:Backups/site-restic' \
  --password-file /etc/mon-site/restic-password init

restic --repo 'sftp:backup@nas:Backups/site-restic' \
  --password-file /etc/mon-site/restic-password \
  backup /var/backups/site --tag site-web
Sortie anonymisée d’une sauvegarde Restic réussie

6. Apply retention

Daily retention limits the space used while maintaining a useful history. Adapt it to your business and obligations.

restic forget --tag site-web --keep-daily 30 --prune

7. Send the heartbeat after success

Place the monitoring call at the end of the script. If the creation of the archive, the Restic copy or retention fails, no signal should be sent.

Contrôle de sauvegarde à jour dans un centre de surveillance

8. Read all data regularly

Plan a full weekly check. It checks the indexes, snapshots and contents of the packs, not just their presence.

restic check --read-data
Contrôle intégral Restic terminé sans erreur

9. Test an actual restore

A visible snapshot proves that Restic recorded data. It does not yet prove that the application archive opens or that its database is consistent. A useful test restores the latest snapshot into an isolated directory, checks every archive and validates every extracted SQLite database.

First load your Restic repository variables in a Bash terminal without displaying the password. The following block never touches live data and automatically removes its temporary copy:

#!/usr/bin/env bash
set -eu
: "${RESTIC_REPOSITORY:?Missing variable}"
: "${RESTIC_PASSWORD_FILE:?Missing variable}"

RESTORE_DIR=$(mktemp -d /tmp/restic-restore-test.XXXXXX)
trap 'rm -rf -- "$RESTORE_DIR"' EXIT

restic \
  --repo "$RESTIC_REPOSITORY" \
  --password-file "$RESTIC_PASSWORD_FILE" \
  restore latest \
  --target "$RESTORE_DIR"

find "$RESTORE_DIR" -type f -name '*.tgz' -print -quit | grep -q . || {
  echo 'No restored .tgz archive found' >&2
  exit 1
}

while IFS= read -r -d '' archive
do
  tar -tzf "$archive" >/dev/null
  extract=$(mktemp -d "$RESTORE_DIR/extracted.XXXXXX")
  tar -xzf "$archive" -C "$extract"

  python3 - "$extract" <<'PY'
import sqlite3
import sys
from pathlib import Path

databases = [
    path for path in Path(sys.argv[1]).rglob("*")
    if path.is_file() and path.suffix in {".db", ".sqlite", ".sqlite3"}
]
if not databases:
    raise SystemExit("No restored SQLite database found")

for path in databases:
    connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
    try:
        result = connection.execute("PRAGMA integrity_check").fetchone()[0]
    finally:
        connection.close()
    print(f"{path.name}: {result}")
    if result != "ok":
        raise SystemExit(1)
PY
done < <(find "$RESTORE_DIR" -type f -name '*.tgz' -print0)

echo 'Restic restore and SQLite integrity: OK'

Adjust the archive filter if your backup does not use .tgz. Never restore directly over the production database for this check.

Restauration Restic dans un dossier temporaire et validation de l’archive Les six validations d’une sauvegarde restaurable

Frequent errors

  • consider a volume Docker as a backup;
  • archive an active SQLite database without taking WAL into account;
  • keep all copies on the same VPS;
  • use the NAS administrator account;
  • send the heartbeat before the remote copy;
  • never test a restore.

Watch your backups

A heartbeat detects the absence of the expected signal after a planned backup. It completes the copy and restore tests; it does not replace them.

Create backup control

A simple strategy can already be solid: a consistent copy, a local archive, an encrypted repository on another machine, monitoring and a restoration test. The expected result is not a created file, but a proven recovery.

Published on 5 August 2026 · updated on 23 August 2026.