Preface

The Virtualizor panel developed by a company in Mumbai, India, has caused tripping hazards for many VPS providers. From last year’s ColorCrossing to recent CloudCone incidents, hackers exploited vulnerabilities to breach servers and extort ransom, wiping out massive amounts of user data.

Facing such “black swan events,” no everyone survives alone; it’s time to perfect my automated data backup setups now.

Choosing Backup Tools

Years ago, I consistently relied on Duplicacy CLI. But its license isn’t fully open-sourced, and updates halted. I searched for better alternatives:

The final winner is Kopia, supporting incremental snapshots, end-to-end encryption, and is insanely simple to use.

Choosing Storage Services

Kopia supports almost all mainstream storage backends and protocols:

  • S3 and S3‑compatible storage
  • Local or network‑attached storage
  • Azure Blob Storage
  • Backblaze B2 Storage
  • Google Cloud Storage
  • Google Drive
  • WebDAV/SFTP/Rclone

I chose Backblaze B2 for Object Storage; pay-as-you-go Plan is immensely cheap ($6/TB approx). Class A operations are free with daily quotas for Class B/C, making incremental backup costs practically negligible.

b2-pricing.webp

Storing 100GB costs around $0.6/month; practically negligible.

Creating Access Keys

Create a bucket in B2, generate secrets in Application Keys, and note keyID + applicationKey to begin.

Installing Kopia

On Debian, adding the official APT repository is the most frictionless path.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Import GPG key

curl -s https://kopia.io/signing-key | sudo gpg --dearmor -o /etc/apt/keyrings/kopia-keyring.gpg

# Add software source

echo "deb [signed-by=/etc/apt/keyrings/kopia-keyring.gpg] http://packages.kopia.io/apt/ stable main" | sudo tee /etc/apt/sources.list.d/kopia.list

# Install

sudo apt update && sudo apt install kopia

# Verify Installation

kopia --version

Configuring Kopia

Initializing Repository

Execute repository create to initialize the repository:

1
2
3
4
5
6
7
8
9
sudo kopia repository create b2 \

  --bucket=<bucket-name> \

  --key-id=<keyID> \

  --key=<applicationKey> \

  --prefix=<prefix>/

Remember your password securely. If forgotten, backup data locks permanently with no recovery possible.

 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
Enter password to create new repository:

Re-enter password for verification:

Initializing repository with:

  block hash:          BLAKE2B-256-128

  encryption:          AES256-GCM-HMAC-SHA256

  key derivation:      scrypt-65536-8-1

  splitter:            DYNAMIC-4M-BUZHASH

Connected to repository.

NOTICE: Kopia will check for updates on GitHub every 7 days, starting 24 hours after first use.

To disable this behavior, set environment variable KOPIA_CHECK_FOR_UPDATES=false

Alternatively you can remove the file "/root/.config/kopia/repository.config.update-info.json".

Retention:

  Annual snapshots:                 3   (defined for this target)

  Monthly snapshots:               24   (defined for this target)

  Weekly snapshots:                 4   (defined for this target)

  Daily snapshots:                  7   (defined for this target)

  Hourly snapshots:                48   (defined for this target)

  Latest snapshots:                10   (defined for this target)

  Ignore identical snapshots:   false   (defined for this target)

Compression disabled.

To find more information about default policy run 'kopia policy get'.

To change the policy use 'kopia policy set' command.

NOTE: Kopia will perform quick maintenance of the repository automatically every 1h0m0s

and full maintenance every 24h0m0s when running as root@vps-micro.

See https://kopia.io/docs/advanced/maintenance/ for more information.

NOTE: To validate that your provider is compatible with Kopia, please run:

$ kopia repository validate-provider

Check connection status

1
sudo kopia repository status

Setting Global Policies

Set global compression to zstd, balancing compression rates and speed bottlenecks:

1
sudo kopia policy set --global --compression=zstd

Set global retention policies as fallback parameters:

1
2
3
# Retain latest 8 snapshots

sudo kopia policy set --global --keep-latest 8

Check global policy lists:

1
sudo kopia policy list

Automated Backup

Use the Docker container under /home/dejavu/warp as an example; it uses bind mounts.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Fine‑tune retention for a single backup target

# For containers with rarely‑changing configs, keeping 3 snapshots is enough

sudo kopia policy set /home/dejavu/warp \

  --keep-latest 3 \

  --keep-hourly 0 \

  --keep-daily 0 \

  --keep-weekly 0 \

  --keep-monthly 0 \

  --keep-annual 0

