Unraid runs entirely in RAM from your USB flash drive. If you download a binary straight to /usr/local/bin, it will vanish the moment you reboot your server. [1, 2]
Follow this standard Unraid method to make Restic persist across system reboots:
Step 1: Save the Binary to Your Flash Drive
- Go to the official Restic GitHub Releases Page and download the latest stable Linux AMD64 file (e.g.,
restic_x.x.x_linux_amd64.bz2). - Extract the
.bz2file on your computer to get the rawresticbinary file. - Plug your Unraid flash drive into your computer (or access it over the network via the
bootSMB share). - Create a folder named
extraon the flash drive if it doesn’t exist, and drop theresticfile inside it:
mkdir -p /boot/extra && cd /boot/extra
wget https://github.com/restic/restic/releases/download/v0.19.0/restic_0.19.0_linux_amd64.bz2
bunzip2 restic_0.19.0_linux_amd64.bz2
cp restic_0.19.0_linux_amd64 restic
chmod +x restic
Step 2: Force Unraid to Load it at Boot
To make Unraid load Restic into system memory automatically every time the server starts up, add it to your go script: [1, 2]
- Open
/boot/config/goon your flash drive using a text editor (like Notepad++ or VS Code). - Append the following lines to the very bottom of the file
# Copy and activate Restic binary on boot
cp /boot/extra/restic /usr/local/bin/restic
chmod 755 /usr/local/bin/restic
- Save and close the file.
(To avoid rebooting right now to test this, simply paste those two command lines directly into your current Unraid web terminal to install it instantly).
How to Initialize and Configure Your First Backup Repo
Before Restic can process automated backups, you must initialize an encrypted repository destination where your deduplicated snapshots will be safely stored. [1, 2]
Step 1: Create the Backup Repository
Open your Unraid terminal. We will initialize a local repository targeting your main HDD Array storage pool
mkdir -p /mnt/user/bak_appdata/restic/data
restic init --repo /mnt/user/bak_appdata/restic/data
The terminal will prompt you to type a password. Do not lose this password. Every piece of data sent to this directory is encrypted using AES-256 by default; without the password, your data is completely unrecoverable. [1, 2]
Step 2: Use the Automation Script
Now that the binary is installed globally and the target directory is initialized, you can deploy the automated backup-gitea.sh script provided in the previous step. The script will handle:
- Running a live, non-destructive dump of the MySQL container databases.
- Scanning your Gitea appdata folders for structural changes.
- Injecting only newly created data chunks into the encrypted repository.
- Trimming away historical versions that fall outside your set retention periods.
#!/bin/bash
#.../dc-gitea/restic-gitea-backup.sh
set -e
# --- CONFIGURATION ---
BACKUP_TAG="gitea-production"
SRC_DATA_DIR="/mnt/nvme-pool/appdata/dc-gitea/gitea"
TEMP_DB_DIR="/mnt/nvme-pool/appdata/dc-gitea/tmp_db"
RESTIC_REPO="/mnt/user/bak_appdata/restic/data" # Store snapshots on your main HDD Array Share
# Define an encryption key for your backup repository (Keep this safe!)
export RESTIC_PASSWORD="myd0gsk1p"
# --- START BACKUP PROCESS ---
echo "Creating temporary database staging directory..."
mkdir -p "$TEMP_DB_DIR"
echo "Executing consistent hot-dump of Gitea MySQL database..."
# Pulls db configuration variables dynamically out of your running stack env
docker compose -f /mnt/nvme-pool/appdata/dc-gitea/docker-compose.yml exec -T db \
mysqldump -u root -p"$(grep DB_PASSWORD /mnt/nvme-pool/appdata/dc-gitea/.env | cut -d'=' -f2)" gitea > "$TEMP_DB_DIR/gitea_db.sql"
# Initialize Restic repository if it doesn't exist yet
if [ ! -d "$RESTIC_REPO" ]; then
echo "Initializing new Restic backup repository..."
restic -r "$RESTIC_REPO" init
fi
echo "Running deduplicated incremental snapshot..."
restic -r "$RESTIC_REPO" backup \
--tag "$BACKUP_TAG" \
"$SRC_DATA_DIR" \
"$TEMP_DB_DIR/gitea_db.sql"
echo "Cleaning up local database dump..."
rm -f "$TEMP_DB_DIR/gitea_db.sql"
rmdir "$TEMP_DB_DIR"
echo "Pruning old historical snapshots based on retention policy..."
# Keeps 7 daily snapshots, 4 weekly snapshots, and 12 monthly snapshots
restic -r "$RESTIC_REPO" forget \
--keep-daily 7 \
--keep-weekly 4 \
--keep-monthly 12 \
--prune
echo "Gitea Incremental Backup Successfully Completed!"
Useful Commands to Manage Your Globally Stored Backups
Once your script starts running daily, you can open your Unraid terminal at any time and run these basic management commands:
- List all historical backup instances:
restic -r /mnt/user/backups/gitea-restic snapshots
- Check the real storage footprint and deduplication stats:
restic -r /mnt/user/backups/gitea-restic stats
- Browse or verify file integrity inside a backup
restic -r /mnt/user/backups/gitea-restic check
Risk of High CPU/RAM Starvation
Restic heavily compresses, hashes (SHA-256), and encrypts data chunks on the fly.
- The Risk: If you back up large datasets (like your 85GB Gitea stack), a bare-metal Restic process will consume as much CPU and RAM as Unraid will give it. This can cause temporary performance stuttering in your other active Docker containers or plex streams.
- The Mitigation: Unlike a Docker container where you can easily set a hard RAM limit, on bare metal you must restrict it manually by prepending
niceandioniceto your script commands to force Restic to run at the lowest priority:
nice -n 19 ionice -c 3 restic -r /mnt/user/bak_appdata/restic/data backup ...
Part 1: How to Restore Gitea From Your Backup
If your NVMe drive fails or you lose data, the restore process takes under 5 minutes. You spin up your basic container environment, restore the files from Restic, and drop the database backup back into MySQL. [1]
Step 1: Stop the Gitea App Container
Keep your database and Redis containers running, but shut down the Gitea application container so it does not lock files:
docker compose stop server runner
Step 2: Extract Your Files Using Restic
Run the Restic restore command. You can choose to restore everything, or use the --target path to extract files exactly where they belong:
# Get your snapshot ID first
restic -r /mnt/user/bak_appdata/restic/data snapshots
# Restore the files straight back to your NVMe appdata pool
restic -r /mnt/user/bak_appdata/restic/data restore LATEST_SNAPSHOT_ID --target /
(This places your gitea/ folder data and the gitea_db.sql file back onto your disk).
Step 3: Restore the MySQL Database
Inject your text-based database dump back into your running MySQL container instance:
docker compose exec -T db mysql -u root -p"YOUR_DB_PASSWORD" gitea < /mnt/nvme-pool/appdata/dc-gitea/tmp_db/gitea_db.sql
Step 4: Restart the Stack
docker compose start server runner
Pro-Tip for Nextcloud & Rocket.Chat Backups
Unlike Git repositories, which are completely immutable, users can write files to Nextcloud while Restic is mid-scan, causing potential file index warning errors during a snapshot.
To achieve 100% data integrity for your other apps, modify your backup script to put Nextcloud into Maintenance Mode before running the backup, and turn it off immediately after Restic finishes:
# Enable Maintenance Mode (Freezes user web writes instantly)
docker exec -u www-data nextcloud-app-container php occ maintenance:mode --on
# ... [ Run your DB Dump & Restic Backup Here ] ...
# Disable Maintenance Mode
docker exec -u www-data nextcloud-app-container php occ maintenance:mode --off
Part 2: Does This Process Work for Nextcloud and Rocket.Chat?
Yes, the core architectural concept is identical, but the command syntax changes slightly depending on the database engine. You always extract the live database to a text file first, then let Restic snapshot the files and the database dump together.
For Nextcloud (MySQL / MariaDB)
Nextcloud operates exactly like Gitea. You use the exact same mysqldump command structure.
The Backup Script Modification:
# Dump the Nextcloud database
docker compose -f /path/to/dc-nextcloud/docker-compose.yml exec -T db \
mysqldump -u root -p"NEXTCLOUD_DB_PASS" nextcloud > /tmp_db/nextcloud_db.sql
# Run Restic snapshot
restic -r /mnt/user/backups/nextcloud-restic backup /mnt/user/appdata/nextcloud /tmp_db/nextcloud_db.sql
The Restore Command:
docker compose exec -T db mysql -u root -p"NEXTCLOUD_DB_PASS" nextcloud < nextcloud_db.sql
For Rocket.Chat (PostgreSQL)
PostgreSQL handles hot-backups differently. Instead of mysqldump, you must use pg_dump. [1, 2]
The Backup Script Modification:
# Dump the PostgreSQL database (Notice the username syntax flag)
docker compose -f /path/to/dc-rocketchat/docker-compose.yml exec -T db \
pg_dump -U rocketchat_user -d rocketchat_db > /tmp_db/rocketchat_db.sql
# Run Restic snapshot
restic -r /mnt/user/backups/rocketchat-restic backup /mnt/user/appdata/rocketchat /tmp_db/rocketchat_db.sql
The Restore Command:
PostgreSQL restores utilize the standard application terminal router:
docker compose exec -T db psql -U rocketchat_user -d rocketchat_db < rocketchat_db.sql
Final Direction:
- unraid-restic-master-backup.sh that calls individual restic-back.sh scripts
#!/bin/bash
#.../myUNRAID/scripts/unraid-restic-master-backup.sh
# --- GLOBAL ORCHESTRATOR ---
APPDATA_DIR="/mnt/nvme-pool/appdata"
echo "=== Starting Global Container Backup Sequence ==="
# Find all dc-* directories containing a restic-backup.sh script
find "$APPDATA_DIR" -maxdepth 2 -type f -name "restic-backup.sh" | while read -r backup_script; do
stack_dir=$(dirname "$backup_script")
stack_name=$(basename "$stack_dir")
echo "--------------------------------------------------"
echo "Found backup configuration for stack: [$stack_name]"
echo "Executing: $backup_script"
echo "--------------------------------------------------"
# Run the script inside its native directory context
(cd "$stack_dir" && ./restic-backup.sh)
done
echo "=================================================="
echo "=== Global Backup Sequence Completed Safely ==="
echo "=================================================="
#!/bin/bash
#.../dc-gita/restic-backup.sh
set -e
# --- CONFIGURATION ---
STACK_DIR=$(pwd)
STACK_NAME=$(basename "$STACK_DIR")
RESTIC_REPO="/mnt/user/bak_appdata/restic/data"
TEMP_DB_DIR="/mnt/nvme-pool/appdata/tmp_db_staging"
RESTIC_PASS="${1:-$(grep ^RESTIC_PASS .env | cut -d'=' -f2)}"
DB_USER="${2:-$(grep ^DB_USER .env | cut -d'=' -f2)}"
DB_PASS="${3:-$(grep ^DB_PASS .env | cut -d'=' -f2)}"
DB_NAME="${4:-$(grep ^DB_NAME .env | cut -d'=' -f2)}"
echo " RESTIC_PASS:$RESTIC_PASS"
echo " DB_USER:$DB_USER"
echo " DB_PASS:$DB_PASS"
echo " DB_NAME:$DB_NAME"
usage() {
echo ""
echo " Usage: $0 RESTIC_PASS DB_USER DB_PASS DB_NAME"
exit 0
}
if [ "$1" == "-h" ]; then
usage
fi
if [ -z "$DB_USER" ] || [ -z "$DB_PASS" ] || [ -z "$DB_NAME" ] || [ -z "$RESTIC_PASS" ]; then
echo " ERROR: Cannot continue with missing arguments!"
usage
fi
export RESTIC_PASSWORD="$RESTIC_PASS"
echo "[$STACK_NAME] Exporting consistent snapshot of Gitea MySQL database..."
echo " docker compose exec -T db mysqldump -u $DB_USER -p$DB_PASS $DB_NAME > $TEMP_DB_DIR/${DB_NAME}_db.sql"
mkdir -p "$TEMP_DB_DIR"
docker compose exec -T db mysqldump -u $DB_USER -p$DB_PASS $DB_NAME > "$TEMP_DB_DIR/${DB_NAME}_db.sql"
echo "[$STACK_NAME] Executing localized deduplicated backup..."
restic -r "$RESTIC_REPO" backup \
--tag "$STACK_NAME" \
--exclude "$STACK_DIR/redis" \
"$STACK_DIR" \
"$TEMP_DB_DIR/gitea_db.sql"
# Clean up
#rm -rf "$TEMP_DB_DIR"
echo "[$STACK_NAME] Pruning old historical timelines..."
restic -r "$RESTIC_REPO" forget --tag "$STACK_NAME" --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune
#!/bin/bash
#.../dc-gitea/restic-restore.sh
set -e
# --- INITIAL CONFIGURATION ---
ORIGINAL_STACK_DIR="/mnt/nvme-pool/appdata/dc-gitea"
STACK_NAME="dc-gitea"
RESTIC_REPO="/mnt/user/bak_appdata/restic/data"
export RESTIC_PASSWORD="YourSuperSecureGlobalBackupPassword"
clear
echo "================================================================="
echo " GITEA INTERACTIVE BACKUP RESTORATION UTILITY "
echo "================================================================="
echo ""
# 1. Fetch and list available snapshots for this specific application tag
echo "Fetching available recovery points for tag [$STACK_NAME]..."
restic -r "$RESTIC_REPO" snapshots --tag "$STACK_NAME"
echo ""
# 2. Ask user which snapshot ID they want to recover
read -p "Enter the Snapshot ID you want to restore (or type 'latest'): " SNAPSHOT_ID
if [ -z "$SNAPSHOT_ID" ]; then
SNAPSHOT_ID="latest"
fi
# 3. Prompt for the extraction target path
echo ""
echo "Where do you want to restore this stack environment?"
echo "Default original location is: $ORIGINAL_STACK_DIR"
read -p "Enter absolute target path (Press Enter for default): " TARGET_DIR
if [ -z "$TARGET_DIR" ]; then
TARGET_DIR="$ORIGINAL_STACK_DIR"
fi
# 4. SAFETY TRIGGER: Check warnings and path collisions
if [ "$TARGET_DIR" == "$ORIGINAL_STACK_DIR" ]; then
echo ""
echo "⚠️ WARNING: You are restoring directly over your LIVE PRODUCTION environment!"
read -p "Are you absolutely sure? This will overwrite existing active assets! (y/N): " CONFIRM
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
echo "Restoration aborted by user safety control."
exit 1
fi
elif [ -d "$TARGET_DIR" ]; then
echo ""
echo "⚠️ NOTICE: Target directory [$TARGET_DIR] already exists."
read -p "Merge/overwrite files within this existing folder? (y/N): " CONFIRM
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
echo "Restoration aborted to prevent data contamination."
exit 1
fi
fi
# 5. Execute file layer restoration
echo ""
echo "Extracting files from Restic repository..."
# Using --target / restores files using the structure mapped during backup
restic -r "$RESTIC_REPO" restore "$SNAPSHOT_ID" --tag "$STACK_NAME" --target /
# If the user restored to a custom path, we move the files from the snapshot path to their designated location
if [ "$TARGET_DIR" != "$ORIGINAL_STACK_DIR" ]; then
echo "Relocating extracted environment files to custom workspace: $TARGET_DIR"
mkdir -p "$TARGET_DIR"
cp -r "$ORIGINAL_STACK_DIR"/. "$TARGET_DIR/"
# Clean up the artifact path left behind by restic's absolute directory restoration matching rules
# (Optional, based on how pristine you want the host workspace)
fi
# 6. Environmental Configuration Automation Checklist
echo ""
echo "=== Running Post-Restore Optimization Checklist ==="
cd "$TARGET_DIR"
if [ "$TARGET_DIR" != "$ORIGINAL_STACK_DIR" ]; then
echo "[!] Adjusting .env paths for Isolated Testing Instance..."
# Update the environment file variables to use the newly selected folder paths
sed -i "s|GITEA_DATA_DIR=.*|GITEA_DATA_DIR=$TARGET_DIR/gitea|g" .env
sed -i "s|MYSQL_DATA_DIR=.*|MYSQL_DATA_DIR=$TARGET_DIR/mysql|g" .env
sed -i "s|REDIS_DATA_DIR=.*|REDIS_DATA_DIR=$TARGET_DIR/redis|g" .env
# Prompt user to change port constraints to bypass runtime container name collisions
read -p "Enter a non-conflicting HTTP Web Port for this test instance (e.g. 3080): " NEW_PORT
sed -i "s|GITEA_HTTP_PORT=.*|GITEA_HTTP_PORT=$NEW_PORT|g" .env
# Enforce non-conflicting custom container project identifier names
sed -i "s|COMPOSE_PROJECT_NAME=.*|COMPOSE_PROJECT_NAME=testrestore|g" .env
fi
echo "[!] Re-establishing container service user privileges (chown 1000:1000)..."
chown -R 1000:1000 "$TARGET_DIR/gitea" || true
echo ""
echo "✅ SUCCESS: Core files extracted successfully to $TARGET_DIR"
echo "================================================================="
echo "👉 FINAL RECOVERY STEP DIRECTIONS:"
echo " 1. Move into the workspace: cd $TARGET_DIR"
echo " 2. Boot up your database: docker compose up -d db"
echo " 3. Stream database records back into the running engine:"
echo " docker compose exec -T db mysql -u root -p\\\$(grep DB_PASSWORD .env | cut -d'=' -f2) gitea < /mnt/nvme-pool/appdata/tmp_db_staging/gitea_db.sql"
echo " 4. Complete stack startup: docker compose up -d"
echo "================================================================="
