#5918 "Backup file": an uncompressed tar of per-table JSON Lines (primary-key order, sorted keys) plus every attachment file, with fixed tar headers, a dated name, and an atomic write through a dot-named .part file. Unchanged rows are the same bytes every day, so restic off-site stores only the edits. No credential ever enters a backup. #5919 "Backup destinations": a folder, or an S3-compatible bucket through a stdlib SigV4 put/list client (checked against AWS's published signatures), with an upload limit. Test destination only lists, so an append-only key works. #5920 "Backup job": daily at a chosen hour, Back up now, a 1/2/4 h retry ladder, a one-hour deadline, an advisory lock, the last 30 runs, and an overdue verdict on Settings -> Backups. #5921 "Restore": python -m inkwell.restore FILE into an empty instance, an integration test that backs up and restores, and docs/backups.md with Fabled Archives as the worked example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5.0 KiB
Backups
Inkwell can write a backup of everything once a day: every account, note, tag, share, group, invite and setting, and every attachment file. Each backup goes to a folder or to an S3-compatible bucket, and is shaped so that a deduplicating off-site copy (restic) stores only what changed.
Set it up in Settings → Backups.
What a backup is
One file per run, named for the moment it was taken: inkwell-20261016T020000Z.tar.
It is an ordinary, uncompressed tar:
manifest.json when, which version, how many rows and files
tables/<table>.jsonl one row per line, in primary-key order
files/<path> every attachment, byte for byte
The layout is chosen for restic. restic splits a file into chunks at points chosen by its content, so a chunk it has already stored costs nothing to store again. To make the most of that:
- Nothing is compressed. Compression changes every byte after the first change, and nothing would match from one day to the next. restic compresses its own repository after it deduplicates.
- An unchanged row is the same bytes every day. Rows are written in primary-key
order with sorted keys. Editing a note changes one line of
tables/notes.jsonl. - Attachments never change, so they deduplicate run after run.
- Only
manifest.jsonchanges every run. It holds the time and the counts; the tar headers are fixed.
In practice a day's new backup adds roughly the size of that day's edits and uploads to the off-site repository, not the size of the whole file.
A backup never holds a credential: not the SMTP password, not the bucket's secret key, and not the server's own signing key. A copy kept off-site cannot be used to reach the server or the bucket it came from. One-hour password reset links are left out too.
Where it goes
Inkwell only ever adds a file. It never deletes or replaces one. Keeping a history and thinning it (keep 7 daily, 4 weekly, 12 monthly, say) is the destination's job.
A folder
Mount a share into the container and give its path in Settings:
services:
app:
volumes:
- inkwell-data:/var/inkwell
- /mnt/archives/inkwell:/backups # add this line
The file is written as .inkwell-….tar.part and renamed when it is complete, so a
service that watches the folder and skips names starting with a dot never picks up
half a backup.
An S3-compatible bucket
Any S3 server that accepts path-style addresses: Fabled Archives, MinIO, Garage,
Backblaze B2 and AWS itself. Give the address with its port, the bucket, the
region (us-east-1 suits most self-hosted servers) and a key.
Inkwell only puts and lists, so a key that may only add objects is enough. It is also the safer key: a stolen copy cannot delete the history it writes into. Test destination only lists the bucket, so it works with such a key.
Uploads are capped at 10 MB/s by default (Upload limit), so a large backup never crowds the network. Set it to 0 for no limit.
Fabled Archives
This is the worked example. Make a backup target in Archives with retention set to Archives (one file per backup, thinned like restic's keep-daily/weekly/monthly) and either:
- S3: set the destination to the bucket, using Archives' S3 address including the
port (for example
http://192.168.0.10:8081). Without the port the request lands on port 80, and the answer is a plain 404, shown as "nothing answered as S3 at that address; check the port". Give Inkwell an append-only key. - Folder: mount the target's folder into the container (above) and point the folder setting at it.
Archives then copies the target off-site with restic, and the shape above is what lets that copy deduplicate.
When it runs
Once a day at the hour you choose (UTC on the server; the page shows the next run in your own time). A backup missed while the server was down runs as soon as it is back. Switching backups on runs the first one within a few minutes, which is also the quickest check that the destination works.
A failed run is tried again after an hour, then two, then four, and then waits for the next day. The page says when backups started failing and why. Every run has a one-hour deadline, and the page keeps the last 30.
Back up now makes one straight away.
Restoring
Restore into a new, empty Inkwell:
-
Start the new instance once, so its database is created. Don't sign up.
-
Put the backup file where the container can read it, then run:
docker compose exec app python -m inkwell.restore /backups/inkwell-20261016T020000Z.tar -
Sign in with an account from the backup.
-
In Settings, enter the SMTP password and the bucket's secret key again. They are never in a backup.
The restore refuses a database that already has accounts, because loading one instance's notes over another's would mix the two with no way to separate them.
Everyone signs in again after a restore, because the signing key is not in the backup. Linked desktop and Android apps keep their links, and should be re-synced.