Skip to main content

Backup Script

Datei: /root/.nextcloud-backup.env

Ändere bitte alle Einträge mit Deinen Werten!

# --- Restic repository & credentials ---
RESTIC_REPOSITORY="/mnt/synology-backup/nextcloud"
RESTIC_PASSWORD_FILE="/root/.restic-password"

# --- Backup target mount (must be mounted + writable) ---
BACKUP_MOUNT="/mnt/synology-backup"

# --- Nextcloud paths on the Docker host (absolute base path) ---
# Example layout:
#   /home/dockeruser/docker/nextcloud/
#     ├─ config/
#     ├─ nextcloud/ (volume bind-mount)
#     │   ├─ data/
#     │   └─ config/   (optional, depending on your setup)
#     └─ docker-compose.yml
NC_BASE="/home/dockeruser/docker/nextcloud"

# Space-separated list of config directories relative to NC_BASE.
# Keep both if you're unsure; the script will include only existing directories.
NC_CONFIG_PATHS="config nextcloud/config"

# Data directory relative to NC_BASE
NC_DATA_PATH="nextcloud/data"

# docker-compose file relative to NC_BASE
NC_COMPOSE_FILE="docker-compose.yml"

# --- Docker container names ---
NC_APP_CONTAINER="nextcloud-app"
NC_DB_CONTAINER="nextcloud-db"

# --- Database credentials inside the DB container ---
PGUSER="nextcloud"
PGDB="nextcloud"

# --- Retention policy ---
KEEP_DAILY="7"
KEEP_WEEKLY="4"
KEEP_MONTHLY="6"

# --- Integrity check (daily subset; keep small for speed) ---
READ_SUBSET="5%"

# --- Pushover ---
PUSHOVER_TOKEN="PUT_YOUR_TOKEN_HERE"
PUSHOVER_USER="PUT_YOUR_USER_KEY_HERE"
# Optional: leave empty to notify all devices
PUSHOVER_DEVICE=""
sudo chown root:root /root/.nextcloud-backup.env
sudo chmod 600 /root/.nextcloud-backup.env

Datei: /root/nextcloud-backup.sh

#!/bin/bash
set -euo pipefail

# =========================
# Nextcloud Restic Backup
# =========================

# ---- Load config/secrets ----
ENV_FILE="/root/.nextcloud-backup.env"
if [[ -f "$ENV_FILE" ]]; then
  source "$ENV_FILE"
else
  echo "ERROR: Env file not found: $ENV_FILE"
  exit 1
fi

# ---- Required env vars ----
: "${RESTIC_REPOSITORY:?RESTIC_REPOSITORY not set}"
: "${RESTIC_PASSWORD_FILE:?RESTIC_PASSWORD_FILE not set}"
: "${BACKUP_MOUNT:?BACKUP_MOUNT not set}"

: "${NC_BASE:?NC_BASE not set}"
: "${NC_CONFIG_PATHS:?NC_CONFIG_PATHS not set}"
: "${NC_DATA_PATH:?NC_DATA_PATH not set}"
: "${NC_COMPOSE_FILE:?NC_COMPOSE_FILE not set}"

: "${NC_APP_CONTAINER:?NC_APP_CONTAINER not set}"
: "${NC_DB_CONTAINER:?NC_DB_CONTAINER not set}"

: "${PGUSER:?PGUSER not set}"
: "${PGDB:?PGDB not set}"

: "${KEEP_DAILY:?KEEP_DAILY not set}"
: "${KEEP_WEEKLY:?KEEP_WEEKLY not set}"
: "${KEEP_MONTHLY:?KEEP_MONTHLY not set}"
: "${READ_SUBSET:?READ_SUBSET not set}"

: "${PUSHOVER_TOKEN:?PUSHOVER_TOKEN not set}"
: "${PUSHOVER_USER:?PUSHOVER_USER not set}"
# PUSHOVER_DEVICE is optional