Excluding Irrelevant Files

Use .kopiaignore files ignoring logs and caches setups:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Ignore log files

*.log

logs/

# Ignore temp directories

tmp/

temp/

# Exclude cache

.cache/

Check policy for a specific backup target

1
sudo kopia policy get /home/dejavu/warp

Automation Scripts

Actions are Kopia’s native Hooks. They can trigger commands before/after snapshots, but this “black box” is not intuitive to debug, so I won’t use it here.

I leverage a Shell script with Crontab instead here.

1
2
3
4
5
6
7
# Create log directory

sudo mkdir -p /root/kopia/logs

# Create backup script

sudo vim /root/kopia/backup-warp.sh

Example script:

 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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
#!/bin/bash

SRC_DIR="/home/dejavu/warp"

LOG_DIR="/root/kopia/logs"

CURRENT_DATE=$(date +%Y%m%d)

LOG_FILE="$LOG_DIR/${CURRENT_DATE}-warp.log"

# Must set the Kopia repository password (wrap in single quotes)

export KOPIA_PASSWORD=''

# [Optional] Disable Kopia update checks (useful for CN servers)

# export KOPIA_CHECK_FOR_UPDATES=false

log() {

    echo "[$(date '+%H:%M:%S')] $1" >> "$LOG_FILE"

}

# Fallback

fallback_service() {

    # Check container status

    if ! docker compose -f "$SRC_DIR/compose.yml" ps --services --filter "status=running" | grep -q "warp"; then

        log "Restoring container..."

        docker compose -f "$SRC_DIR/compose.yml" up -d >> "$LOG_FILE" 2>&1

    fi

}

trap fallback_service EXIT

log "=== Backup start ==="

cd "$SRC_DIR" || { log "Fatal error: directory $SRC_DIR not found"; exit 1; }

log "1. Stopping containers..."

docker compose down >> "$LOG_FILE" 2>&1

if [ $? -ne 0 ]; then

    log "❌ Failed to stop container; skipping backup to keep data safe."

    exit 1

fi

# 4.3 Create snapshot (time‑consuming)

log "Starting snapshot..."

kopia snapshot create "$SRC_DIR" --description "Weekly Backup" >> "$LOG_FILE" 2>&1

SNAPSHOT_STATUS=$?

# Start services

log "3. Restoring services..."

docker compose up -d >> "$LOG_FILE" 2>&1

if [ $SNAPSHOT_STATUS -eq 0 ]; then

    log "✅ Snapshot created successfully"

else

    log "❌ Snapshot creation failed!"

    exit 1

fi

# Maintenance tasks

log "Running repository maintenance (GC)..."

kopia maintenance run --full >> "$LOG_FILE" 2>&1

log "=== Backup end ==="

Set script permissions

1
sudo chmod 700 /root/kopia/backup-warp.sh

Run once manually

1
sudo /root/kopia/backup-warp.sh

Check the log from the first run

1
cat /root/kopia/logs/20260201-warp.log

Checked the B2 bucket; snapshots are synced and the logs look good:

 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
70
71
72
73
[10:26:27] === Backup start ===

[10:26:27] 1. Stopping containers...

 Container warp Stopping

 Container warp Stopped

 Container warp Removing

 Container warp Removed

 Network warp-tunnel Removing

 Network warp-tunnel Resource is still in use

[10:26:37] Starting snapshot...

Snapshotting root@vps-micro:/home/dejavu/warp ...

 * 0 hashing, 40 hashed (23.4 MB), 0 cached (0 B), uploaded 193 B, estimated 23.4 MB (100.0%) 0s left

Created snapshot with root ka463aa955f638a00aed18d636e77e60c and ID 72c3275ef74463396ad6092d49c84ff0 in 1s

Running quick maintenance...

Compacting an eligible uncompacted epoch...

Advancing epoch markers...

Finished quick maintenance.

[10:26:46] 3. Restoring services...

 Container warp Creating

 Container warp Created

 Container warp Starting

 Container warp Started

[10:26:47] ✅ Snapshot created successfully

[10:26:47] Running repository maintenance (GC)...

Running full maintenance...

GC found 0 unused contents (0 B)

GC found 0 unused contents that are too recent to delete (0 B)

GC found 47 in-use contents (1.3 MB)

GC found 5 in-use system-contents (2.7 KB)

GC undeleted 0 contents (0 B)

Compacting an eligible uncompacted epoch...

Advancing epoch markers...

Attempting to compact a range of epoch indexes ...

Cleaning up unneeded epoch markers...

