Hardening Nextcloud Backups with Restic and Filen
Build secure, automated Nextcloud backups in a Proxmox LXC with Restic, Filen WebDAV, read-only mounts, retention, restore checks, and ntfy alerts.
I wanted a backup of my Nextcloud files that was independent from the Nextcloud container itself. Proxmox snapshots are useful for quickly rolling back a container, but they are not a substitute for an encrypted, off-site backup with its own retention policy.
My solution is a small Debian backup LXC. It sees the Nextcloud data directory through a read-only bind mount, starts the Filen WebDAV bridge only for the duration of the job, and writes an encrypted Restic repository through Rclone. ntfy sends me a notification when the job finishes or fails.
This protects the backup job from accidentally modifying the live data. It does not make the entire design invulnerable: anyone who gains control of the backup LXC, its Filen session, or the Restic password can still affect recovery. I therefore keep the backup container separate and test restores instead of treating a successful upload as proof that the backup works.
Architecture Overview
The read-only mount is the important boundary here. The backup container can write its own logs, lock file, and temporary state, but the backup script cannot delete or alter files in the mounted Nextcloud data directory.
flowchart TB
NC[Nextcloud LXC or VM]
STORAGE[Proxmox storage]
BACKUP["Backup LXC<br/>read-write operating system"]
RESTIC["Restic<br/>encrypted repository"]
WEBDAV["Filen WebDAV<br/>local bridge"]
FILEN[Filen cloud]
NTFY["ntfy<br/>status notification"]
STORAGE -->|read-only bind mount| BACKUP
NC -->|production data| STORAGE
BACKUP -->|restic backup| RESTIC
RESTIC -->|rclone WebDAV remote| WEBDAV
WEBDAV --> FILEN
BACKUP -->|success or failure| NTFY
The repository is encrypted by Restic before it reaches Filen. Filen provides the remote storage and its own encryption model, but I still treat the Restic password as essential recovery material. Without it, the repository cannot be restored.
Prerequisites and Assumptions
This guide assumes that I already have a running Nextcloud instance and a Proxmox host. The host must be able to expose the Nextcloud data directory to a second LXC as a read-only mount. The exact path and container IDs are specific to each installation.
I also need the following before creating the scheduled job:
- A Debian-based backup LXC with enough local space for logs and temporary files.
- A Filen account and an authenticated Filen CLI session.
- Restic, Rclone,
curl, andflockinstalled in the backup container. - An ntfy topic that is not reused for unrelated public notifications.
- A securely stored copy of the Restic password and the Filen session credentials.
The examples use /mnt/nextcloud-data for the read-only mount and /root/.config/rclone/rclone.conf for the Rclone configuration. Replace both paths with the layout used in the actual container.
Create the Read-Only Backup LXC
I created a separate unprivileged LXC for the backup process. On the Proxmox host, I added a read-only mount point to the backup container configuration. Replace the container ID and host path with the values from the local setup.
1
mp0: /pool/nextcloud_data,mp=/mnt/nextcloud-data,ro=1
After restarting the backup container, I verify both the mount and its permissions before installing the backup software:
1
2
findmnt /mnt/nextcloud-data
touch /mnt/nextcloud-data/backup-write-test
The touch command should fail with a permission error. I remove the test command from any shell history or logs if the path contains sensitive information. A read-only mount is a useful safety boundary, but it is not a replacement for correct LXC isolation and host security.
Install the Backup Tools
I install the distribution packages first so the backup container has the basic tools needed by the script. I install the Filen CLI using the current instructions from Filen and inspect third-party installation scripts before running them.
1
2
apt update
apt install -y restic rclone fuse3 curl ca-certificates util-linux
The Filen CLI installation and login are interactive. I run them manually as root, then confirm that the CLI can access the intended account before continuing:
1
2
3
curl -fsSL https://filen.io/cli.sh | bash
/root/.filen-cli/bin/filen login
/root/.filen-cli/bin/filen --help
Piping a downloaded script directly to a shell is convenient but deserves scrutiny. I use the official Filen documentation and CLI repository to confirm the installation command before running it, and I do not put the resulting session token in the article or in a world-readable file.
Configure the Filen WebDAV Remote
Restic can use an Rclone remote as its repository backend. Filen exposes a local WebDAV endpoint, so the bridge only needs to listen on loopback while the backup is running.
For the one-time Rclone setup, I start the bridge with a local username and password that are unrelated to my Filen password:
1
2
3
4
5
6
7
/root/.filen-cli/bin/filen webdav \
--w-port 1900 \
--w-hostname 127.0.0.1 \
--w-user backupuser \
--w-password 'replace-with-a-local-bridge-password' \
>/tmp/filen-webdav-setup.log 2>&1 &
bridge_pid=$!
I then run rclone config and create a WebDAV remote with these values:
- Remote name:
filen-local - Storage type:
webdav - URL:
http://127.0.0.1:1900 - Vendor:
other - User: the local WebDAV username
- Password: the local WebDAV password
After saving the remote, I stop the temporary bridge and remove the setup log if it contains connection details:
1
2
3
kill "$bridge_pid"
wait "$bridge_pid" 2>/dev/null || true
rm -f /tmp/filen-webdav-setup.log
Initialize the Restic Repository
I keep the repository location and password outside the executable script. The environment file is readable only by root and is never committed to a repository.
1
2
install -o root -g root -m 600 /dev/null /etc/nextcloud-backup.env
${EDITOR:-vi} /etc/nextcloud-backup.env
The file contains placeholders like these. I replace every value locally:
1
2
3
4
5
6
7
NEXTCLOUD_DATA=/mnt/nextcloud-data
WEBDAV_PORT=1900
WEBDAV_USER=backupuser
WEBDAV_PASS=replace-with-a-local-bridge-password
RESTIC_REPOSITORY=rclone:filen-local:/nextcloud-backups
RESTIC_PASSWORD_FILE=/root/.config/restic/password
NTFY_URL=https://ntfy.sh/replace-with-a-private-topic
I store the Restic password separately and protect it with the same permissions:
1
2
install -o root -g root -m 600 /dev/null /root/.config/restic/password
${EDITOR:-vi} /root/.config/restic/password
With the WebDAV bridge running, I initialize the repository once:
1
2
3
4
5
set -a
. /etc/nextcloud-backup.env
set +a
restic init
restic snapshots
I save the Restic password in a password manager or another independent recovery location. A backup repository without its password is not recoverable.
Choose Exclusions Carefully
I initially considered excluding all appdata_* and uploads directories because they contain generated files and upload chunks. That is too broad for a general file recovery backup. Upload chunks can represent an in-progress upload, and application data may contain settings that are useful during recovery.
My baseline exclusion file removes only caches, previews, and operating-system noise. I keep the actual Nextcloud files and upload directories in the repository:
1
2
3
4
**/cache/**
**/preview/**
.DS_Store
Thumbs.db
I create the file with root ownership:
1
2
install -o root -g root -m 644 /dev/null /etc/restic-excludes.txt
${EDITOR:-vi} /etc/restic-excludes.txt
Exclusions are a recovery decision, not just a way to reduce storage. If the goal is a complete Nextcloud data restore, I keep anything that has not been verified as reproducible. Database dumps and the Nextcloud configuration are separate backup concerns and should be included in a tested disaster-recovery plan.
Run the Backup Script
The script below starts the bridge, checks that the local WebDAV endpoint responds, verifies that Restic can open the repository, and then creates a snapshot. flock prevents two scheduled runs from pruning the repository at the same time.
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#!/usr/bin/env bash
set -Eeuo pipefail
readonly CONFIG_FILE=/etc/nextcloud-backup.env
readonly EXCLUDE_FILE=/etc/restic-excludes.txt
readonly LOCK_FILE=/run/nextcloud-backup.lock
readonly FILEN_CLI=/root/.filen-cli/bin/filen
source "$CONFIG_FILE"
export RESTIC_REPOSITORY RESTIC_PASSWORD_FILE
filen_pid=''
send_ntfy() {
local message="$1"
local priority="$2"
curl --fail --silent --show-error \
--header 'Title: Nextcloud backup status' \
--header "Priority: $priority" \
--data-raw "$message" \
"$NTFY_URL" >/dev/null
}
cleanup() {
local exit_code=$?
if [[ "$exit_code" -ne 0 ]]; then
send_ntfy 'Nextcloud backup failed. Check /var/log/nextcloud-backup.log.' high || true
fi
if [[ -n "$filen_pid" ]] && kill -0 "$filen_pid" 2>/dev/null; then
kill "$filen_pid" || true
wait "$filen_pid" 2>/dev/null || true
fi
}
trap cleanup EXIT
exec 9>"$LOCK_FILE"
flock -n 9 || exit 0
"$FILEN_CLI" webdav \
--w-port "$WEBDAV_PORT" \
--w-hostname 127.0.0.1 \
--w-user "$WEBDAV_USER" \
--w-password "$WEBDAV_PASS" \
>/tmp/filen-webdav.log 2>&1 &
filen_pid=$!
for attempt in {1..15}; do
if curl --fail --silent --show-error \
--user "$WEBDAV_USER:$WEBDAV_PASS" \
--request PROPFIND \
--header 'Depth: 0' \
"http://127.0.0.1:$WEBDAV_PORT/" >/dev/null; then
break
fi
if [[ "$attempt" -eq 15 ]]; then
cat /tmp/filen-webdav.log >&2
exit 1
fi
sleep 1
done
restic snapshots >/dev/null
restic backup "$NEXTCLOUD_DATA" --exclude-file="$EXCLUDE_FILE"
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune
send_ntfy 'Nextcloud backup completed and old snapshots were pruned.' default
I save the script and make it executable. The temporary WebDAV log should be stored somewhere with restricted permissions, or redirected to the job log after confirming that the CLI does not print secrets.
1
2
3
install -o root -g root -m 700 /dev/null /usr/local/bin/nextcloud-backup.sh
${EDITOR:-vi} /usr/local/bin/nextcloud-backup.sh
/usr/local/bin/nextcloud-backup.sh
The first manual run is important. It catches mount permissions, an incorrect Rclone remote, a missing Restic password, and a WebDAV startup problem before automation hides the useful error behind a cron log.
Schedule and Monitor the Job
I use cron here because the backup container is small and the schedule is simple. The lock in the script makes an accidental overlap harmless, while the redirect keeps the command output available for troubleshooting.
1
crontab -e
1
0 2 * * * /usr/local/bin/nextcloud-backup.sh >>/var/log/nextcloud-backup.log 2>&1
I check the log after the first scheduled run and confirm that ntfy delivered the expected message. A notification is only an operational signal. It does not prove that every file is restorable.
Test the Repository and Restore
I run a repository consistency check regularly, preferably from a separate maintenance schedule because checking all repository data can take time:
1
2
3
4
set -a
. /etc/nextcloud-backup.env
set +a
restic check
For a restore test, I choose a disposable directory and restore a recent snapshot into it. I compare representative files, permissions, and timestamps with the source data before deleting the test directory:
1
2
3
4
mkdir -p /var/tmp/nextcloud-restore-test
restic restore latest --target /var/tmp/nextcloud-restore-test
find /var/tmp/nextcloud-restore-test -type f | head
rm -rf /var/tmp/nextcloud-restore-test
The Restic repository contains files, not a complete Nextcloud service. A real disaster recovery also needs the Nextcloud configuration, database, container configuration, and a documented way to bring the service back online. I keep those items in a separate recovery checklist.
Limitations and Security Notes
This design improves separation, but it does not solve every backup threat:
- A compromised Proxmox host can access both containers and their storage.
- The Filen session and Rclone configuration must be protected like passwords.
- The Restic password is required for recovery and must be stored independently.
- An ntfy topic URL is a bearer secret. I do not publish it or use a predictable topic name.
-
restic forget --prunedeletes data that is no longer referenced by retained snapshots. I verify the retention policy before relying on it. - A read-only source mount protects live files from this script, but it cannot protect the backup repository from a compromised backup container.
🔗 References
- Proxmox VE storage and mount point documentation
- Restic documentation
- Restic repository maintenance
- Rclone WebDAV backend
- Filen documentation
- ntfy publishing messages
- crontab manual
Final Thoughts
This setup gives me encrypted, deduplicated, off-site snapshots without giving the backup script write access to the live Nextcloud data. The read-only mount and short-lived WebDAV bridge reduce the blast radius of a mistake, while the lock and ntfy notification make the daily job easier to operate.
The part I trust most is not the cron entry. It is the restore test. A backup becomes useful only after I have opened the repository, restored real files, and documented the remaining pieces needed to rebuild Nextcloud.