export RESTIC_REPOSITORY
export RESTIC_PASSWORD_FILE

# ---- Logging ----
LOGFILE="/var/log/nextcloud-backup.log"
CSVLOG="/var/log/nextcloud-backup.csv"
HOSTNAME="$(hostname -s)"

exec >> "$LOGFILE" 2>&1

log() { echo "[$(date '+%F %T')] $*"; }

csv_log() {
  local STATUS="$1"     # OK / FAIL
  local DURATION="$2"   # seconds
  local SNAPSHOT="${3:-}"
  local TS
  TS="$(date --iso-8601=seconds)"

  if [[ ! -f "$CSVLOG" ]]; then
    echo "timestamp,host,status,duration_seconds,snapshot_id" > "$CSVLOG"
    chmod 600 "$CSVLOG" || true
  fi

  echo "${TS},${HOSTNAME},${STATUS},${DURATION},${SNAPSHOT}" >> "$CSVLOG"
}

# ---- Pushover ----
pushover() {
  local TITLE="$1"
  local MESSAGE="$2"
  local PRIORITY="${3:-0}"

  local DEVICE_ARG=()
  if [[ -n "${PUSHOVER_DEVICE:-}" ]]; then
    DEVICE_ARG=(-F "device=$PUSHOVER_DEVICE")
  fi

  curl -s \
    -F "token=$PUSHOVER_TOKEN" \
    -F "user=$PUSHOVER_USER" \
    "${DEVICE_ARG[@]}" \
    -F "title=$TITLE" \
    -F "priority=$PRIORITY" \
    -F "message=$MESSAGE" \
    https://api.pushover.net/1/messages.json >/dev/null || true
}

# ---- Wait for backup target (mounted + writable) ----
wait_for_backup_target() {
  local TARGET_MOUNT="$BACKUP_MOUNT"
  local MAX_WAIT=900        # 15 minutes
  local INTERVAL=15         # seconds
  local waited=0

  log "Checking backup target: ${TARGET_MOUNT}"

  while true; do
    if mountpoint -q "$TARGET_MOUNT"; then
      if timeout 5 sh -c "echo test > '${TARGET_MOUNT}/.nc_backup_writetest'"; then
        rm -f "${TARGET_MOUNT}/.nc_backup_writetest" >/dev/null 2>&1 || true
        log "Backup target OK (mounted + writable)"
        return 0
      fi
      log "Backup target mounted but not writable yet."
    else
      log "Backup target not mounted yet."
    fi

    if (( waited >= MAX_WAIT )); then
      log "ERROR: Backup target not ready after ${MAX_WAIT}s"
      pushover "Nextcloud Backup FAILED" "Backup target (${TARGET_MOUNT}) not available/writable after ${MAX_WAIT}s. Aborting." 1
      exit 1
    fi

    sleep "$INTERVAL"
    waited=$((waited + INTERVAL))
  done
}

# ---- Maintenance cleanup ----
cleanup() {
  docker exec -u www-data "$NC_APP_CONTAINER" php occ maintenance:mode --off >/dev/null 2>&1 || true
}

START_TS=$(date +%s)
SNAPSHOT_ID=""

on_error() {
  local exit_code=$?
  local now dur
  now=$(date +%s)
  dur=$((now-START_TS))

  log "ERROR: Backup failed (exit=${exit_code}) after ${dur}s"
  csv_log "FAIL" "$dur" "${SNAPSHOT_ID:-}"
  pushover "Nextcloud Backup FAILED" "Backup failed after ${dur}s (exit=${exit_code}). Check ${LOGFILE}" 1

  cleanup
  exit "$exit_code"
}

trap on_error ERR
trap cleanup EXIT

log "========== START BACKUP =========="

# Wait for NAS/NFS target before doing anything
wait_for_backup_target

log "Enabling maintenance mode"
docker exec -u www-data "$NC_APP_CONTAINER" php occ maintenance:mode --on

# ---- Build backup path list from env ----
BACKUP_PATHS=()