Cleaning up old index blobs which have already been compacted...

Cleaned up 0 logs.

Finished full maintenance.

[10:26:56] === Backup end ===

Backup Notifications [Optional]

This is optional but quite useful. We’ll use Apprise for notifications; Telegram is the example below.

Spinning up Apprise containers:

 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
services:

  apprise:

    image: caronc/apprise:latest

    container_name: apprise

    restart: unless-stopped

    user: "1000"

    networks:

      - apprise

    environment:

      APPRISE_STATEFUL_MODE: simple

      APPRISE_WORKER_COUNT: "1"

    volumes:

      - ./config:/config

      - ./plugin:/plugin

      - ./attach:/attach

networks:

  apprise:

    name: apprise

    driver: bridge

    enable_ipv6: true

Adjusting the Shell script formats:

  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
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
#!/bin/bash

SRC_DIR="/home/dejavu/warp"

LOG_DIR="/root/kopia/logs"

CURRENT_DATE=$(date +%Y%m%d)

LOG_FILE="$LOG_DIR/${CURRENT_DATE}-warp.log"

# ================= Configuration =================

# Kopia repository password

export KOPIA_PASSWORD=''

# [Optional] Disable Kopia update checks

# export KOPIA_CHECK_FOR_UPDATES=false

# Apprise notification config

NOTIFICATION_URL="tgram://<botToken>/<group>/"

APPRISE_NET="apprise"

APPRISE_API="http://apprise:8000/notify"

# ===========================================

log() {

    echo "[$(date '+%H:%M:%S')] $1" >> "$LOG_FILE"

}

# Dispatch Notification function

send_notify() {

    local STATUS_ICON=$1

    local STATUS_MSG=$2

    local DETAIL_MSG=$3

    local DATE_STR=$(date +%Y-%m-%d)

    

    local TITLE="Kopia Backup - Warp"

    local BODY="${STATUS_ICON} ${DATE_STR}-warp-${STATUS_MSG}\n\n${DETAIL_MSG}"

    local JSON_PAYLOAD=$(cat <<EOF

{

    "urls": "$NOTIFICATION_URL",

    "title": "$TITLE",

    "body": "$BODY"

}

EOF

)

    log "Sending notification..."

    docker run --rm --network "$APPRISE_NET" curlimages/curl:8.18.0 \

        -s -o /dev/null -X POST \

        -H "Content-Type: application/json" \

        -d "$JSON_PAYLOAD" \

        "$APPRISE_API"

}

# Fallback function

fallback_service() {

    if ! docker compose -f "$SRC_DIR/compose.yml" ps --services --filter "status=running" | grep -q "warp"; then

        log "Restoring container..."

        docker compose -f "$SRC_DIR/compose.yml" up -d >> "$LOG_FILE" 2>&1

    fi

}

trap fallback_service EXIT

log "=== Backup start ==="

cd "$SRC_DIR" || { log "Fatal error: directory $SRC_DIR not found"; exit 1; }

log "1. Stopping containers..."

docker compose down >> "$LOG_FILE" 2>&1

if [ $? -ne 0 ]; then

    log "❌ Failed to stop container; skipping backup to keep data safe."

    send_notify "❌" "Backup aborted" "Reason: container could not stop; backup was not executed."

    exit 1

fi

# Create snapshot

log "Starting snapshot..."

# Capture output to include partial info in the notification

SNAPSHOT_OUTPUT=$(kopia snapshot create "$SRC_DIR" --description "Weekly Backup" 2>&1 | tee -a "$LOG_FILE")

SNAPSHOT_STATUS=${PIPESTATUS[0]} 

# Start services

log "Restoring services..."

docker compose up -d >> "$LOG_FILE" 2>&1

# Get only the log filename (e.g., 20260202-warp.log)

LOG_FILENAME=$(basename "$LOG_FILE")

if [ $SNAPSHOT_STATUS -eq 0 ]; then

    log "✅ Snapshot created successfully"

    

    # Extract snapshot ID

    SNAP_ID=$(grep "Created snapshot with root" "$LOG_FILE" | tail -n 1 | awk '{print $5}')

    [ -z "$SNAP_ID" ] && SNAP_ID="ID extraction failed"

    

    # === Change: log path variable is now $LOG_FILENAME ===

    send_notify "✅" "Backup succeeded!" "Snapshot ID: $SNAP_ID\nService has been restored.\nLog location: $LOG_FILENAME"

