This repository is a read-only mirror. Development happens on a private Gitea instance; commits are pushed here automatically. Pull requests opened on GitHub cannot be merged and may go unnoticed — please open an issue instead, which is read. See CONTRIBUTING.md.
Pull-only rsync task manager. Web UI like TrueNAS's "Rsync Tasks" but inverted: the puller holds all credentials, sources never need anything from us.
Stack: FastAPI (Python 3.13) · SQLite · APScheduler · React 18 + Vite + Tailwind + shadcn/ui
Every host, path, schedule and byte count below is fabricated. They come from a
throwaway local instance produced by scripts/seed-demo.py
— see docs/demo.md — never from a real deployment.
The task list, filtered by engine. Each row carries its schedule, last and next run, and the outcome of the most recent run.
A finished run: exit code, byte and file counts, and the complete rsync output
including the --stats2 summary. Logs stream live while a run is in progress.
![]() |
![]() |
| Per-task run history — durations, throughput and failures. | The task form, with the usual rsync knobs. |
![]() |
![]() |
| Sources — defined once, reused across tasks. | The same list on a phone. |
- Source — a remote host you pull from (name,
user@host:port, SSH key). Defined once, reused across tasks. - Task — a scheduled
rsyncpull from a source path → local path, with the usual rsync knobs (archive, compress, delete, bwlimit, excludes …). - Run — one execution of a task. Records exit code, byte/file counts, link to streaming log.
- Matrix — checkbox; failures (and optionally successes) post to a configured room.
- Uptime Kuma — checkbox; Pullbackup creates a Kuma push monitor for the task and updates the heartbeat interval whenever the cron changes. Disabled tasks → paused monitor. Deleted tasks → deleted monitor.
Pullbackup ships as a single container image and is deployed as a Docker
Compose stack. No image is published yet, so build it from a checkout. The
Dockerfile at the repository root is a multi-stage build that compiles the
frontend and packages the backend with rsync, ssh and syncoid:
git clone https://github.com/mikeotoole/pullbackup.git
cd pullbackup
docker build -t pullbackup:local .docker/compose.example.yaml is a starting
point. It builds the same image from the repository root, so from the docker/
directory docker compose up -d --build is enough once a .env beside it
supplies the values from .env.example.
Bind-mount the destination roots you want exposed; Pullbackup's filesystem
browser is allowlisted to those roots. All UI and API routes except
/api/system/health require authentication: a signed session cookie obtained
from the login page, or HTTP Basic credentials for scripted access.
volumes:
- ${DOCKER_VOLUMES}/pullback/data:/data # sqlite, logs, ssh keys
- /mnt/user/backups:/mnt/dest/backups # destination root
- /mnt/user/media:/mnt/dest/media # add more roots as needed
/api/system/health is unauthenticated and reports both the app and the
scheduler:
{"ok": true, "version": "0.15.0", "scheduler_alive": true}Point an external monitor at scheduler_alive, not at ok. A backup
service whose scheduler has stopped still serves HTTP perfectly — that is
exactly how a four-day outage went unnoticed behind a green healthcheck — so
ok answers "is the app up" and scheduler_alive answers "is it still running
backups". ok deliberately stays true when the scheduler is dead, because
this is also what the container healthcheck reads and a self-restarting
container would destroy the evidence.
One boolean is the whole anonymous contract. This is the only endpoint reachable without credentials, so it carries no timestamps, no ages and no task detail — publishing when the scheduler last ran and how long a gap it tolerates would hand out the shape of the service's blind window to anyone who asked.
GET /api/system/scheduler (authenticated) carries the diagnosis: the full
liveness detail — running alongside alive, the last heartbeat, its age and
the staleness threshold — plus the ids of every task whose schedule is currently
overdue. The same per-task signal appears per row in the UI as a
missed schedule flag, which is independent of the last run's state: a task
can have succeeded last time and not have run since.
# Backend
cd backend
uv sync
export PULLBACKUP_HTTP_BASIC_USERNAME=pullbackup-dev
export PULLBACKUP_HTTP_BASIC_PASSWORD=$(openssl rand -base64 32)
uv run uvicorn pullbackup.main:app --reload --port 8000
# Frontend (separate terminal)
cd frontend
npm install
npm run dev # Vite proxies /api → :8000Note on naming. The product is Pullbackup, and so are the Python package and the
PULLBACKUP_environment prefix. Two identifiers still carry the shorter former name and are renamed separately: the host data directory (${DOCKER_VOLUMES}/pullback/data) and the SQLite file inside it (pullback.db). Do not rename the host data directory or the database on your own — they hold the database, run logs, and SSH keys, and pointing the app at a new path or filename starts it against an empty directory.PULLBACKUP_DB_FILENAMEexists so that rename can be done deliberately, with a backup, when you choose.
See .env.example. Deployment requires both PULLBACKUP_HTTP_BASIC_USERNAME and PULLBACKUP_HTTP_BASIC_PASSWORD; the password must contain at least 32 characters. Keep the password in the deployment secret store, not in source control.
Opening the UI presents a sign-in form at /login. Submitting the configured
credential exchanges it for an HttpOnly, SameSite=Lax session cookie
(Secure when the request arrives over https), so the credential is not
re-sent on every request. Sessions last 7 days by default
(PULLBACKUP_SESSION_MAX_AGE_SECONDS) and sign out in the header
invalidates the session server-side, not just in the browser.
HTTP Basic still works unchanged for scripted callers and for the container healthcheck — a request authenticates with either a valid session cookie or valid Basic credentials:
curl -u "$PULLBACKUP_HTTP_BASIC_USERNAME:$PULLBACKUP_HTTP_BASIC_PASSWORD" \
http://localhost:8000/api/tasks
An unauthenticated /api/* request is always refused with 401, but the
WWW-Authenticate: Basic challenge on that refusal is omitted when the caller
is recognisably a browser — a Sec-Fetch-Mode other than navigate, an
X-Pullbackup-Client: web header (the UI sends it), or Accept: text/event-stream. Otherwise the browser would pop its own native credential
dialog in front of the sign-in page. Scripted callers, the container
healthcheck, and a browser navigating directly at /openapi.json all still
receive the challenge, so curl -u and --anyauth are unaffected.
Cookies are signed with PULLBACKUP_SESSION_SECRET when it is set. When it is
not, the signing key is derived from the configured credential, so upgrading
needs no new configuration — with the deliberate consequence that changing
the username or password invalidates every existing session. Set an explicit
secret if you would rather rotate the password without signing everyone out.
Sessions are held in process memory, so restarting the container also signs
everyone out.
The signing itself is itsdangerous,
the Pallets library Starlette's own SessionMiddleware uses. Expiry and
revocation stay in this codebase: a signed token is valid until it expires by
definition, so "logout kills this cookie" needs server-side state whatever
signs the token.
Repeated failed sign-ins from one address are locked out for 60 seconds after 5 failures. HTTP Basic had no login endpoint to brute-force; a form does.
The throttle keys on the address the app actually sees. Behind a reverse proxy that is the proxy's address for every caller, so by default all clients share one failure bucket — five wrong guesses by anybody locks the login form for everybody for 60 seconds. HTTP Basic is unaffected, so a scripted caller always has a way in, but the form is unusable for the duration.
Set PULLBACKUP_TRUSTED_PROXIES to a comma-separated list of the proxy
addresses or CIDR ranges you control:
PULLBACKUP_TRUSTED_PROXIES=172.18.0.0/16
Only then is X-Forwarded-For consulted, and only for requests arriving from
one of those addresses; each real client then gets its own bucket. A request
from anywhere else has its forwarding headers ignored entirely, so the header
can never be used to escape the limit. Leaving the setting empty keeps the
pre-existing behaviour exactly.
Only list proxies you actually control — any host in this list can claim to be forwarding for any client. A value that is not a valid address or CIDR is refused at startup rather than silently ignored.
This build reads the PULLBACKUP_ prefix. If it finds a retired PULLBACK_
name with no PULLBACKUP_ counterpart it adopts that value and logs a
warning, rather than falling back to a built-in default — which would widen
the rsync destination allowlist and blank the HTTP Basic credentials. Both
prefixes may be set at once during a deployment cutover; the new names win.
The adoption shim is temporary: rename your variables and it goes quiet.
PULLBACKUP_ZFS_DEST_ROOTS is a comma-separated allowlist for Syncoid destinations and ZFS pruning. Each Syncoid destination must be a descendant of one configured dataset root (for example, cache/docker_remote/eel beneath cache/docker_remote). An empty allowlist disables Syncoid and pruning operations. PULLBACKUP_DEST_ROOTS remains the separate filesystem allowlist for rsync destinations and the browser.
PULLBACKUP_MAX_CONCURRENT_RUNS (default 3) bounds how many backups execute
at the same time. The bound exists because the disks and the uplink are shared:
unbounded parallel rsync/syncoid would thrash both. Set it to 1 for a
deployment that genuinely wants one transfer at a time; a value below 1 is
refused at startup, because with no slots every run is admitted and then waits
forever without starting.
Raising it never allows two backups into the same place. Destination exclusion is enforced separately and always: two tasks writing the same directory — or one writing a subdirectory of the other — serialize, as do two Syncoid replications into the same ZFS dataset or one of its descendants. A run that finds its destination busy waits and starts when it frees, rather than being skipped. Runs from the same source also remain serialized.
PULLBACKUP_RUN_TIMEOUT_SECONDS (default 86400, one day) bounds how long a
single run may execute. A backup subprocess can hang indefinitely — a dead
network path, a zfs receive refusing a diverged incremental, a zfs destroy
on a suspended pool — and without a ceiling it holds its executor slot forever
while its run row stays running, which blocks every subsequent run for that
source. On expiry the run's whole process group is terminated (so the ssh or
zfs send | zfs receive goes with it) and the run is recorded as failed,
with the exceeded bound named in the error.
The ceiling covers the whole run, not each subprocess: a Syncoid task that
also prunes snapshots shares one deadline across its transfer and its pruning,
so a run can never spend its full ceiling twice. Set it to 0 to wait forever
if your initial replication genuinely runs longer than any ceiling you would
set; a negative value is refused at startup.
PULLBACKUP_WATCHDOG_INTERVAL_SECONDS (default 60) is how often the runner
reconciles active run rows against the executions it is actually running, and
fails any row it provably is not executing. Startup recovery does the same
thing, but only at startup — so without this a row left behind by a crashed
execution blocks its source until someone restarts the container. Liveness is
decided by run ownership, not by a pid (recycled, and not known until after the
exec) and not by elapsed time (which cannot tell a wedge from a long transfer).
A value below 1 is refused at startup: zero would spin rather than switch the
watchdog off.
Matrix and Uptime Kuma credentials are optional; omit their values to disable those integrations.
Copyright (C) 2026 Mike O'Toole.
Pullbackup is licensed under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). The full text is in LICENSE, reproduced verbatim from the FSF; the copyright notice lives in NOTICE so the licence file stays unmodified.
Source files carry a one-line SPDX identifier
(SPDX-License-Identifier: AGPL-3.0-or-later) rather than a repeated notice
block.
The AGPL was chosen deliberately over a permissive licence. Pullbackup is server software you self-host, and section 13 means that anyone who modifies it and offers it to others over a network must make their modified source available to those users. Running an unmodified copy for yourself — the normal case — carries no such obligation.