# Config directories (only include existing ones)
for cfg in $NC_CONFIG_PATHS; do
  FULL_CFG="$NC_BASE/$cfg"
  if [[ -d "$FULL_CFG" ]]; then
    BACKUP_PATHS+=("$FULL_CFG")
  fi
done

# Compose file
if [[ -f "$NC_BASE/$NC_COMPOSE_FILE" ]]; then
  BACKUP_PATHS+=("$NC_BASE/$NC_COMPOSE_FILE")
else
  log "WARNING: Compose file not found: $NC_BASE/$NC_COMPOSE_FILE"
fi

# Data directory
if [[ -d "$NC_BASE/$NC_DATA_PATH" ]]; then
  BACKUP_PATHS+=("$NC_BASE/$NC_DATA_PATH")
else
  log "WARNING: Data directory not found: $NC_BASE/$NC_DATA_PATH"
fi

if [[ ${#BACKUP_PATHS[@]} -eq 0 ]]; then
  log "ERROR: No valid backup paths found. Check NC_BASE / NC_CONFIG_PATHS / NC_DATA_PATH / NC_COMPOSE_FILE."
  exit 1
fi

log "Restic backup paths: ${BACKUP_PATHS[*]}"
log "Running restic backup (files)"
restic backup --tag nextcloud --tag daily "${BACKUP_PATHS[@]}"

log "Running restic backup (database dump via stdin -> db.sql)"
docker exec -t "$NC_DB_CONTAINER" pg_dump -U "$PGUSER" -d "$PGDB" \
  | restic backup --stdin --stdin-filename "db.sql" --tag nextcloud --tag daily

log "Disabling maintenance mode"
docker exec -u www-data "$NC_APP_CONTAINER" php occ maintenance:mode --off

# Snapshot ID (best-effort)
SNAPSHOT_ID="$(restic snapshots --tag nextcloud --latest 1 --json 2>/dev/null \
  | grep -m1 '"short_id"' \
  | sed -E 's/.*"short_id": ?"([^"]+)".*/\1/' || true)"

log "Retention: forget/prune (daily=${KEEP_DAILY}, weekly=${KEEP_WEEKLY}, monthly=${KEEP_MONTHLY})"
restic forget \
  --tag nextcloud \
  --keep-daily "$KEEP_DAILY" \
  --keep-weekly "$KEEP_WEEKLY" \
  --keep-monthly "$KEEP_MONTHLY" \
  --prune

log "Integrity check (read-data-subset=${READ_SUBSET})"
restic check --read-data-subset="$READ_SUBSET" >/dev/null

END_TS=$(date +%s)
DUR=$((END_TS-START_TS))

log "Backup finished in ${DUR}s (snapshot=${SNAPSHOT_ID:-unknown})"
log "========== END BACKUP =========="

csv_log "OK" "$DUR" "${SNAPSHOT_ID:-}"
pushover "Nextcloud Backup OK" "Backup successful in ${DUR}s (snapshot=${SNAPSHOT_ID:-unknown})."
sudo chown root:root /root/nextcloud-backup.sh
sudo chmod 700 /root/nextcloud-backup.sh

Datei: /root/nextcloud-restore-test.sh

#!/bin/bash
set -euo pipefail

# =========================
# Monthly Restic Restore Test
# =========================

ENV_FILE="/root/.nextcloud-backup.env"
if [[ -f "$ENV_FILE" ]]; then
  source "$ENV_FILE"
else
  echo "ERROR: Env file not found: $ENV_FILE"
  exit 1
fi

: "${RESTIC_REPOSITORY:?RESTIC_REPOSITORY not set}"
: "${RESTIC_PASSWORD_FILE:?RESTIC_PASSWORD_FILE not set}"
: "${PUSHOVER_TOKEN:?PUSHOVER_TOKEN not set}"
: "${PUSHOVER_USER:?PUSHOVER_USER not set}"

export RESTIC_REPOSITORY
export RESTIC_PASSWORD_FILE

LOGFILE="/var/log/nextcloud-restore-test.log"
exec >> "$LOGFILE" 2>&1

log() { echo "[$(date '+%F %T')] $*"; }

pushover() {
  local TITLE="$1"
  local MESSAGE="$2"
  local PRIORITY="${3:-0}"

  local DEVICE_ARG=()
  if [[ -n "${PUSHOVER_DEVICE:-}" ]]; then
    DEVICE_ARG=(-F "device=$PUSHOVER_DEVICE")
  fi

  curl -s \
    -F "token=$PUSHOVER_TOKEN" \
    -F "user=$PUSHOVER_USER" \
    "${DEVICE_ARG[@]}" \
    -F "title=$TITLE" \
    -F "priority=$PRIORITY" \
    -F "message=$MESSAGE" \
    https://api.pushover.net/1/messages.json >/dev/null || true
}

# Helper: pick first real path from `restic find` output
pick_find_path() {
  # Prefer lines that start with "/" (real paths)
  local p
  p="$(restic find latest "$1" 2>/dev/null | grep '^/' | head -n 1 || true)"
  if [[ -n "${p:-}" ]]; then
    echo "$p"
    return 0
  fi
  # Fallback: ignore informational lines, take first remaining line
  p="$(restic find latest "$1" 2>/dev/null \
      | grep -v '^Found matching entries' \
      | grep -v '^repository ' \
      | head -n 1 || true)"
  echo "$p"
}

TARGET="/tmp/restore-test-monthly"
rm -rf "$TARGET"
mkdir -p "$TARGET"

log "========== MONTHLY RESTORE TEST =========="
log "Repo: $RESTIC_REPOSITORY"

SNAPSHOT_ID="$(restic snapshots --latest 1 --json 2>/dev/null \
  | grep -m1 '"short_id"' \
  | sed -E 's/.*"short_id": ?"([^"]+)".*/\1/' || true)"

if [[ -z "${SNAPSHOT_ID:-}" ]]; then
  log "ERROR: No snapshots found"
  pushover "Nextcloud Restore-Test FAILED" "No restic snapshots found." 1
  exit 1
fi

log "Latest snapshot: $SNAPSHOT_ID"

CFG_PATH="$(pick_find_path "config.php")"
DB_PATH="$(pick_find_path "db.sql")"

if [[ -z "${CFG_PATH:-}" ]]; then
  log "ERROR: config.php not found in latest snapshot"
  pushover "Nextcloud Restore-Test FAILED" "config.php not found in latest snapshot." 1
  exit 1
fi

if [[ -z "${DB_PATH:-}" ]]; then
  log "ERROR: db.sql not found in latest snapshot"
  pushover "Nextcloud Restore-Test FAILED" "db.sql not found in latest snapshot." 1
  exit 1
fi

# Use relative paths for restore (more compatible across restic versions)
CFG_REL="${CFG_PATH#/}"
DB_REL="${DB_PATH#/}"

log "Restoring config: $CFG_REL"
restic restore latest --target "$TARGET" --include "$CFG_REL"

log "Restoring database: $DB_REL"
restic restore latest --target "$TARGET" --include "$DB_REL"

RESTORED_CFG="$TARGET/$CFG_REL"
RESTORED_DB="$TARGET/$DB_REL"

log "Checking restored files"
log "  CFG: $RESTORED_CFG"
log "  DB : $RESTORED_DB"

if [[ ! -s "$RESTORED_CFG" ]]; then
  log "ERROR: Restored config.php missing/empty"
  ls -lah "$TARGET" || true
  pushover "Nextcloud Restore-Test FAILED" "Restore of config.php failed or file is empty." 1
  exit 1
fi

if [[ ! -s "$RESTORED_DB" ]]; then
  log "ERROR: Restored db.sql missing/empty"
  pushover "Nextcloud Restore-Test FAILED" "Restore of db.sql failed or file is empty." 1
  exit 1
fi

# Real read test
head -n 3 "$RESTORED_CFG" >/dev/null
head -n 3 "$RESTORED_DB" >/dev/null

log "Running quick integrity check (read-data-subset=1%)"
restic check --read-data-subset=1% >/dev/null

rm -rf "$TARGET"

log "Restore test successful (snapshot=$SNAPSHOT_ID)"
log "========== RESTORE TEST OK =========="

pushover "Nextcloud Restore-Test OK" "Restore test successful. Snapshot: $SNAPSHOT_ID"
sudo chown root:root /root/nextcloud-restore-test.sh
sudo chmod 700 /root/nextcloud-restore-test.sh

 

Wöchentlicher Healthreport

Datei: /root/nextcloud-health-report.sh

#!/bin/bash
set -euo pipefail

# =========================
# Weekly Health Report
# =========================

ENV_FILE="/root/.nextcloud-backup.env"
if [[ -f "$ENV_FILE" ]]; then
  # shellcheck disable=SC1090
  source "$ENV_FILE"
else
  echo "ERROR: Env file not found: $ENV_FILE"
  exit 1
fi

: "${RESTIC_REPOSITORY:?RESTIC_REPOSITORY not set}"
: "${RESTIC_PASSWORD_FILE:?RESTIC_PASSWORD_FILE not set}"
: "${PUSHOVER_TOKEN:?PUSHOVER_TOKEN not set}"
: "${PUSHOVER_USER:?PUSHOVER_USER not set}"

export RESTIC_REPOSITORY
export RESTIC_PASSWORD_FILE

LOGFILE="/var/log/nextcloud-health-report.log"
exec >> "$LOGFILE" 2>&1

log() { echo "[$(date '+%F %T')] $*"; }

pushover() {
  local TITLE="$1"
  local MESSAGE="$2"
  local PRIORITY="${3:-0}"

  local DEVICE_ARG=()
  if [[ -n "${PUSHOVER_DEVICE:-}" ]]; then
    DEVICE_ARG=(-F "device=$PUSHOVER_DEVICE")
  fi

  curl -s \
    -F "token=$PUSHOVER_TOKEN" \
    -F "user=$PUSHOVER_USER" \
    "${DEVICE_ARG[@]}" \
    -F "title=$TITLE" \
    -F "priority=$PRIORITY" \
    -F "message=$MESSAGE" \
    https://api.pushover.net/1/messages.json >/dev/null || true
}

# Prevent concurrent runs with backup/restore-test
LOCKFILE="/var/lock/nextcloud-restic.lock"
exec 9>"$LOCKFILE"
flock -n 9 || { echo "Another backup/restore job is running. Exiting."; exit 0; }

CSVLOG="/var/log/nextcloud-backup.csv"
RESTORE_LOG="/var/log/nextcloud-restore-test.log"

log "========== WEEKLY HEALTH REPORT =========="

# ---- Last backup info from CSV ----
LAST_LINE=""
if [[ -f "$CSVLOG" ]]; then
  LAST_LINE="$(tail -n 1 "$CSVLOG" | tr -d '\r' || true)"
fi

LAST_STATUS="unknown"
LAST_DUR="?"
LAST_SNAP="?"
LAST_TS="?"

if [[ -n "${LAST_LINE:-}" && "$LAST_LINE" != timestamp,* ]]; then
  IFS=',' read -r LAST_TS _host LAST_STATUS LAST_DUR LAST_SNAP <<< "$LAST_LINE"
fi

# ---- Count backups in last 7 days (from restic, tag nextcloud) ----
WEEK_COUNT="?"
if restic snapshots --tag nextcloud --json >/dev/null 2>&1; then
  # Prefer JSON if possible, fallback to plain output count
  WEEK_COUNT="$(restic snapshots --tag nextcloud | grep -E '^[0-9a-f]{8,}' | wc -l | tr -d ' ')"
fi

# ---- Latest snapshot time/id (from restic) ----
LATEST_SNAP_ID="?"
LATEST_SNAP_TIME="?"
if restic snapshots --tag nextcloud --latest 1 --json >/dev/null 2>&1; then
  # Extract short_id and time without jq
  LATEST_SNAP_ID="$(restic snapshots --tag nextcloud --latest 1 --json 2>/dev/null | grep -m1 '"short_id"' | sed -E 's/.*"short_id": ?"([^"]+)".*/\1/' || true)"
  LATEST_SNAP_TIME="$(restic snapshots --tag nextcloud --latest 1 --json 2>/dev/null | grep -m1 '"time"' | sed -E 's/.*"time": ?"([^"]+)".*/\1/' || true)"
fi

# ---- Repo stats (if supported) ----
STATS_LINE="Stats: n/a"
if restic stats --mode=raw-data >/dev/null 2>&1; then
  # raw-data mode typically prints a compact table; keep it short
  STATS_LINE="$(restic stats --mode=raw-data 2>/dev/null | tail -n 5 | tr '\n' ' ' | sed -E 's/[[:space:]]+/ /g' | cut -c1-220)"
elif restic stats >/dev/null 2>&1; then
  STATS_LINE="$(restic stats 2>/dev/null | tail -n 8 | tr '\n' ' ' | sed -E 's/[[:space:]]+/ /g' | cut -c1-220)"
fi

# ---- Locks ----
LOCKS_MSG="Locks: none"
LOCKS_PRIORITY="0"
if restic list locks >/dev/null 2>&1; then
  LOCK_COUNT="$(restic list locks 2>/dev/null | grep -E '^[0-9a-f]{8,}' | wc -l | tr -d ' ' || true)"
  if [[ "${LOCK_COUNT:-0}" -gt 0 ]]; then
    LOCKS_MSG="Locks: ${LOCK_COUNT} (check 'restic unlock' if stale)"
    LOCKS_PRIORITY="1"
  fi
fi

# ---- Restore-test last result (best-effort from log) ----
RESTORE_STATUS="unknown"
if [[ -f "$RESTORE_LOG" ]]; then
  if tail -n 200 "$RESTORE_LOG" | grep -q "RESTORE TEST OK"; then
    RESTORE_STATUS="OK"
  elif tail -n 200 "$RESTORE_LOG" | grep -q "FAILED\|ERROR:"; then
    RESTORE_STATUS="FAIL"
  fi
fi

TITLE="Nextcloud Weekly Health"
PRIO="0"

# Escalate if last backup failed or locks exist
if [[ "${LAST_STATUS}" == "FAIL" ]]; then
  PRIO="1"
fi
if [[ "${LOCKS_PRIORITY}" == "1" ]]; then
  PRIO="1"
fi

MSG=$(
  cat <<EOF
Last backup: ${LAST_STATUS} (${LAST_DUR}s) @ ${LAST_TS} (snap ${LAST_SNAP})
Latest snapshot: ${LATEST_SNAP_ID} @ ${LATEST_SNAP_TIME}
Backups in repo: ${WEEK_COUNT}
Restore test: ${RESTORE_STATUS}
${LOCKS_MSG}
${STATS_LINE}
EOF
)

log "Sending weekly health report (priority=${PRIO})"
pushover "$TITLE" "$MSG" "$PRIO"

log "========== REPORT SENT =========="
sudo chown root:root /root/nextcloud-health-report.sh
sudo chmod 700 /root/nextcloud-health-report.sh

Cron einrichten

Wichtig ist, das Du als root eingeloggt sein musst.

crontab -e

Füge folgende Zeilen an das Ende ein.

Das Backup erfolgt jeden Tag um 3:30 Uhr und monatlich wird ein Restore-Test um 30:30  durchgeführt.

30 3 * * * /root/nextcloud-backup.sh
30 20 1 * * /root/nextcloud-restore-test.sh
0 9 * * 0 /root/nextcloud-health-report.sh