nextcloud-backup is a production-oriented Bash backup utility for self-hosted Nextcloud installations backed by MariaDB. It creates a timestamped .tar.xz archive containing a full MariaDB dump and the Nextcloud installation tree, with maintenance-mode protection, pre-flight validation, structured logging, archive verification, retention-based rotation, rollback-safe cleanup, and optional ntfy push notifications.
The script is intentionally configured through a separate nextcloud-backup.env file. You should not edit nextcloud-backup.sh for normal configuration.
Important: A backup is only real after you have successfully restored it in a test environment. This project creates the archive; you are still responsible for off-site storage, restore testing, encryption policy, monitoring, and disaster-recovery procedures.
- Features
- What this backs up
- Archive format
- How the backup works
- Requirements
- Installation
- Configuration
- Database user
- Running a backup
- Scheduling
- Notifications
- Retention and rotation
- Verifying backups
- Restore guide
- Security model
- Operational notes
- Troubleshooting
- Limitations
- Recommended production checklist
- License
- Single-command Nextcloud backup using
nextcloud-backup.sh. - Environment-file configuration through
nextcloud-backup.envlocated beside the script. - MariaDB logical dump using
mariadb-dump. - Consistent InnoDB snapshot strategy using
--single-transactionand--quick. - Nextcloud maintenance mode integration through
occ maintenance:mode --onand--off. - Full Nextcloud directory archive by default.
- Optional include list for backing up only selected paths under
NC_DIR. - Permanent cache exclusions for regenerable Nextcloud cache, thumbnail, appstore, updater, and preview data.
- Timestamped
.tar.xzarchives for compact storage. - Archive integrity check using
tar --listafter archive creation. - Retention-based cleanup of old archives matching the project backup filename pattern.
- Atomic lock directory to prevent concurrent runs.
- Structured log output to terminal and
nextcloud-backup.log. - Rollback-safe cleanup via
EXIT,ERR,INT,TERM, andHUPtraps. - Automatic maintenance-mode recovery on most failure paths after maintenance mode has been enabled.
- Best-effort ntfy notifications for start, failure, and success events.
- Root-only execution to avoid partial backups caused by insufficient permissions.
- Private default file creation through
umask 0077.
A successful run produces one compressed archive containing:
-
nextcloud-database.sql
A full MariaDB dump of the configured Nextcloud database. -
nextcloud-directory/
A normalized copy of the configured Nextcloud installation directory, regardless of the original filesystem path.
By default, the script archives the entire NC_DIR tree. This normally includes:
config/data/apps/themes/, if present- core Nextcloud application files
- any other files stored under the configured
NC_DIR
The default behavior aligns with the official Nextcloud backup model: retain configuration, custom apps, data, themes, and the database.
Every successful backup creates an archive like this:
nextcloud-backup_YYYY-mm-dd_HH-MM-SS.tar.xz
├── nextcloud-database.sql
└── nextcloud-directory/
├── config/
├── data/
├── apps/
└── ...
Example:
/opt/nextcloud-backup/nextcloud-backup_2026-06-17_03-00-01.tar.xz
The archive contains fixed internal names:
- The database dump is always stored as
nextcloud-database.sql. - The Nextcloud installation tree is always stored as
nextcloud-directory/.
This makes restore procedures predictable even when the original Nextcloud path differs between systems.
The script runs the following workflow:
-
Initialize logging
Logs are written to the terminal and tonextcloud-backup.login the script directory. -
Bootstrap
The script verifies root privileges, checks required commands, loadsnextcloud-backup.env, applies defaults, validates required variables, creates runtime paths, and creates required directories. -
Acquire exclusive lock
A lock directory named.nextcloud-backup.lockis created beside the script. If it already exists, the script aborts to avoid overlapping backups. -
Send start notification
IfNTFY_URLis configured, a start notification is sent. -
Run pre-flight checks
The script verifies:NC_DIRexists.NC_DIR/occexists.WEB_USERexists.- MariaDB credentials can connect successfully.
BACKUP_ROOThas enough available disk space according to the script's disk-space check.
-
Enable Nextcloud maintenance mode
The script runs:sudo -u "$WEB_USER" php "$NC_DIR/occ" maintenance:mode --on
-
Dump the database
The script writes:/tmp/nextcloud-backup-<pid>/nextcloud-database.sql -
Create the archive
The SQL dump and Nextcloud directory are packed into a.tar.xzarchive inBACKUP_ROOT. -
Verify the archive
The script lists the archive withtar --listto confirm that the tar structure and xz stream are readable. -
Disable maintenance mode
Nextcloud access is restored after the archive is verified. -
Rotate old archives
Old files matchingnextcloud-backup_*.tar.xzinBACKUP_ROOTare removed if they are older thanRETENTION_DAYS. -
Compute statistics and notify
Final archive size and runtime duration are logged. If configured, a success notification is sent. -
Cleanup
Temporary files are removed and the lock directory is released.
This script is designed for a host-accessible Nextcloud installation where:
- the Nextcloud directory is available on the host filesystem;
- the
occcommand can be executed fromNC_DIR/occ; - the MariaDB database is reachable from the host using the configured credentials;
- the backup process can run as
root.
It is best suited for traditional bare-metal, VM, LXC, or host-mounted deployments.
For Docker-only deployments, this script can still be adapted, but only if the host can access the Nextcloud files and run the correct occ command. Pure container-only deployments usually need container-aware commands such as docker exec or volume-level backup tooling, which this script does not currently implement.
The script checks for these commands:
| Command | Purpose |
|---|---|
bash |
script runtime |
php |
running Nextcloud occ |
mariadb |
database connectivity test |
mariadb-dump |
database dump |
tar |
archive creation and verification |
xz |
compression backend for tar --xz |
curl |
ntfy notifications |
find |
backup rotation |
df |
free-space check |
du |
size calculation |
awk |
output parsing |
sudo |
running occ as the web-server user |
Bash 4.3+ is recommended. Bash 5.x is preferred on modern Linux distributions.
Debian / Ubuntu:
sudo apt update
sudo apt install bash mariadb-client php-cli tar xz-utils curl findutils coreutils gawk sudoArch Linux:
sudo pacman -S --needed bash mariadb-clients php tar xz curl findutils coreutils gawk sudoRHEL / Rocky / AlmaLinux / Fedora family:
sudo dnf install bash mariadb php-cli tar xz curl findutils coreutils gawk sudoPackage names can differ by distribution and release. Use your distribution's package manager to map the required commands if the examples above do not match your system.
Clone the repository:
git clone https://github.com/avds2/nextcloud-backup.git
cd nextcloud-backupInstall the files somewhere root-owned, for example:
sudo mkdir -p /opt/nextcloud-backup
sudo chown root:root /opt/nextcloud-backup
sudo chmod 700 /opt/nextcloud-backup
sudo cp nextcloud-backup.sh nextcloud-backup.env /opt/nextcloud-backup/
sudo chown root:root /opt/nextcloud-backup/nextcloud-backup.sh /opt/nextcloud-backup/nextcloud-backup.env
sudo chmod 700 /opt/nextcloud-backup/nextcloud-backup.sh
sudo chmod 600 /opt/nextcloud-backup/nextcloud-backup.envThe script and env file must stay in the same directory:
/opt/nextcloud-backup/
├── nextcloud-backup.sh
└── nextcloud-backup.env
Edit only nextcloud-backup.env:
sudo nano /opt/nextcloud-backup/nextcloud-backup.env| Variable | Required | Example | Description |
|---|---|---|---|
DB_HOST |
yes | localhost |
MariaDB host reachable from the backup host. |
DB_NAME |
yes | nextcloud |
Nextcloud database name. |
DB_USER |
yes | backup_user |
MariaDB user used for dumping the database. |
DB_PASSWORD |
yes | strong-password |
Password for DB_USER. |
NC_DIR |
yes | /var/www/nextcloud |
Absolute path to the Nextcloud installation directory containing occ. |
WEB_USER |
yes | www-data |
OS user used to run Nextcloud/PHP and execute occ. |
BACKUP_ROOT |
yes | /opt/nextcloud-backup |
Directory where completed archives are stored. |
| Variable | Default | Description |
|---|---|---|
INCLUDES |
empty | Bash array of paths relative to NC_DIR to include. Empty means archive all of NC_DIR. |
EXCLUDES |
cache/updater/appstore/previews/trashbin defaults | Bash array of tar exclude patterns relative to NC_DIR. |
RETENTION_DAYS |
7 |
Archives older than this many days are removed after a successful backup. Set 0 to disable rotation. |
XZ_LEVEL |
6 |
xz compression preset. 0 is fastest/largest; 9 is slowest/smallest and uses more memory. |
NTFY_URL |
empty | ntfy topic URL for start, failure, and success notifications. Empty disables notifications. |
DB_HOST="localhost"
DB_NAME="nextcloud"
DB_USER="backup_user"
DB_PASSWORD="CHANGE_THIS_TO_A_STRONG_PASSWORD"
NC_DIR="/var/www/nextcloud"
WEB_USER="www-data"
BACKUP_ROOT="/opt/nextcloud-backup"
INCLUDES=()
EXCLUDES=(
"--exclude=updater-*"
"--exclude=data/*/cache"
"--exclude=data/*/thumbnails"
"--exclude=data/appdata_*/preview"
"--exclude=data/appdata_*/appstore"
)
RETENTION_DAYS=7
XZ_LEVEL=6
NTFY_URL=""By default, INCLUDES=() archives the entire NC_DIR tree. This is the safest default.
To archive only selected paths under NC_DIR:
INCLUDES=("config" "data" "apps" "themes")Do not use absolute paths in INCLUDES. These entries must be relative to NC_DIR.
EXCLUDES entries are written as tar-style --exclude= patterns relative to NC_DIR:
EXCLUDES=(
"--exclude=updater-*"
"--exclude=data/*/cache"
"--exclude=data/*/thumbnails"
"--exclude=data/appdata_*/preview"
"--exclude=data/appdata_*/appstore"
"--exclude=data/*/files_trashbin"
)The script automatically prefixes exclude patterns with nextcloud-directory/ internally so they match the archive layout. Do not add nextcloud-directory/ yourself.
Strong recommendation: Be conservative with exclusions. Excluding application data without fully understanding Nextcloud's storage layout can produce backups that are technically valid but operationally incomplete.
Using MariaDB root works, but is not recommended for routine automated backups. Create a dedicated backup user instead.
Example:
CREATE USER 'backup_user'@'localhost' IDENTIFIED BY 'strong-password-here';
GRANT SELECT, LOCK TABLES, SHOW VIEW, TRIGGER, EVENT, PROCESS
ON nextcloud.* TO 'backup_user'@'localhost';
FLUSH PRIVILEGES;Then configure:
DB_USER="backup_user"
DB_PASSWORD="strong-password-here"If DB_HOST is not localhost, adjust the MariaDB host part accordingly:
CREATE USER 'backup_user'@'backup-hostname-or-ip' IDENTIFIED BY 'strong-password-here';For local Unix-socket connections, DB_HOST="localhost" is usually correct. For TCP connections, use an IP address or hostname reachable from the backup host.
Run manually as root:
sudo /opt/nextcloud-backup/nextcloud-backup.shFollow logs live:
sudo tail -f /opt/nextcloud-backup/nextcloud-backup.logList produced archives:
sudo ls -lh /opt/nextcloud-backup/Expected archive filename pattern:
nextcloud-backup_YYYY-mm-dd_HH-MM-SS.tar.xz
Create a service unit:
sudo nano /etc/systemd/system/nextcloud-backup.service[Unit]
Description=Nextcloud backup
Documentation=https://github.com/avds2/nextcloud-backup
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/nextcloud-backup/nextcloud-backup.sh
User=root
Group=rootCreate a timer:
sudo nano /etc/systemd/system/nextcloud-backup.timer[Unit]
Description=Run Nextcloud backup daily
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
Unit=nextcloud-backup.service
[Install]
WantedBy=timers.targetEnable the timer:
sudo systemctl daemon-reload
sudo systemctl enable --now nextcloud-backup.timerCheck timer status:
systemctl list-timers nextcloud-backup.timer
systemctl status nextcloud-backup.timerRun immediately through systemd:
sudo systemctl start nextcloud-backup.serviceInspect logs:
journalctl -u nextcloud-backup.service -n 200 --no-pager
sudo tail -n 200 /opt/nextcloud-backup/nextcloud-backup.logOpen root's crontab:
sudo crontab -eRun daily at 03:00:
0 3 * * * /opt/nextcloud-backup/nextcloud-backup.sh >/dev/null 2>&1The script already writes its own log file, so redirecting cron output is acceptable.
Notifications are optional and disabled by default.
To enable ntfy notifications, set NTFY_URL in nextcloud-backup.env:
NTFY_URL="https://ntfy.sh/my-secret-nextcloud-backup-topic"The script sends notifications for:
- backup started;
- backup failed;
- backup successful.
Notification delivery is best-effort. A failed ntfy request is logged as a warning and does not fail the backup.
If you use the public ntfy.sh service, choose a long, unguessable topic name. Treat the topic URL as a secret. For sensitive environments, self-host ntfy or use another private notification path.
RETENTION_DAYS controls automatic deletion of old archives.
Example:
RETENTION_DAYS=14This removes matching archives older than 14 days from BACKUP_ROOT after a successful backup.
Only files matching this pattern are considered:
nextcloud-backup_*.tar.xz
Disable automatic rotation:
RETENTION_DAYS=0Important: Retention on the same host is not a complete backup strategy. Keep separate off-site or offline copies. A compromised or failed server can destroy both live data and local backups.
The script performs an automatic structural verification:
tar --list --file="/opt/nextcloud-backup/nextcloud-backup_YYYY-mm-dd_HH-MM-SS.tar.xz" >/dev/nullYou can manually list archive contents:
sudo tar --list --file="/opt/nextcloud-backup/nextcloud-backup_YYYY-mm-dd_HH-MM-SS.tar.xz" | lessShow the top-level entries:
sudo tar --list --file="/opt/nextcloud-backup/nextcloud-backup_YYYY-mm-dd_HH-MM-SS.tar.xz" | head -50Check archive size:
sudo du -sh /opt/nextcloud-backup/nextcloud-backup_*.tar.xzOptional: create a checksum file after each backup:
cd /opt/nextcloud-backup
sudo sha256sum nextcloud-backup_YYYY-mm-dd_HH-MM-SS.tar.xz | sudo tee nextcloud-backup_YYYY-mm-dd_HH-MM-SS.tar.xz.sha256Verify later:
cd /opt/nextcloud-backup
sha256sum -c nextcloud-backup_YYYY-mm-dd_HH-MM-SS.tar.xz.sha256
tar --listconfirms that the archive can be read and decompressed. It does not prove that your Nextcloud instance can be restored successfully. Perform periodic restore drills.
This section is intentionally explicit because a backup script is incomplete without a recovery procedure.
The commands below are examples. Adjust paths, service names, database names, PHP version, and web-server user for your environment.
ARCHIVE="/opt/nextcloud-backup/nextcloud-backup_YYYY-mm-dd_HH-MM-SS.tar.xz"
RESTORE_DIR="/root/nextcloud-restore-test"sudo rm -rf "$RESTORE_DIR"
sudo mkdir -p "$RESTORE_DIR"
sudo tar --extract --file="$ARCHIVE" --directory="$RESTORE_DIR"Expected result:
/root/nextcloud-restore-test/
├── nextcloud-database.sql
└── nextcloud-directory/
sudo ls -lah "$RESTORE_DIR"
sudo ls -lah "$RESTORE_DIR/nextcloud-directory"
sudo test -s "$RESTORE_DIR/nextcloud-database.sql" && echo "SQL dump exists"sudo -u www-data php /var/www/nextcloud/occ maintenance:mode --onFor severe disaster recovery, also stop the web server, PHP-FPM, and cron jobs that may touch Nextcloud:
sudo systemctl stop nginx apache2 php*-fpm 2>/dev/null || true
sudo systemctl stop cron 2>/dev/null || trueUse the service names that actually exist on your system.
Before overwriting anything, keep a last-resort copy:
sudo rsync -Aax /var/www/nextcloud/ /root/nextcloud-before-restore/sudo rsync -Aax --delete "$RESTORE_DIR/nextcloud-directory/" /var/www/nextcloud/Fix ownership if required:
sudo chown -R www-data:www-data /var/www/nextcloudIf your deployment uses a different web-server user, replace www-data with your configured WEB_USER.
Use a MariaDB administrative user for restore.
sudo mariadb -u root -p -e "DROP DATABASE nextcloud;"
sudo mariadb -u root -p -e "CREATE DATABASE nextcloud CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;"
sudo mariadb -u root -p nextcloud < "$RESTORE_DIR/nextcloud-database.sql"If your original database used different character set or collation settings, recreate it accordingly.
sudo -u www-data php /var/www/nextcloud/occ maintenance:repairIf the restored data is older than some client-side data, evaluate whether to run:
sudo -u www-data php /var/www/nextcloud/occ maintenance:data-fingerprintIf files were restored manually or the database and data directory may be out of sync, evaluate whether a file scan is required:
sudo -u www-data php /var/www/nextcloud/occ files:scan --allsudo -u www-data php /var/www/nextcloud/occ maintenance:mode --off
sudo systemctl start cron 2>/dev/null || true
sudo systemctl start php*-fpm nginx apache2 2>/dev/null || trueCheck:
sudo -u www-data php /var/www/nextcloud/occ status
sudo -u www-data php /var/www/nextcloud/occ maintenance:modeThen verify from the web UI:
- login works;
- files are visible;
- files can be uploaded and downloaded;
- shares work;
- apps load;
- background jobs run;
- logs do not show new critical errors.
This project handles highly sensitive data. Treat every archive as a full copy of your Nextcloud instance.
Recommended permissions:
sudo chown root:root /opt/nextcloud-backup/nextcloud-backup.sh
sudo chown root:root /opt/nextcloud-backup/nextcloud-backup.env
sudo chmod 700 /opt/nextcloud-backup/nextcloud-backup.sh
sudo chmod 600 /opt/nextcloud-backup/nextcloud-backup.env
sudo chmod 700 /opt/nextcloud-backupThe script sets:
umask 0077This makes newly created files private by default.
The script passes the MariaDB password through MYSQL_PWD instead of placing it directly in the command-line arguments. This avoids exposing the password in normal ps command output.
However, the password still exists in the root-readable env file and in the process environment while the dump is running. Keep the env file root-only.
Do not store backups:
- inside the Nextcloud web root;
- inside a publicly served directory;
- only on the same disk as the original data;
- only on the same server.
Recommended production pattern:
- Create the local backup archive.
- Verify it.
- Generate a checksum.
- Encrypt it if required by your threat model.
- Copy it off-site.
- Test restore regularly.
Example off-site copy using rsync:
rsync -avh --progress /opt/nextcloud-backup/ backup-server:/srv/backups/nextcloud/Example encryption with age:
age -r "age1examplepublickey..." \
-o nextcloud-backup_YYYY-mm-dd_HH-MM-SS.tar.xz.age \
nextcloud-backup_YYYY-mm-dd_HH-MM-SS.tar.xzThe script enables maintenance mode before dumping the database and archiving files, then disables it after the archive is verified. Users cannot log in normally while maintenance mode is active.
Schedule backups during low-traffic windows.
The script currently calculates required free space as:
required_kb=$(( nc_size_kb * 2 ))So the implemented check requires approximately 2× NC_DIR size available in BACKUP_ROOT.
The built-in verification step confirms the archive can be listed by tar. This catches many truncation, xz, and tar-structure problems. It does not validate application-level consistency, database importability, filesystem ownership correctness, or full restore success.
The lock directory is:
.nextcloud-backup.lock
It is created in the same directory as the script. If a run crashes hard, the lock may remain. Confirm that no backup is running before removing it:
ps aux | grep '[n]extcloud-backup.sh'
sudo rm -rf /opt/nextcloud-backup/.nextcloud-backup.lockThe log file is:
nextcloud-backup.log
It is stored beside the script, not inside BACKUP_ROOT.
Consider external log rotation for long-running deployments:
/opt/nextcloud-backup/nextcloud-backup.log {
weekly
rotate 12
compress
missingok
notifempty
create 0600 root root
}
Run it with sudo:
sudo /opt/nextcloud-backup/nextcloud-backup.shnextcloud-backup.env must be in the same directory as nextcloud-backup.sh.
Check:
ls -lah /opt/nextcloud-backup/Install the package that provides the missing command. For example, on Debian/Ubuntu:
sudo apt install mariadb-client php-cli xz-utils curl findutils coreutils gawk sudoNC_DIR is wrong or points to the wrong directory.
Check:
sudo ls -lah /var/www/nextcloud/occUpdate:
NC_DIR="/correct/path/to/nextcloud"Check your web/PHP user:
ps aux | grep -E 'php-fpm|apache|nginx' | head
id www-data
id http
id nginx
id apacheThen update:
WEB_USER="www-data"Test manually:
MYSQL_PWD='your-password' mariadb \
--host='localhost' \
--user='backup_user' \
--execute='SELECT 1;' \
nextcloudCheck:
DB_HOSTDB_NAMEDB_USERDB_PASSWORD- MariaDB service status
- firewall rules
- user host grants, such as
'backup_user'@'localhost'vs'backup_user'@'%'
Check sizes:
sudo du -sh /var/www/nextcloud
sudo df -h /opt/nextcloud-backupFree space, change BACKUP_ROOT, reduce unnecessary data, or move backups to a larger filesystem.
Possible causes:
- wrong database name;
- insufficient MariaDB privileges;
- broken DB connection;
- disk write failure;
- MariaDB authentication issue.
Run the dump manually with the same credentials and inspect the error.
Possible causes:
- disk filled during archive creation;
- filesystem error;
- xz/tar failure;
- interrupted backup;
- hardware/storage problem.
The script removes partial or unverified archives when possible.
The script attempts to disable maintenance mode during cleanup. If that fails, run:
sudo -u www-data php /var/www/nextcloud/occ maintenance:mode --offIf occ is unavailable, inspect config/config.php and set:
'maintenance' => false,Confirm no backup is running:
ps aux | grep '[n]extcloud-backup.sh'Remove the stale lock:
sudo rm -rf /opt/nextcloud-backup/.nextcloud-backup.lockTest manually:
curl -H "Title: Test" -H "Tags: white_check_mark" -d "Nextcloud backup notification test" "https://ntfy.sh/your-topic"Check:
NTFY_URLis correct;- topic name is correct;
- outbound HTTPS is allowed;
- self-hosted ntfy endpoint is reachable;
- phone/browser is subscribed to the topic.
- Supports MariaDB backups only. PostgreSQL, MySQL-specific tooling, and SQLite are not implemented.
- Creates full backups, not incremental or differential backups.
- Does not upload backups to remote storage.
- Does not encrypt, sign, or checksum archives by itself.
- Does not implement Docker-aware
occor database commands. - Does not support distributed/multi-node Nextcloud deployments directly.
- Does not guarantee application-level restore success without restore testing.
- Uses maintenance mode, so backup windows create service downtime.
- Uses local retention only; this does not replace off-site backup policy.
- Archive verification is structural, not a full disaster-recovery validation.
Before relying on this backup system, complete the following:
- Use a dedicated MariaDB backup user instead of database root.
- Store
nextcloud-backup.envas root-readable only. - Store
BACKUP_ROOToutside the web root. - Confirm
NC_DIRpoints to the real Nextcloud installation. - Confirm
WEB_USERmatches the real web/PHP user. - Run one manual backup successfully.
- Verify the archive with
tar --list. - Extract a backup in a staging directory.
- Restore into a test Nextcloud instance.
- Configure systemd timer or cron scheduling.
- Configure ntfy or another monitoring path.
- Add off-site replication.
- Add checksums.
- Add encryption if required.
- Monitor backup age and failure status.
- Document the restore procedure for your actual server.
- Periodically perform restore drills.
nextcloud-backup/
├── LICENSE
├── README.md
├── nextcloud-backup.env
└── nextcloud-backup.sh
Contributions should preserve the current operational principles:
- configuration belongs in
nextcloud-backup.env, not hard-coded in the script; - failures should abort clearly and safely;
- maintenance mode must not be left enabled silently;
- partial or unverified archives should not be retained;
- logs should remain readable by humans during emergencies;
- restore documentation should stay aligned with the archive format.
Useful future improvements include:
- optional checksum generation;
- optional encryption;
- optional remote sync target;
- Docker-aware mode;
- PostgreSQL support;
- explicit backup manifest file inside each archive;
- systemd unit files in the repository;
- automated restore-test helper.
This project is licensed under the MIT License. See LICENSE for details.