else

    log "❌ Snapshot creation failed!"

    # On failure, show only the filename to keep it tidy

    send_notify "❌" "Backup failed!" "Kopia returned an error code.\nPlease check server logs immediately: $LOG_FILENAME"

    exit 1

fi

# Maintenance tasks

log "Running repository maintenance (GC)..."

kopia maintenance run --full >> "$LOG_FILE" 2>&1

log "=== Backup end ==="

Example output:

1
2
3
4
5
6
7
8
9
Kopia Backup - Warp

✅ 2026-02-02-warp-backup succeeded!

Snapshot ID: ke5e0d06612cb9850a1172cf0242983fb

Service has been restored.

Log location: 20260202-warp.log

Configuring Cron Jobs

Automate tasks via Crontab

1
sudo crontab -e

Add a task at the end

1
2
3
4
5
# My server timezone is UTC

# Expected: run at 01:00 Beijing time every Monday

0 17 * * 0 /bin/bash /root/kopia/backup-warp.sh

Restoring Backup

Create a test directory to verify the restore flow.

1
mkdir -p /tmp/warp_test_restore

Standard Restore

If you need the latest data, run:

1
2
3
4
5
6
7
# Format: snapshot restore <source_path> <target_destination>

sudo kopia snapshot restore /home/dejavu/warp /tmp/warp_test_restore/latest

# Verify restore

ls -lah /tmp/warp_test_restore/latest

Rollback to specific historical snapshots IDs targets:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# List snapshot history

sudo kopia snapshot list /home/dejavu/warp

# Example output:

root@vps-micro:/home/dejavu/warp

  2026-02-01 10:26:40 UTC ka463aa955f638a00aed18d636e77e60c 23.4 MB drwxrwxr-x files:40 dirs:6 (latest-1)

# Restore by snapshot ID

sudo kopia snapshot restore ka463aa955f638a00aed18d636e77e60c /tmp/warp_test_restore/history

# Verify restore

ls -lah /tmp/warp_test_restore/history

So far, everything looks good.

Disaster Recovery

Now consider an extreme case: if server data is completely destroyed, how do we recover in a new environment?

  1. Install Kopia on the new machine
  2. Connect to the existing bucket with the same B2 configuration
    1
    2
    3
    4
    5
    
    sudo kopia repository connect b2 \
      --bucket=<bucket-name> \
      --key-id=<keyID> \
      --key=<applicationKey> \
      --prefix=<prefix>/
    
  3. The new machine may have a different hostname; list all existing snapshots
    1
    2
    3
    4
    
    sudo kopia snapshot list --all
    # Find the previous machine’s <user>@<hostname>:<backup-path>
    root@vps-micro:/home/dejavu/warp
      2026-02-01 10:26:40 UTC ka463aa955f638a00aed18d636e77e60c 23.4 MB drwxrwxr-x files:40 dirs:6 (latest-1)
    
  4. Restore data
    1
    2
    
    # Usage: snapshot restore <user>@<old-hostname>:<old-path> <target-path>
    sudo kopia snapshot restore ka463aa955f638a00aed18d636e77e60c root@vps-micro:/home/dejavu/warp /tmp/warp_restore_test
    
  5. If you want the new machine to fully take over the old machine’s incremental backup history, you need to “spoof” identity when connecting.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    # Disconnect and reconnect with override parameters
    sudo kopia repository disconnect
    sudo kopia repository connect b2 \
      --bucket=<bucket-name> \
      --key-id=<keyID> \
      --key=<applicationKey> \
      --prefix=<prefix>/
      --override-hostname=<old-hostname> \
      --override-username=<old-username>
    

After verification, remove the test path

1
sudo rm -rf /tmp/warp_test_restore

Conclusion

With that, the automated backup setup is complete. Adding subsequent automated tasks takes just:

 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
# Fine‑tune policy

sudo kopia policy set /home/dejavu/wakapi \

  --keep-latest 3 \

  --keep-hourly 0 \

  --keep-daily 0 \

  --keep-weekly 0 \

  --keep-monthly 0 \

  --keep-annual 0

# Verify policy

sudo kopia policy get /home/dejavu/wakapi

# Edit automation task

sudo vim /root/kopia/backup-wakapi.sh

# Set permissions

sudo chmod 700 /root/kopia/backup-wakapi.sh

# First backup

sudo /bin/bash /root/kopia/backup-wakapi.sh

# Cron job

sudo crontab -e

You can also consider these optimizations:

  • Tier offsets dispersed daily request ceilings to lower costs benchmarks.
  • Multi-provider/multi-region redundancy elevates crash safety headers.