- Shell 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .gitignore | ||
| backup.conf | ||
| README.md | ||
| setup-zfs-dataset.sh | ||
| zfs-docker-backup.sh | ||
ZFS Docker Backup
Automated backup solution for Docker Compose projects using ZFS snapshots with TrueNAS replication.
Features
- Atomic ZFS snapshots — Instant, consistent backups with zero data corruption risk
- Parallel container stop/start — Minimized downtime (typically 15-60 seconds)
- TrueNAS replication — Pull-based backup via SSH with incremental transfers
- Restricted SSH access — Dedicated user with limited ZFS commands only
- Pre/post backup hooks — Run custom commands before/after backup
- Dual notifications — UptimeKuma for monitoring + Gotify for failure alerts
- Comprehensive logging — Detailed logs with terminal output when run interactively
- Dry-run mode — Test without making changes
- Automatic recovery — Containers are automatically restarted if backup fails
Recommended Docker Setup
For optimal backup efficiency, organize your Docker services as follows:
/data/ # ZFS dataset (mounted, snapshotted)
├── nextcloud/
│ ├── docker-compose.yaml # Service definition
│ ├── config/ # Configuration files
│ │ └── config.php
│ └── data/ # Persistent application data
│ └── nextcloud/
├── gitea/
│ ├── docker-compose.yaml
│ ├── config/
│ └── data/
└── vaultwarden/
├── docker-compose.yaml
└── data/
Key principles:
-
One subdirectory per service — Each Docker Compose project lives in its own folder under
/data/ -
Include all persistent data — Mount application data directories into the service folder:
volumes: - ./data/nextcloud:/var/www/html # Persistent (backed up) - ./config/config.php:/var/www/html/config/config.php -
Exclude non-persistent data — Use Docker volumes for cache, temp, and other non-essential data:
volumes: - nextcloud-tmp:/tmp # Non-persistent (not backed up) - nextcloud-cache:/var/cache # Non-persistent (not backed up) # Named volumes are NOT stored on ZFS, so they won't consume backup space
This approach ensures:
- Only meaningful data is backed up
- Backup size remains minimal
- Fast restore (just restore snapshot and restart containers)
Requirements
- Ubuntu Server (or any Linux with ZFS support)
- ZFS filesystem mounted at
/data(or your preferred path) - Docker and Docker Compose
- OpenSSH server (for TrueNAS replication)
- UptimeKuma instance (optional, for monitoring)
- Gotify instance (optional, for push notifications)
Installation
1. Set up ZFS Dataset (Wizard Mode)
Run the setup script without arguments to start the interactive wizard:
sudo ./setup-zfs-dataset.sh
The wizard will guide you through:
- Pool creation — Choose storage method:
- LVM logical volume (recommended for existing systems)
- Physical disk device
- Dataset configuration — Set name and mount point
- SSH user setup — Configure TrueNAS replication user (optional)
Alternatively, use command-line options:
# Create new pool on /dev/sdb
sudo ./setup-zfs-dataset.sh --disk /dev/sdb
# Use existing pool
sudo ./setup-zfs-dataset.sh --pool tank --dataset data --mount /data
# Setup SSH user only (after initial setup)
sudo ./setup-zfs-dataset.sh --setup-ssh
2. Install Scripts
# Create directory
sudo mkdir -p /opt/zfs-backup
# Make executable
sudo chmod +x /opt/zfs-backup/zfs-docker-backup.sh
sudo chmod +x /opt/zfs-backup/setup-zfs-dataset.sh
# Create log directory
sudo mkdir -p /var/log/zfs-backup
3. Edit Configuration
sudo nano /opt/zfs-backup/backup.conf
4. Test with Dry-Run
sudo /opt/zfs-backup/zfs-docker-backup.sh --dry-run --verbose
5. Set up Cron
sudo crontab -e
Add:
# Daily backup at 03:30 (TrueNAS replication at 04:30)
30 3 * * * /opt/zfs-backup/zfs-docker-backup.sh >> /var/log/zfs-backup/cron.log 2>&1
TrueNAS Core Replication Setup
Step 1: Generate SSH Key
- Navigate to Account > Users
- Edit the
rootuser - Under SSH Public Key, click Generate SSH Key Pair
- Copy the public key for next step
Step 2: Configure SSH User on VPS
Run the setup wizard:
sudo ./setup-zfs-dataset.sh --setup-ssh
Paste the SSH public key when prompted.
Or provide the key directly:
sudo ./setup-zfs-dataset.sh --ssh-key "ssh-ed25519 AAAA..."
Step 3: Test Connection
From TrueNAS shell:
ssh zfs-repl@<vps-ip> 'zfs list -t snapshot'
Expected: List of snapshots from the backup dataset.
Step 4: Create Replication Task
- Navigate to Tasks > Replication Tasks
- Click Add
Basic Settings
| Setting | Value |
|---|---|
| Name | vps-backup |
| Direction | Pull |
SSH Connection
| Setting | Value |
|---|---|
| Remote Host | <vps-ip-address> |
| Port | 22 |
| Username | zfs-repl |
| Private Key | Select key from Step 1 |
Dataset Selection
| Setting | Value |
|---|---|
| Source Dataset | tank/data |
| Destination Dataset | tank/backups/vps-data |
Options
| Setting | Value |
|---|---|
| Recursive | Yes |
| Compressed Transfer | Yes |
Schedule
| Setting | Value |
|---|---|
| Schedule | Custom |
| Run automatically | Yes |
| Cron | 30 4 * * * (daily at 04:30) |
Retention
| Setting | Value |
|---|---|
| Snapshot Lifetime | 4 weeks |
- Click Submit
Step 5: Run Initial Replication
- Click the play button on the replication task
- Monitor progress in Tasks > Running Tasks
First run transfers full dataset. Subsequent runs are incremental.
Verify
On TrueNAS:
zfs list -t snapshot -r tank/backups/vps-data
Usage
Command Line
# Normal backup
sudo /opt/zfs-backup/zfs-docker-backup.sh
# Dry-run (test without changes)
sudo /opt/zfs-backup/zfs-docker-backup.sh --dry-run
# Verbose output (show DEBUG logs)
sudo /opt/zfs-backup/zfs-docker-backup.sh --verbose
# Skip notifications
sudo /opt/zfs-backup/zfs-docker-backup.sh --no-push
# Use alternative config
sudo /opt/zfs-backup/zfs-docker-backup.sh --config /etc/my-backup.conf
# Show help
sudo /opt/zfs-backup/zfs-docker-backup.sh --help
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Lock acquisition failed (another instance running) |
| 2 | Docker Compose stop failed |
| 3 | ZFS snapshot failed |
| 4 | Docker Compose start failed |
| 5 | Snapshot cleanup failed |
| 10 | Unknown error |
Configuration Reference
Required Variables
| Variable | Description | Example |
|---|---|---|
ZFS_DATASET |
ZFS dataset name | tank/data |
DATA_DIR |
Mount point of ZFS dataset | /data |
UPTIMEKUMA_PUSH_URL |
UptimeKuma push URL | https://uptime-kuma.example.com/api/push/XXXXX |
GOTIFY_URL |
Gotify server URL | https://gotify.example.com |
GOTIFY_TOKEN |
Gotify application token | XXXXX |
Optional Variables
| Variable | Default | Description |
|---|---|---|
LOCAL_SNAPSHOT_RETENTION |
7 | Number of local snapshots to keep |
STOP_TIMEOUT |
300 | Max seconds to wait for container stop |
LOG_RETENTION_DAYS |
30 | Days to keep log files |
GOTIFY_PRIORITY |
8 | Gotify message priority (1-10) |
LOCK_FILE |
/var/run/zfs-backup.lock |
Lock file path |
Projects Format
PROJECTS=(
"project_name:/path/to/docker-compose/directory"
"nextcloud:/data/nextcloud"
"gitea:/data/gitea"
)
Hooks Format
# Pre-backup hooks (run before stopping containers)
PRE_HOOKS=(
"nextcloud:sudo -u www-data php /data/nextcloud/html/occ maintenance:mode --on"
)
# Post-backup hooks (run after starting containers)
POST_HOOKS=(
"nextcloud:sudo -u www-data php /data/nextcloud/html/occ maintenance:mode --off"
)
How It Works
Backup Flow
1. Acquire lock (prevent concurrent runs)
2. Run pre-backup hooks
3. Stop all Docker Compose projects in parallel
4. Create ZFS snapshot (instant, atomic)
5. Start all Docker Compose projects in parallel
6. Run post-backup hooks
7. Clean old snapshots (keep last N)
8. Send success notifications
Replication Architecture
┌────────────────────────────────────────────────────────────────┐
│ VPS (Source) │
├────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Docker Services │ │ zfs-docker- │ │
│ │ │───▶│ backup.sh │ │
│ │ - Nextcloud │ │ │ │
│ │ - Gitea │ │ 1. Stop │ │
│ │ - Vaultwarden │ │ 2. Snapshot │ │
│ └─────────────────┘ │ 3. Start │ │
│ │ 4. Cleanup │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ ZFS Dataset │ │
│ │ tank/data │ │
│ │ │ │
│ │ Snapshots: │ │
│ │ @backup-2024... │ │
│ │ @backup-2024... │ │
│ └────────┬────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SSH Server (zfs-repl user) │ │
│ │ │ │
│ │ Restricted to: │ │
│ │ - zfs send │ │
│ │ - zfs list │ │
│ │ - zfs get │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
└───────────────────────────────────┼────────────────────────────┘
│
│ SSH (pull)
│ zfs send | zfs receive
│
┌───────────────────────────────────┼────────────────────────────┐
│ TrueNAS (Destination) │
├───────────────────────────────────┼────────────────────────────┤
│ │ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Replication Task │ │
│ │ │ │
│ │ Schedule: Daily at 04:30 │ │
│ │ Action: Pull new snapshots from VPS │ │
│ │ Retention: 4 weeks │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ ZFS Dataset │ │
│ │ tank/backups/ │ │
│ │ vps-data │ │
│ │ │ │
│ │ Snapshots: │ │
│ │ @backup-2024... │ │
│ │ @backup-2024... │ │
│ └─────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
Failure Recovery
If any step fails:
- All containers are automatically restarted (if they were stopped)
- Failure notification is sent to both Gotify and UptimeKuma
- Script exits with appropriate error code
Snapshot Naming
Snapshots are named with full timestamp:
tank/data@backup-20260414-033000 # Cron run at 03:30
tank/data@backup-20260414-150327 # Manual test at 15:03:27
Accessing Snapshots
# List snapshots
zfs list -t snapshot -r tank/data
# Browse snapshot files
ls /data/.zfs/snapshot/backup-20260414-033000/
# Restore specific file
cp /data/.zfs/snapshot/backup-20260414-033000/path/to/file /data/path/to/file
Security Model
SSH User Restrictions
The zfs-repl user is restricted via SSH forced command:
| Allowed | Blocked |
|---|---|
zfs send |
Shell access |
zfs list |
File access |
zfs get |
Arbitrary commands |
| Other datasets |
Configuration Files
| File | Purpose |
|---|---|
/home/zfs-repl/.ssh/authorized_keys |
SSH public key |
/etc/ssh/sshd_config.d/zfs-repl.conf |
SSH restrictions |
/etc/sudoers.d/zfs-repl |
ZFS sudo permissions |
/usr/local/bin/zfs-repl-shell |
Restricted shell |
/var/log/zfs-repl-shell.log |
Command audit log |
Additional Hardening
Restrict SSH access to TrueNAS IP only:
# Edit SSH config
sudo nano /etc/ssh/sshd_config.d/zfs-repl.conf
# Add:
AllowUsers zfs-repl@<truenas-ip>
Log Files
Logs are stored in /var/log/zfs-backup/:
/var/log/zfs-backup/
├── backup-20260414-033000.log # Cron run
├── backup-20260414-150327.log # Manual test
└── cron.log # Cron output redirect
Notifications
UptimeKuma
- Sends
upstatus on success - Sends
downstatus on failure - Short message format for monitoring dashboard
- Always notified (success and failure)
Gotify
- Sends push notification on failure only
- More detailed error information
- Priority configurable (default: 8)
- Not sent on successful backups — only alerts when something goes wrong
- Notification title includes hostname:
[hostname] Backup Failed
Troubleshooting
"Lock acquisition failed"
Another instance is running. Check:
# Check for running process
ps aux | grep zfs-docker-backup.sh
# Remove stale lock if needed
sudo rm /var/run/zfs-backup.lock
"Failed to stop" timeout
Increase STOP_TIMEOUT in config:
STOP_TIMEOUT=600 # 10 minutes
If a container doesn't stop gracefully within the timeout, Docker will force-kill it. The script logs this as a warning.
Snapshot creation fails
Check ZFS status:
zpool status
zfs list
SSH connection issues
# Check SSH service
sudo systemctl status ssh
# Check firewall
sudo ufw status
# Test connection from TrueNAS
ssh zfs-repl@<vps-ip> 'zfs list -t snapshot'
Replication not working
- Check TrueNAS replication task logs
- Check VPS audit log:
tail -f /var/log/zfs-repl-shell.log - Verify snapshots exist:
zfs list -t snapshot -r tank/data
Permission denied
Ensure script runs as root:
sudo /opt/zfs-backup/zfs-docker-backup.sh
Security Considerations
- Store
backup.confwith restricted permissions:chmod 600 - Gotify token should be kept secret
- SSH user is restricted to ZFS commands only
- Consider firewall rules to limit SSH access to TrueNAS IP only
Related Files
| File | Description |
|---|---|
zfs-docker-backup.sh |
Main backup script |
setup-zfs-dataset.sh |
ZFS and SSH user setup |
backup.conf |
Configuration file |
License
MIT License