ZIMA.Docs / ZimaOS / Community MakesCommunity Guide

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.

Restic backing up DropBridge over the tailnet


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

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>

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/.docker before any docker command — the root filesystem is read-only, so the default ~/.docker path 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

  1. Open the console on the sending box: http://<boxA-tailnet-ip>:8787/
  2. In the drop zone's target selector, pick the receiving box (it lists this box + every configured peer).
  3. Drag files or a whole folder onto the drop zone. DropBridge streams each file (POST /api/send here → the peer's POST /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 tailscale0 interface 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 a 100.64.0.0/10 source, an authenticated tailscale serve session, 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, not 8000: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/restic image sidesteps all of it, and mounting /DATA:ro guarantees 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.

  1. Open http://<box>/modules/cron/ (the Scheduler).
  2. Click New Task (top right). The Create Task dialog opens.
  3. 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 enter 0 3 * * * for a daily 03:00 run (or keep Interval and enter minutes). - Category: backup - Tags (comma-separated): critical, daily - Priority (1-10): leave at 5 unless you need it to win a run-slot race.
  4. Click Create. The task appears in the list with Next Run and a Last Result badge.
  5. Click Run Once to trigger it immediately, then Show Logs to confirm you see snapshot … saved and >>> 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. Keep repo-pass identical 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


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.