diff --git a/docs/backups.md b/docs/backups.md
new file mode 100644
index 00000000..2adc52c9
--- /dev/null
+++ b/docs/backups.md
@@ -0,0 +1,158 @@
+# Backups
+
+Scribe can back itself up every day to a **folder** or an **S3-compatible
+bucket**. Each run adds one file; Scribe never deletes an old one. Keeping and
+thinning the history is the destination's job.
+
+Set it up under **Settings → Data → Scheduled backups** (admins only).
+
+## What a backup is
+
+One file per run, named for the moment it was taken in UTC:
+
+```
+scribe-20261015T020000Z.tar
+```
+
+It is a plain, **uncompressed** tar:
+
+- `manifest.json` comes first. It holds the backup format version, the Scribe
+ version, when the backup was taken, and how many rows each table has.
+- Then there is one `
.jsonl` per table, one row per line.
+
+It holds everything the admin **Full Backup** download holds, laid out
+differently. It does **not** hold:
+
+- **Search indexes.** They are rebuilt from the records when Scribe starts.
+- **Sharing:** groups, project shares and note shares.
+- **Credentials:** API keys and forge connections. After a restore, issue new
+ API keys and add forge connections again.
+- **Logs and notifications.**
+- **The backup destination's own S3 secret.**
+
+The file contains hashed passwords, so store it somewhere only trusted people
+can read.
+
+### Why it is shaped this way
+
+The file is meant to be copied off-site by a deduplicating tool such as
+restic, which stores a chunk of data only once no matter how many backups
+contain it. Every choice serves that:
+
+- **It is never compressed.** Compression changes every byte after the first
+ edit, so nothing would match the day before. restic compresses its own
+ repository after deduplicating, so nothing is lost.
+- **Rows are in primary-key order, with their keys sorted.** A row that did
+ not change is the same bytes in the same place every day. An edited row
+ changes one line, and new rows land at the end of their table's file.
+- **Each table is its own file**, so a busy table never shifts the bytes of a
+ quiet one.
+- **The parts that change every run are kept in `manifest.json`**: the time
+ and the counts.
+- **Each run gets a new, dated name.** Overwriting one name would keep one
+ copy and no history.
+
+Day to day, the off-site copy then stores roughly what changed, not the whole
+file again.
+
+## Destinations
+
+### A folder
+
+A path **inside the Scribe container**, normally a share mounted there:
+
+```yaml
+services:
+ scribe:
+ volumes:
+ - /nfs/backup/scribe:/backups
+```
+
+Scribe writes each file under a hidden name (`.scribe-….tar.part`) and renames
+it once it is complete. A program watching the folder never sees half a
+backup.
+
+### An S3-compatible bucket
+
+You need four things:
+
+- **Address:** the service itself, like `https://backup.example.com`. Don't
+ include the bucket; Scribe addresses buckets by path.
+- **Bucket.**
+- **Access key.**
+- **Secret key.**
+
+Scribe uploads and lists; it never deletes. A key that may **only add
+objects** is therefore enough, and it is the safer key to hand out: a stolen
+one cannot wipe the history.
+
+The secret is stored encrypted, under a key derived from `SECRET_KEY`. It is
+never shown again, and never included in a backup. If an instance's
+`SECRET_KEY` changes, for example after a restore onto a new instance, the
+page asks for the secret again.
+
+**Test destination** checks the destination without sending a backup:
+
+- a folder: it writes and removes a hidden file;
+- a bucket: it only lists, because an add-only key could never remove a test
+ file.
+
+## Schedule and history
+
+- Backups run once a day at the hour you choose, in UTC. The default is 02:00,
+ before the nightly trash purge and database maintenance.
+- **Back up now** runs one straight away.
+- The page leads with when the last good backup was taken. It says
+ **Overdue** when backups are on and none has worked for 26 hours.
+- It lists the last 30 runs, and gives the reason for each failure in words.
+- A run that hasn't finished after an hour is abandoned and recorded as
+ failed.
+
+## Example: Fabled Archives
+
+[Fabled Archives](https://git.fabledsword.com/bvandeusen/fabledarchives)
+keeps service backups as "backup targets". Either destination works with it.
+
+1. On Archives' **Backups** page, make a target for Scribe, named `scribe`
+ for example.
+2. Choose how Scribe's files arrive:
+ - **S3**: turn on the S3 way in for the target and make an **append-only**
+ S3 key. In Scribe, choose **An S3 bucket**:
+ - address: Archives' backup address (`http://:8081`);
+ - bucket: the target's name;
+ - access key and secret key: the key you just made.
+ - **A folder**: mount the share Archives watches into Scribe as
+ `/backups/scribe`, and point the target at that folder. In Scribe, choose
+ **A folder** with `/backups/scribe`.
+3. Set the target's **Retention** to **Archives**. Archives then keeps the
+ newest backups and thins the rest by day, week and month. Scribe only adds.
+4. Give the target an expected cadence of **daily**, so a missed backup turns
+ it late in Archives as well as overdue in Scribe.
+5. Add an off-site **restic** replication job for the target. The uncompressed
+ layout above is what lets that job send only what changed each day.
+
+## Restoring
+
+### Into a fresh instance
+
+Start Scribe on an empty database, copy the backup file into the container,
+and run:
+
+```sh
+docker exec -it scribe python -m scribe.restore /backups/scribe-20261015T020000Z.tar
+```
+
+- The command refuses a database that already has an account. A restore
+ re-creates everything with new ids, so on a live instance it would add a
+ second copy instead of replacing anything.
+- Nothing limits the file's size.
+- It also reads the JSON from the admin **Full Backup** download.
+
+Afterwards, **restart Scribe** so the search indexes are rebuilt. Then issue
+new API keys, add forge connections again, and set up any sharing.
+
+### From the web
+
+**Settings → Data → Restore from Backup** takes the `.tar` as well as the
+JSON download. The upload is held to the server's request-size limit. For a
+bigger file, use the command above.
diff --git a/docs/features.md b/docs/features.md
index 892ede30..0ef84740 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -127,8 +127,12 @@ Every read and mutation is scoped by owner + direct shares + group shares.
- **Personal export** — download all your notes/tasks as a Markdown ZIP (YAML
frontmatter) or a JSON array.
-- **Admin backup** — full application backup/restore (format v4) with ID remapping on
+- **Admin backup** — full application backup/restore with ID remapping on
restore for cross-instance migration.
+- **Scheduled backups** — a daily, dated, uncompressed `.tar` to a folder or an
+ S3-compatible bucket, laid out so a deduplicating tool such as restic stores
+ only what changed; `python -m scribe.restore` brings a fresh instance back
+ from one. See [Backups](backups.md).
## Progressive Web App
@@ -148,7 +152,7 @@ reset, and session invalidation are included. See [SSO / OAuth](sso-oauth.md).
| Account | Email change, password change, session invalidation |
| Profile | Per-user profile fields |
| Integrations | SearXNG status |
-| Data | Personal export; backup / restore (admin) |
+| Data | Personal export; backup / restore and scheduled backups (admin) |
| API Keys | Create/revoke `fmcp_` keys for the MCP endpoint |
| Config (admin) | Base URL, SMTP, DB-maintenance schedule |
| Users (admin) | User list, invite links, registration toggle |
diff --git a/src/scribe/restore.py b/src/scribe/restore.py
new file mode 100644
index 00000000..24edcfd3
--- /dev/null
+++ b/src/scribe/restore.py
@@ -0,0 +1,95 @@
+"""Restore a Scribe backup into an empty instance, from the command line.
+
+ python -m scribe.restore /backups/scribe-20261015T020000Z.tar
+
+This is how a fresh instance is brought back after losing the old one. Run it
+inside the Scribe container, which already has the database settings and has
+already migrated the schema on start. It reads either the scheduled backup's
+tar (services/backup_file.py) or the JSON the admin download produces.
+
+It refuses a database that already holds an account. A restore re-creates
+every user, project and record from the file with new ids; into a live
+instance it would add a second copy of everything rather than replace it.
+(Settings → Backup & Restore still offers the upload for that case, on
+purpose and with its own warning.)
+
+Unlike the upload, nothing limits the file's size here, so this is also the
+way in for a backup too big for the web upload.
+
+Afterwards, restart Scribe: search indexes are not part of a backup and are
+rebuilt from the restored records at startup.
+"""
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import sys
+from pathlib import Path
+
+from sqlalchemy import func, select
+
+
+def load_backup(path: Path) -> dict:
+ """The payload in `path`, whichever of the two backup formats it is."""
+ from scribe.services.backup_file import is_archive, read_archive
+
+ with open(path, "rb") as f:
+ if is_archive(f):
+ return read_archive(f)
+ return json.load(f)
+
+
+async def _account_count() -> int:
+ from scribe.models import async_session
+ from scribe.models.user import User
+
+ async with async_session() as session:
+ return (await session.execute(select(func.count()).select_from(User))).scalar_one()
+
+
+async def run(path: Path) -> int:
+ from scribe.models.engine import engine
+ from scribe.services.backup import restore_full_backup
+ from scribe.services.backup_file import ArchiveError
+
+ try:
+ try:
+ payload = load_backup(path)
+ except FileNotFoundError:
+ print(f"There is no file at {path}.", file=sys.stderr)
+ return 2
+ except (ArchiveError, ValueError) as exc:
+ print(f"{path} isn't a backup this Scribe can read: {exc}", file=sys.stderr)
+ return 2
+
+ accounts = await _account_count()
+ if accounts:
+ print(
+ f"This database already holds {accounts} account(s). A restore adds a second "
+ "copy of everything rather than replacing it, so it only runs into an empty "
+ "instance. Start Scribe on an empty database and run this again.",
+ file=sys.stderr,
+ )
+ return 3
+
+ stats = await restore_full_backup(payload)
+ print(json.dumps({"restored": stats}, indent=2, default=str))
+ print("Restored. Restart Scribe so the search indexes are rebuilt.", file=sys.stderr)
+ return 0
+ finally:
+ await engine.dispose()
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ prog="python -m scribe.restore",
+ description="Restore a Scribe backup (.tar or .json) into an empty instance.",
+ )
+ parser.add_argument("file", type=Path, help="the backup file")
+ args = parser.parse_args(argv)
+ return asyncio.run(run(args.file))
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/scribe/routes/admin.py b/src/scribe/routes/admin.py
index 611754cb..43e98641 100644
--- a/src/scribe/routes/admin.py
+++ b/src/scribe/routes/admin.py
@@ -59,7 +59,21 @@ async def backup():
@admin_bp.route("/restore", methods=["POST"])
@admin_required
async def restore():
- data = await request.get_json()
+ """Restore from an uploaded backup: the JSON download (application/json)
+ or the scheduled backup's tar (sent as the raw body). The upload is held
+ to the app's request-size limit; `python -m scribe.restore` has none, and
+ is the way in for a bigger file or a fresh instance."""
+ if request.mimetype == "application/json":
+ data = await request.get_json()
+ else:
+ import io
+
+ from scribe.services.backup_file import ArchiveError, read_archive
+
+ try:
+ data = read_archive(io.BytesIO(await request.get_data()))
+ except ArchiveError as exc:
+ return jsonify({"error": f"That file isn't a Scribe backup: {exc}"}), 400
if not data:
return jsonify({"error": "No backup data provided"}), 400
diff --git a/tests/test_integration_backup_file_restore.py b/tests/test_integration_backup_file_restore.py
new file mode 100644
index 00000000..913ba7ac
--- /dev/null
+++ b/tests/test_integration_backup_file_restore.py
@@ -0,0 +1,103 @@
+"""Real-Postgres proof that the scheduled backup's FILE restores (milestone
+523): a payload written as the tar, read back through `scribe.restore`'s
+loader, and fed to the real `restore_full_backup`.
+
+The unit lane already shows the tar hands back the dict it was given. What
+only this lane shows is that the dict it hands back is one the restore
+accepts — every id remapped to the restored rows, nothing dropped — and that
+the command-line restore refuses a database that already has accounts.
+"""
+import pytest
+import pytest_asyncio
+from sqlalchemy import select
+
+from scribe.models import async_session
+from scribe.models.milestone import Milestone
+from scribe.models.note import Note
+from scribe.models.project import Project
+from scribe.models.user import User
+from scribe.restore import load_backup, run
+from scribe.services import backup
+from scribe.services.backup_file import write_archive
+from tests.helpers import ensure_user
+
+pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
+
+OWNER = "backup_file_restore_owner"
+RESTORED = "backup_file_restore_restored"
+
+
+async def _purge(username: str) -> None:
+ async with async_session() as s:
+ for user in (await s.execute(select(User).where(User.username == username))).scalars().all():
+ for model in (Note, Milestone, Project):
+ for row in (await s.execute(
+ select(model).where(model.user_id == user.id)
+ )).scalars().all():
+ await s.delete(row)
+ await s.flush()
+ await s.delete(user)
+ await s.commit()
+
+
+@pytest_asyncio.fixture(autouse=True)
+async def _no_leftovers():
+ """Setup only — see the rule_version roundtrip module for why."""
+ await _purge(RESTORED)
+ await _purge(OWNER)
+
+
+@pytest_asyncio.fixture
+async def written(tmp_path):
+ """A project with a milestone and a task in it, exported with the real
+ row builders, renamed so the restore mints a separate user, and written
+ as the backup tar."""
+ async with async_session() as s:
+ user = await ensure_user(s, OWNER)
+ project = Project(user_id=user.id, title="Restore drill")
+ s.add(project)
+ await s.flush()
+ milestone = Milestone(user_id=user.id, project_id=project.id, title="Plan")
+ s.add(milestone)
+ await s.flush()
+ note = Note(user_id=user.id, project_id=project.id, milestone_id=milestone.id,
+ title="Prove the restore", body="From the tar, not the JSON.",
+ status="todo", note_type="note", task_kind="work")
+ s.add(note)
+ await s.commit()
+ payload = {
+ "version": backup.BACKUP_VERSION,
+ "scope": "full",
+ "users": backup._user_rows([user]),
+ "projects": backup._project_rows([project]),
+ "milestones": backup._milestone_rows([milestone]),
+ "notes": backup._note_rows([note]),
+ }
+ payload["users"][0]["username"] = RESTORED
+ from datetime import datetime, timezone
+ path = write_archive(payload, tmp_path, datetime.now(timezone.utc), app_version="test")
+ yield path
+ await _purge(RESTORED)
+ await _purge(OWNER)
+
+
+async def test_the_tar_restores_with_every_id_remapped(written):
+ await backup.restore_full_backup(load_backup(written))
+ async with async_session() as s:
+ user = (await s.execute(select(User).where(User.username == RESTORED))).scalars().one()
+ project = (await s.execute(select(Project).where(Project.user_id == user.id))).scalars().one()
+ milestone = (await s.execute(select(Milestone).where(Milestone.user_id == user.id))).scalars().one()
+ note = (await s.execute(select(Note).where(Note.user_id == user.id))).scalars().one()
+ assert project.title == "Restore drill"
+ assert (note.project_id, note.milestone_id) == (project.id, milestone.id)
+ assert note.body == "From the tar, not the JSON."
+ assert note.status == "todo"
+
+
+async def test_the_command_refuses_a_database_with_accounts(written):
+ """The source user exists, so this database is not empty: the command
+ must stop before restoring anything."""
+ assert await run(written) == 3
+ async with async_session() as s:
+ restored = (await s.execute(select(User).where(User.username == RESTORED))).scalars().all()
+ assert restored == []