DropBridge + Restic — Install & Automated Backup Guide for ZimaOS
A step-by-step guide: install DropBridge on ZimaOS, then protect its data — and the rest of your box — with encrypted, deduplicated Restic backups pushed to an append-only repository over your tailnet, scheduled from the ZimaOS Cron module.
Maintained by chicohaager/dropbridge. Found a mistake? Open an issue on the repo.
Why back up DropBridge with Restic?
DropBridge lands files in a single directory on the receiving box (/DATA/dropbridge/incoming).
That directory — plus a couple of config files — is your DropBridge state. This
guide backs it up (alongside your other /DATA app data) with Restic:
| Advantage | Description |
|---|---|
| Encrypted at rest | The repository is AES-256 encrypted. Even on a box in another building, the data is unreadable without the password. |
| Deduplicated & incremental | Content-defined chunking stores re-dropped or moved files once. Daily snapshots cost only the delta. |
| Runs as a container | Restic runs from the official restic/restic image — no host install, so ZimaOS's read-only root and missing package manager are non-issues. |
| Append-only target | The backup server is started --append-only: the NAS can add snapshots but can never delete or overwrite them. Ransomware or a compromised box cannot wipe your history. |
| Tailnet-native | The repository lives on another machine on your Tailscale network. No cloud, no ports open to the internet. |
| File-driven config | What to back up, where, and what to skip are plain text files — change the backup set without touching code. |
Prerequisites
- A ZimaOS (or any Linux + Docker + Tailscale) box running DropBridge — the source.
- SSH access to that box (
ssh <user>@<zima-tailnet-ip>). - A second machine on your tailnet to hold the backups — the target (e.g. a ZimaBoard in another location). It runs the Restic REST server in a container.
- Both machines on the same Tailscale tailnet (
tailscale statusshows both). - The ZimaOS Cron module installed (
chicohaager/cron, reachable athttp://<box>/modules/cron/) for scheduling.
Version Information
Tested with:
| Component | Version |
|---|---|
| ZimaOS | 1.6.x |
| DropBridge | ghcr.io/chicohaager/dropbridge:0.2.0 |
| Restic | restic/restic (official image, 0.17.x) |
| rest-server | restic/rest-server (append-only) |
| Cron module | chicohaager/cron (ZimaOS Mod-Store) |
What gets backed up
The whole backup is driven by one file — secrets/include.txt — with one path per
line. A sensible ZimaOS set (and what this guide uses):
| Path | Contains | In backup? |
|---|---|---|
/DATA/dropbridge |
DropBridge received files, .env, secret.token |
✅ Yes |
/DATA/Documents |
Your documents | ✅ Yes |
/DATA/AppData |
All app data | ✅ Yes (minus excludes) |
/var/lib/casaos/apps |
CasaOS app definitions (so apps can be re-registered) | ✅ Yes |
Excluded (secrets/exclude.txt) — large or regenerable data:
/DATA/AppData/open-webui-ollama/ollama/models # tens of GB, re-pullable
**/cache
**/*.tmp
Use case & architecture
┌────────────────────────────────────────────────┐
│ SOURCE BOX · ZimaOS │
│ DropBridge :8787 -> /DATA/dropbridge │
│ restic/restic container · Cron-scheduled │
└──┬─────────────────────────────────────────────┘
│
│ restic backup -> rest:http://…@<target>:8000/
│ AES-256 · deduplicated · over the tailnet
▼
┌────────────────────────────────────────────────┐
│ PRIMARY TARGET · append-only │
│ restic/rest-server --append-only │
│ repo: /media/<disk>/restic-home │
│ prune · verify · dead-man's switch (Part 6) │
└──┬─────────────────────────────────────────────┘
│
│ restic copy · LAN · append-only
▼
┌────────────────────────────────────────────────┐
│ SECOND COPY · rest-server (LAN) │
│ --append-only · 3-2-1 offsite mirror │
└────────────────────────────────────────────────┘
Restic runs on the source box as a throwaway container, mounts /DATA read-only,
and pushes encrypted chunks to a REST server on the primary target. Because that
server runs --append-only, the source can add snapshots but never delete them —
pruning, integrity checks, and a dead-man's switch all run on the target (Part 6),
and a restic copy mirrors the repo to a second append-only server for 3-2-1.
Part 1 — Install DropBridge
Step 1: SSH into the box
ssh <user>@<zima-tailnet-ip>
Step 2: Install (ZimaOS — recommended)
The installer pulls the prebuilt image, generates the shared token, prepares the data disk, and registers a fixed-URL dashboard tile:
curl -fsSL https://raw.githubusercontent.com/chicohaager/dropbridge/main/install.sh -o install.sh
sudo sh install.sh
Step 2 (alternative): Install with Docker Compose
export DOCKER_CONFIG=/DATA/.docker # ZimaOS root is read-only → keep docker config on writable /DATA
mkdir -p /DATA/dropbridge/incoming
cd /DATA/dropbridge
curl -fsSL https://raw.githubusercontent.com/chicohaager/dropbridge/main/docker-compose.ghcr.yml -o docker-compose.ghcr.yml
umask 077
LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 48 > secret.token
chown 1000:1000 secret.token && chmod 0400 secret.token
docker pull ghcr.io/chicohaager/dropbridge:0.2.0
docker compose -f docker-compose.ghcr.yml up -d
⚠️ Important (ZimaOS): always set
DOCKER_CONFIG=/DATA/.dockerbefore anydockercommand — the root filesystem is read-only, so the default~/.dockerpath fails.
Step 3: Verify DropBridge is up
docker ps --filter name=dropbridge # STATUS should be "Up ..."
curl -fsS http://127.0.0.1:8787/api/config # returns JSON, not an error
Open the UI at http://<zima-tailnet-ip>:8787/ — you should see the live mesh
console (not demo mode). Drop a test file so there's something to back up.
Part 2 — Drop files to another ZimaOS box
DropBridge's core function: drag files into the browser on one box and have them stream straight to a second ZimaOS box over the tailnet — no cloud, no LAN port, no size limit. Both boxes run the same container, each pointed at the other.
Step 1: Install DropBridge on the second box too
Repeat Part 1 on the second ZimaOS. Both boxes must be on the same Tailscale
tailnet (tailscale status shows both) and share the same bearer token — the
identical secret.token next to the compose file on both is what pairs them:
# copy the token from box A to box B (same value on both = paired)
scp /DATA/dropbridge/secret.token <user>@<boxB-tailnet-ip>:/DATA/dropbridge/secret.token
# on box B: restore ownership/mode
chown 1000:1000 /DATA/dropbridge/secret.token && chmod 0400 /DATA/dropbridge/secret.token
Step 2: Point each box at the other
Per box, set the peer in .env. DROPBRIDGE_PEER_URL is the other node's tailnet
IP on port 8787:
| var | box A | box B |
|---|---|---|
DROPBRIDGE_NODE |
boxA |
boxB |
DROPBRIDGE_PEER_NAME |
boxB |
boxA |
DROPBRIDGE_PEER_URL |
http://<boxB-tailnet-ip>:8787 |
http://<boxA-tailnet-ip>:8787 |
tailscale ip -4 # each box's tailnet IP (100.64.0.0/10 range)
export DOCKER_CONFIG=/DATA/.docker
docker compose -f docker-compose.ghcr.yml up -d # 'up -d', not 'restart' — reloads .env
This seeds the default send target. You can add more targets live in Step 4 without editing
.env.
Step 3: Drop a file
- Open the console on the sending box:
http://<boxA-tailnet-ip>:8787/ - In the drop zone's target selector, pick the receiving box (it lists this box + every configured peer).
- Drag files or a whole folder onto the drop zone. DropBridge streams each file
(
POST /api/sendhere → the peer'sPOST /api/ingest) to/DATA/dropbridge/incoming/on the receiving box. Folder structure is kept, and name collisions are auto-renamed — never silently overwritten.
Step 4: Add more boxes live (no config edit)
Click + add node in the console: it lists your tailnet devices (via tailscaled)
as candidates. Pick one — DropBridge probes its /api/config and refuses anything
that isn't DropBridge or carries the wrong token, so you can't accidentally add a
phone or an unrelated host. Added peers persist to .dropbridge-peers.json in the
incoming volume and show up in the target selector.
Step 5: Verify the transfer landed
On the receiving box:
ls -la /DATA/dropbridge/incoming/ # your dropped files are here
curl -fsS -H "Authorization: Bearer $(cat /DATA/dropbridge/secret.token)" \
http://127.0.0.1:8787/api/received # JSON list of received files
Or watch the Activity feed in the console. A failed transfer returns a non-2xx and surfaces loudly in the UI — DropBridge never reports a silent "delivered".
No firewall change needed. ZimaOS's ZFW lets the
tailscale0interface through by default, so peer-to-peer transfers work without opening any LAN port. Every control/data endpoint is tailnet-only (guard()): reachable only from a100.64.0.0/10source, an authenticatedtailscale servesession, or the bearer token. You only need a ZFW rule to reach the UI from a plain-LAN device.
(Optional) Hand a file to someone not on your tailnet
Click share on a received file → DropBridge mints a 128-bit token and returns a
public link https://<node>.<tailnet>.ts.net:10000/s/<token> (needs a path-scoped
tailscale funnel on /s plus DROPBRIDGE_FUNNEL_BASE; revoke or a TTL kills
it). Only /s/{token} is ever public — the UI and /api/* stay tailnet-only.
Part 3 — Stand up the backup target (append-only REST server)
On the target box (a second ZimaOS box reachable over the tailnet), run the official Restic REST server in a container, in append-only mode.
Step 1: Create the repository credential
The REST server authenticates clients against a .htpasswd file at the root of
the repo data directory. Create one bcrypt entry — the username/password you set
here are exactly what goes into the source box's repo-url
(rest:http://<user>:<pass>@…):
export DOCKER_CONFIG=/DATA/.docker
mkdir -p /media/<disk>/restic-home
# ZimaOS has no htpasswd binary — generate the entry with a throwaway container:
docker run --rm httpd:alpine htpasswd -nbB backup '<a-strong-password>' \
> /media/<disk>/restic-home/.htpasswd
Step 2: Run the REST server, append-only
/DATA/AppData/restic-rest-server/docker-compose.yml:
name: restic-rest-server
services:
rest-server:
image: restic/rest-server:latest
container_name: restic-rest-server
restart: unless-stopped
environment:
- OPTIONS=--append-only
ports:
- "<target-tailnet-ip>:8000:8000" # bound to the tailnet IP only
volumes:
- type: bind
source: /media/<disk>/restic-home # the repo lives on the big disk
target: /data
docker compose -f /DATA/AppData/restic-rest-server/docker-compose.yml up -d
docker ps --filter name=restic-rest-server # STATUS should be "Up ..."
Two hardening details worth copying: - The port is published as
<tailnet-ip>:8000:8000, not8000:8000— so the server is reachable only over the tailnet, never on the LAN or other interfaces. -OPTIONS=--append-only: over REST, clients can add snapshots but never delete or overwrite them. A compromised source box cannot wipe your history. Pruning is done locally on the target against the repo directory (Part 6), which bypasses the append-only HTTP guard.
Part 4 — Configure & run Restic on the source box
Everything lives under /DATA/AppData/restic-client/. No host binary — Restic runs
as a container that mounts /DATA read-only.
Step 1: Lay out the config
mkdir -p /DATA/AppData/restic-client/secrets
cd /DATA/AppData/restic-client/secrets
Create these files (all chmod 0400, root-owned):
repo-url — WHERE it backs up to (the append-only REST target):
rest:http://backup:<rest-password>@<target-tailnet-ip>:8000/
repo-pass — the repository encryption password (random; store a copy in your
password manager — a lost password means unreadable snapshots forever):
LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 40 > repo-pass
include.txt — WHAT gets backed up (one path per line):
/DATA/Documents
/DATA/dropbridge
/var/lib/casaos/apps
/DATA/AppData
exclude.txt — what to skip:
/DATA/AppData/open-webui-ollama/ollama/models
**/cache
**/*.tmp
pushover.conf (optional alerts):
PUSHOVER_TOKEN=<your-app-token>
PUSHOVER_USER=<your-user-key>
chmod 0400 /DATA/AppData/restic-client/secrets/*
Step 2: The backup script
Save as /DATA/AppData/restic-client/restic-backup.sh (chmod 0755):
#!/bin/sh
# Curated restic backup: this box -> append-only rest-server over the tailnet.
# Streams live progress (tee) AND notifies via Pushover on success/failure.
set -u
S=/DATA/AppData/restic-client/secrets
FPS="${RESTIC_PROGRESS_FPS:-0.2}"
PUSHOVER_CONF="$S/pushover.conf"
notify() { # TITLE MESSAGE PRIORITY (-1 quiet, 0 normal, 1 high)
[ -f "$PUSHOVER_CONF" ] || return 0
PUSHOVER_TOKEN=""; PUSHOVER_USER=""
. "$PUSHOVER_CONF" 2>/dev/null || return 0
[ -n "$PUSHOVER_TOKEN" ] && [ -n "$PUSHOVER_USER" ] || return 0
curl -s --max-time 20 \
--form-string "token=$PUSHOVER_TOKEN" --form-string "user=$PUSHOVER_USER" \
--form-string "title=$1" --form-string "message=$2" --form-string "priority=$3" \
https://api.pushover.net/1/messages.json >/dev/null 2>&1 || true
}
restic() {
env DOCKER_CONFIG=/DATA/.docker docker run --rm \
-e RESTIC_REPOSITORY_FILE=/secrets/repo-url \
-e RESTIC_PASSWORD_FILE=/secrets/repo-pass \
-e RESTIC_PROGRESS_FPS="$FPS" \
-v "$S":/secrets:ro \
-v /DATA:/DATA:ro \
-v /var/lib/casaos/apps:/var/lib/casaos/apps:ro \
restic/restic "$@"
}
START=$(date '+%F %T')
LOG=$(mktemp)
echo ">>> restic backup starting $START"
restic backup --host "$(hostname)" --tag critical \
--files-from /secrets/include.txt --exclude-file /secrets/exclude.txt 2>&1 | tee "$LOG"
if grep -q 'snapshot .* saved' "$LOG"; then
SUMMARY=$(grep -E 'Added to the repository|processed [0-9]' "$LOG" | tr '\n' ' ')
echo ">>> OK: $SUMMARY"
notify "DropBridge Backup OK" "${SUMMARY:-done} ($START)" "-1"
RC=0
else
echo ">>> FAILED"
notify "DropBridge Backup FAILED" "no snapshot written ($START) — $(tail -4 "$LOG" | tr '\n' ' ')" "1"
RC=1
fi
rm -f "$LOG"
exit $RC
Why a container, not a host binary: ZimaOS has a read-only root and no package manager, so installing Restic natively is painful. The official
restic/resticimage sidesteps all of it, and mounting/DATA:roguarantees the backup can never modify your live data.
Step 3: Initialize the repository & first backup
# one-time: create the repo on the target
env DOCKER_CONFIG=/DATA/.docker docker run --rm \
-e RESTIC_REPOSITORY_FILE=/secrets/repo-url \
-e RESTIC_PASSWORD_FILE=/secrets/repo-pass \
-v /DATA/AppData/restic-client/secrets:/secrets:ro \
restic/restic init
# first backup
sh /DATA/AppData/restic-client/restic-backup.sh
Watch for snapshot <id> saved and a Pushover "Backup OK" push.
Step 4: Verify
alias rc='env DOCKER_CONFIG=/DATA/.docker docker run --rm \
-e RESTIC_REPOSITORY_FILE=/secrets/repo-url -e RESTIC_PASSWORD_FILE=/secrets/repo-pass \
-v /DATA/AppData/restic-client/secrets:/secrets:ro restic/restic'
rc snapshots # lists your snapshot, tagged "critical"
rc check # integrity check (read-only — works fine against append-only)
Step 5: Test a restore (do not skip)
A backup you have never restored is a hope, not a backup. Restore into a scratch dir and diff against the live DropBridge data:
env DOCKER_CONFIG=/DATA/.docker docker run --rm \
-e RESTIC_REPOSITORY_FILE=/secrets/repo-url -e RESTIC_PASSWORD_FILE=/secrets/repo-pass \
-v /DATA/AppData/restic-client/secrets:/secrets:ro \
-v /tmp/restore-test:/restore \
restic/restic restore latest --target /restore --include /DATA/dropbridge
diff -r /DATA/dropbridge/incoming /tmp/restore-test/DATA/dropbridge/incoming && echo "RESTORE OK"
rm -rf /tmp/restore-test
Part 5 — Schedule it with the ZimaOS Cron module
You already have the chicohaager/cron module — schedule the script there instead
of hand-editing crontabs.
- Open
http://<box>/modules/cron/(the Scheduler). - Click New Task (top right). The Create Task dialog opens.
- Leave Start from Template on
-- Blank Task --and fill in: - Task Name:Restic Backup → <target>- Command:/DATA/AppData/restic-client/restic-backup.sh- Schedule Type: switch from Interval (minutes) to Cron Expression, then enter0 3 * * *for a daily 03:00 run (or keep Interval and enter minutes). - Category:backup- Tags (comma-separated):critical, daily- Priority (1-10): leave at5unless you need it to win a run-slot race. - Click Create. The task appears in the list with Next Run and a Last Result badge.
- Click Run Once to trigger it immediately, then Show Logs to confirm you
see
snapshot … savedand>>> OK: ….
Verify the run, not just the badge: a green Success means the script exited
0— always open Show Logs at least once to confirm a snapshot was actually written and the summary line shows bytes processed.
Part 6 — Target-side maintenance & monitoring
The append-only guard protects history but means the source can never prune, and
never knows if it stops sending. Four small jobs on the target close those gaps.
All live in /DATA/AppData/restic-rest-server/, run restic as a container against
the local repo directory (/media/<disk>/restic-home, mounted /repo), and
are scheduled from the target's Cron module. They share the repo password
(repo-pass) and an optional pushover.conf for alerts.
6.1 Prune (prune.sh) — reclaim space
Running locally against the repo dir bypasses the append-only HTTP guard, so packs can actually be repacked and deleted. Schedule weekly.
REPO=/media/<disk>/restic-home
APPDIR=/DATA/AppData/restic-rest-server
env DOCKER_CONFIG=/DATA/.docker docker run --rm \
-e RESTIC_REPOSITORY=/repo -e RESTIC_PASSWORD_FILE=/secrets/repo-pass \
-v "$REPO":/repo -v "$APPDIR":/secrets:ro \
restic/restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
6.2 Verify (verify.sh) — prove the repo restores
A backup you never restore is a hope. This runs weekly: restic check
(structure) → check --read-data-subset=5% (samples packs for bitrot) → a restore
probe of /DATA/dropbridge into a scratch dir on the big disk, asserting the file
count is non-zero. Pushover priority 1 on failure, -1 as a heartbeat on success.
# core of the restore drill (fails loud if 0 files come back):
env DOCKER_CONFIG=/DATA/.docker docker run --rm \
-e RESTIC_REPOSITORY=/repo -e RESTIC_PASSWORD_FILE=/secrets/repo-pass \
-v "$REPO":/repo -v "$APPDIR":/secrets:ro -v "$PROBE":/restore \
restic/restic restore latest --host <source-host> --include /DATA/dropbridge --target /restore
Note: put the probe dir on the big disk (
/media/<disk>/…), not/tmp— ZimaOS's eMMC root is tiny and a full DropBridge restore will fill it.
6.3 Dead-man's switch (deadman.sh) — catch a backup that silently stopped
Runs on the target independently of the source (hourly or a few times a day). If
the newest snapshot is older than MAXAGE_H=25 hours, it fires a high-priority
Pushover alert — this is what catches "the source died / cron broke / nothing ran".
# newest snapshot across ALL groups (not `--latest 1`, which is per host+paths):
latest=$(… restic/restic snapshots --json | grep -o '"time":"[^"]*"' | cut -d'"' -f4 | sort | tail -1)
# age >= 25h → notify "Backup VERALTET/STILL" priority 1
6.4 Second copy (copy-to-second.sh) — 3-2-1
Mirrors the primary repo to a second append-only rest-server over the local
network with restic copy (copy only adds, so append-only is fine). Schedule
daily, after the primary backup window.
env DOCKER_CONFIG=/DATA/.docker docker run --rm \
-e RESTIC_REPOSITORY_FILE=/secrets/copy-dest-url \
-e RESTIC_PASSWORD_FILE=/secrets/repo-pass \
-v "$SRCREPO":/srcrepo -v "$APPDIR":/secrets:ro \
restic/restic copy --from-repo /srcrepo --from-password-file /secrets/repo-pass
Schedule 6.1–6.4 in the target's Cron module (
http://<target>/modules/cron/) — the same New Task flow as Part 5. Keeprepo-passidentical on every box that touches the repo.
Part 7 — Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Fatal: unable to open config file on first run |
Repo not initialized yet | Run the restic init step (Part 4, Step 3) once |
wrong password or no key found |
repo-pass differs from the repo's password |
Use the exact password the repo was init-ed with |
Save(<data/…>) returned error … 403 Forbidden |
Trying to prune/delete against an append-only server | Correct — prune on the target (Part 6), not the source |
403 Unauthorized on backup |
Wrong REST user/password in repo-url |
Match the htpasswd credential you created on the target |
| Backup runs but DropBridge files missing | /DATA/dropbridge not in include.txt |
Add it — one path per line; re-run |
| Cron task shows Success but nothing changed | Script exited 0 without writing a snapshot | Open Show Logs; check the target is reachable over the tailnet |
permission denied reading secret.token |
Restic container runs as root; file is 0400 uid 1000 |
Fine — the -v /DATA:/DATA:ro mount reads it as root |
Verification Checklist
- [ ]
docker psshowsdropbridgeUp, UI reachable on:8787(not demo mode) - [ ] REST server on the target is Up and running
--append-only - [ ]
rc snapshotslists a snapshot taggedcritical - [ ]
rc checkreports no errors - [ ] A test restore matched the live
/DATA/dropbridge/incoming(RESTORE OK) - [ ] The Cron module shows the task scheduled with a Success last result — and Show Logs confirms a snapshot was written
- [ ]
repo-passis saved in your password manager (both boxes)
Quick Reference — Restic commands
Run everything through the container (define the rc alias from Part 4, Step 4):
rc snapshots # list snapshots
rc backup --files-from /secrets/include.txt --exclude-file /secrets/exclude.txt --tag critical
rc restore latest --target /restore --include /DATA/dropbridge
rc check # verify integrity (read-only)
rc stats # repository size / dedup ratio
# forget/prune: on the TARGET only (append-only server blocks it from the source)
Appendix — Restore onto a fresh box
# recreate the secrets/ dir (repo-url + repo-pass) on the new box, then:
env DOCKER_CONFIG=/DATA/.docker docker run --rm \
-e RESTIC_REPOSITORY_FILE=/secrets/repo-url -e RESTIC_PASSWORD_FILE=/secrets/repo-pass \
-v /DATA/AppData/restic-client/secrets:/secrets:ro \
-v /:/restore \
restic/restic restore latest --target /restore --include /DATA/dropbridge
chown 1000:1000 /DATA/dropbridge/secret.token && chmod 0400 /DATA/dropbridge/secret.token
export DOCKER_CONFIG=/DATA/.docker
docker compose -f /DATA/dropbridge/docker-compose.ghcr.yml up -d
Your received files, token, and config come back exactly as they were.