A complete Spliit backup is three things, not one: a compressed pg_dump of the PostgreSQL database, a copy of the S3 or MinIO bucket holding receipt images, and the .env file that defines your base URL and storage credentials. Capture all three on the same schedule, keep one copy off the machine that runs the containers, and restore them in that order onto a Spliit image of the same minor version. Everything else in the stack, the Next.js application container included, is disposable and rebuilt from your compose file in minutes. The piece almost everyone forgets is the index of your group links, which lives in your browser's local storage and not in the database at all.
TL;DR by reader profile:
-
The two person household (a couple splitting rent, groceries and one streaming bill): a nightly
pg_dumpwritten to a second disk covers you, because the database is measured in kilobytes and you use no receipt uploads. - The flatshare treasurer with five people and two years of history: add dated dumps with a retention window of roughly 30 files, because a wrong split or a deleted expense can go unnoticed for weeks and yesterday's copy will not help.
- The trip organiser who scans every receipt: back up the bucket before you worry about anything else, because image blobs are the only part of Spliit nobody can retype from memory.
- The privacy-conscious user with no sysadmin background: reduce the whole job to one scripted command and one calendar reminder to rehearse the restore, because an untested backup is a guess rather than a plan.
- The homelab owner already running PostgreSQL for other apps: dump Spliit's database on its own rather than taking a cluster wide dump, because a single database dump restores without disturbing your other services.
The central tradeoff: logical dumps are portable and survive PostgreSQL and Spliit upgrades but need a script and a schedule, while volume snapshots are trivial to take and brittle to restore once versions drift.
Table of contents
- What do you actually need to back up in a self-hosted Spliit stack?
- Where Spliit keeps your data: PostgreSQL rows, S3 receipt images and browser local storage
- How do you dump the Spliit PostgreSQL database without stopping the container?
- pg_dump versus a Docker volume snapshot: which one restores cleanly?
- Backing up the S3 or MinIO bucket that holds your Spliit receipts
- Which secrets and environment variables belong in the backup?
- A nightly Spliit backup script you can drive from cron
- How much storage do three years of Spliit backups really need?
- How do you restore Spliit onto a new host, step by step?
- What breaks during a restore: Prisma migrations, image drift and group URLs
- How do you rehearse a Spliit restore without touching the live instance?
- Where should you run Spliit, and where should the backups land?
- Privacy and data sovereignty: what a Spliit backup reveals about your household
- Which Spliit backup plan fits you?
What do you actually need to back up in a self-hosted Spliit stack?
Four things, and only one of them is the database. A default Spliit deployment is a Next.js container talking to PostgreSQL over DATABASE_URL, with optional S3 compatible storage for expense document images. Strip away what Docker can rebuild and this is your real backup surface.
-
The PostgreSQL database: every group, participant, expense, split, category and recurring entry lives here, so a logical dump taken with
pg_dump -Fcis the single most important file you produce. - The S3 or MinIO bucket: uploaded receipt images are stored as objects outside the database, which means a perfect dump restored against an empty bucket gives you expenses whose attachments load as broken links.
-
The
.envfile and compose file:DATABASE_URL,NEXT_PUBLIC_BASE_URL, yourS3_*credentials and any OpenAI key for receipt scanning are configuration, not data, and losing them turns a 10 minute restore into an afternoon of guesswork. - The group URLs themselves: Spliit identifies each group by a random id in its address, and your browser keeps the list of groups you have visited in local storage, so export or write down those links before you need them.
What you do not back up is equally clear: the application image, node_modules, the build cache and the container filesystem are all reproducible from docker compose up -d.
Where the stack runs changes none of this. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user. A self-managed VPS, a NAS with Container Manager, a spare mini PC at home and Yundera all present the same four items to protect.
Where Spliit keeps your data: PostgreSQL rows, S3 receipt images and browser local storage
Three storage locations, three different failure modes. Knowing which is which tells you why a single copy of one of them is never a backup.
-
The PostgreSQL data directory: inside the database container this is
/var/lib/postgresql/data, normally mapped to a named Docker volume such asspliit_postgres_data, and it holds the Prisma managed tables for groups, participants, expenses, paid-for splits, categories and recurring expenses. -
The
_prisma_migrationstable: this sits in the same database and records which migrations have already run, so it is the row set that decides whether a restored dump boots cleanly against a given Spliit image or fails on startup. -
The S3 compatible bucket: receipt and document uploads go to object storage, and the database keeps only a pointer row per attachment with its URL and dimensions, so the bytes never appear in
pg_dumpoutput at all. - Your browser's local storage: Spliit addresses each group by a random identifier in its URL and remembers the groups you have opened on the device you opened them from, which means clearing site data on one laptop can hide groups that still exist perfectly well in the database.
Two practical consequences follow. Restoring a dump onto a fresh bucket produces expenses with unreachable images, and restoring a bucket without the dump produces orphaned objects nothing references. Check both locations on whatever you run, whether that is a VPS, a NAS, a mini PC at home or Yundera, before you trust a schedule you have not tested.
How do you dump the Spliit PostgreSQL database without stopping the container?
You do not need downtime. PostgreSQL dumps run inside a transaction with a consistent snapshot, so pg_dump against a live Spliit instance produces a coherent file even while someone is adding an expense.
The command runs in the database container, not on the host:
docker compose exec -T postgres pg_dump -U spliit -d spliit -Fc \
> spliit-$(date +%F-%H%M).dump
-
Use the container's own
pg_dump: running the client from inside the image guarantees the client and server versions match, which matters becausepg_dumpfrom PostgreSQL 15 refuses to dump a PostgreSQL 16 server. -
Prefer
-Fcover plain SQL: the custom format is compressed by default and letspg_restore -j 4rebuild in parallel and restore selected tables, where a plain.sqlfile only replays top to bottom. -
Pass
-Ttodocker compose exec: without it Docker allocates a pseudo TTY and injects carriage returns into the redirected stream, which corrupts the archive in a way that only shows up when you try to restore it. -
Read the credentials from your compose file, not from memory: the user and database name come from
POSTGRES_USERandPOSTGRES_DB, anddocker compose exec postgres env | grep POSTGRESconfirms them in one line. -
Verify every dump immediately:
pg_restore -l spliit-2026-09-26-0300.dump | wc -llists the archive table of contents, and a file that produces no listing is a failed backup you still have time to retake.
For a household sized instance the whole operation finishes in under 5 seconds.
pg_dump versus a Docker volume snapshot: which one restores cleanly?
Both approaches work. They fail differently, and the difference only appears on the day you restore.
| Aspect |
pg_dump -Fc logical dump |
Copy or snapshot of the Postgres volume |
|---|---|---|
| Taken while running | Safe, uses a consistent transaction snapshot | Unsafe unless the container is stopped or the filesystem does atomic snapshots |
| Cross version restore | Restores into PostgreSQL 16 or 17 without touching the data directory | Data directory is tied to one major version, so 16 files will not start under 17 |
| Granularity |
pg_restore -t expenses recovers one table |
All or nothing, the whole cluster comes back together |
| Typical size | Compressed, a household instance stays in the low megabytes | Includes WAL, free space and indexes, so several times larger |
| Downtime to take | None | Around 10 to 30 seconds of docker compose stop postgres
|
| Repair options | Text inspectable with pg_restore -f - before loading |
Opaque, a torn copy usually shows as a refusal to start |
The honest case for snapshots is speed of recovery. If your host dies, restoring a ZFS or Btrfs snapshot brings Spliit back with the bucket and the compose file in one action, and you skip the ordering problems entirely.
The case for dumps is that they survive change. Spliit and PostgreSQL both move, and a dump taken today still loads next year against a newer image.
Run both if the disk allows: a nightly pg_dump for portability, plus a weekly cold volume copy taken with the stack stopped for a genuine disaster.
Backing up the S3 or MinIO bucket that holds your Spliit receipts
Receipt images are the part of Spliit nobody can reconstruct. An expense can be retyped from a bank statement in 30 seconds. A photo of a till receipt from last March is gone for good.
The tool of choice is rclone, which speaks the same S3 API whether your bucket lives in MinIO on the same host, in Backblaze B2, in Wasabi or in AWS S3:
rclone sync spliit-s3:my-spliit-bucket /backups/spliit-bucket \
--checksum --transfers 8
-
Pull the credentials from your existing configuration: the
S3_UPLOAD_KEY,S3_UPLOAD_SECRET,S3_UPLOAD_BUCKET,S3_UPLOAD_REGIONand endpoint values already in your Spliit environment are exactly what thercloneremote needs, so create a read only key if your provider supports one. -
Use
syncwith--checksum, notcopywith timestamps: object storage timestamps change on rewrite, and checksum comparison avoids both re-uploading unchanged images and skipping a changed one. -
Never let
syncrun towards the live bucket: it deletes on the destination, so a reversed argument order replaces your receipts with whatever the backup directory happens to contain. - Back up MinIO as objects, not as its disk: if you self-host MinIO, copy through the API rather than tarring its data directory, because the on-disk layout carries per-version metadata you do not want to depend on.
-
Keep the bucket copy with the matching dump: store
spliit-2026-09-26-0300.dumpand that night's bucket sync under the same dated folder, so the pointer rows and the objects they reference always line up.
Which secrets and environment variables belong in the backup?
Configuration is small, and it is the difference between a restore that takes 10 minutes and one that takes a weekend of trial and error. The whole set fits in a single encrypted file.
-
NEXT_PUBLIC_BASE_URL: this is baked into links Spliit generates and shares, so restoring under a different value changes every group URL you have sent to other people and quietly breaks their bookmarks. -
POSTGRES_USER,POSTGRES_PASSWORDandPOSTGRES_DB: the dump contains data, not credentials, and a restore into a database whose role name differs from the one recorded in the archive produces ownership errors on every table. -
The
S3_*block and its endpoint: without the exact bucket name and region the pointer rows in the database resolve to nothing, and a regenerated access key that lacks read permission looks identical to lost images. -
OPENAI_API_KEY, if you use receipt scanning: this one is best rotated rather than restored, because a key sitting in an old backup copy is a credential you no longer control. -
docker-compose.ymland the image tags it pins: record the exact tag you run, for examplepostgres:16-alpinealongside your Spliit tag, sincelateston restore day is not the image your dump came from.
Encrypt the bundle rather than storing it in plain text. One command covers it:
tar czf - .env docker-compose.yml | age -r <your-public-key> > spliit-config.age
Keep that output in a different place from the dumps, and keep the decryption key somewhere that survives the server, such as a password manager entry or a printed recovery phrase.
A nightly Spliit backup script you can drive from cron
One file, five commands, no orchestration framework. Save this as /usr/local/bin/spliit-backup.sh and make it executable with chmod 750.
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%F-%H%M)
DEST=/backups/spliit/$STAMP
mkdir -p "$DEST"
cd /opt/spliit
docker compose exec -T postgres pg_dump -U spliit -d spliit -Fc > "$DEST/db.dump"
rclone sync spliit-s3:my-spliit-bucket "$DEST/bucket" --checksum
cp .env docker-compose.yml "$DEST/"
pg_restore -l "$DEST/db.dump" > /dev/null
find /backups/spliit -maxdepth 1 -type d -mtime +30 -exec rm -rf {} +
-
set -euo pipefailis the most important line: without it a failed dump still leaves a zero byte file behind, the script exits 0, and your monitoring reports success for weeks. - One dated directory per run: keeping the dump, the bucket copy and the configuration together means a restore never involves matching files by guesswork.
-
Verify before pruning: the
pg_restore -lcheck runs before thefinddeletion, so a broken new backup never triggers the removal of a good old one. - Retention of 30 days is a starting point: raise it to 90 if you settle debts quarterly, since the window that matters is how long a wrong edit can go unnoticed.
-
Schedule it when nobody is entering expenses:
15 3 * * * /usr/local/bin/spliit-backup.sh >> /var/log/spliit-backup.log 2>&1puts it at 03:15 and keeps the output where you can read it.
Add a copy step to a second location afterwards, for example restic backup /backups/spliit, because a backup on the same disk as the database survives almost nothing.
How much storage do three years of Spliit backups really need?
Measure your own instance rather than trusting a rule of thumb. Three commands give you every input you need, and the arithmetic afterwards is trivial.
| Component | Measure it today | How it grows over 3 years |
|---|---|---|
| Database dump | docker compose exec -T postgres psql -U spliit -d spliit -c "SELECT pg_size_pretty(pg_database_size('spliit'));" |
Roughly linear in expense count, text rows only, stays modest for household use |
| Bucket objects | rclone size spliit-s3:my-spliit-bucket |
Dominates everything else, driven by how many receipts you photograph and at what resolution |
| Config bundle | du -h spliit-config.age |
Effectively flat, it changes only when you edit .env
|
| Cold volume copy | docker run --rm -v spliit_postgres_data:/v alpine du -sh /v |
Larger than the dump because of WAL and index bloat, and each copy is full size |
Two multipliers decide the total. Dumps multiply by your retention count, so 30 dated copies cost 30 times one dump unless you pipe them into a deduplicating tool such as restic or borg, where near identical dumps collapse to a fraction of that. Bucket copies do not multiply if you sync into one directory, because yesterday's images are not rewritten.
The practical result for most self-hosted Spliit instances is that the text data is a rounding error and the receipt images are the entire storage budget. If you want a hard ceiling, resize uploads before you attach them and check rclone size once a quarter. Set your off-site quota from that number, not from the dump.
How do you restore Spliit onto a new host, step by step?
Order matters. Bring up the database first, load data, then start the application so its migration check runs against a populated schema.
-
Recreate the stack skeleton: install Docker, copy the dated backup directory to the new machine, and place
docker-compose.ymland.envin/opt/spliitwith the same image tags you recorded, notlatest. -
Start PostgreSQL alone:
docker compose up -d postgrescreates an empty database fromPOSTGRES_DB, and waiting about 10 seconds for it to accept connections avoids a restore that fails on the first table. -
Load the dump into that empty database:
docker compose exec -T postgres pg_restore -U spliit -d spliit --no-owner --clean --if-exists < db.dumpdrops any conflicting objects first and ignores ownership differences between hosts. -
Push the bucket back before the app starts: reverse the sync direction with
rclone sync /backups/spliit/<stamp>/bucket spliit-s3:my-spliit-bucket --checksum, so every pointer row has an object waiting behind it. -
Start the application container last:
docker compose up -dlets Spliit apply any pending Prisma migrations against real data, anddocker compose logs -fshows within 30 seconds whether that succeeded or errored. - Verify with a known group URL: open one group you remember, confirm the balance total and open one receipt image, which tests the database and the bucket in a single click.
The target can be a self-managed VPS, a NAS, a spare mini PC or Yundera, where apps are installed from an app store in one click rather than assembled from compose files by hand. Whichever you choose, keep NEXT_PUBLIC_BASE_URL pointing at the same public hostname.
What breaks during a restore: Prisma migrations, image drift and group URLs
Failed Spliit restores almost never involve lost rows. They involve a schema and an application that disagree about which year it is.
-
Restoring an old dump into a newer image: Prisma compares your data against
_prisma_migrationsand applies what is missing, which usually works, but a migration that transforms data can only run in the direction it was written, so jumping several releases at once is riskier than stepping through them. - Restoring a new dump into an older image: this is the failure with no clean recovery, because the schema already contains columns the older code never learned about and Prisma has no downgrade path. Always record the image tag next to the dump.
-
latestdrift on the host: if your compose file sayslatest, the image pulled on restore day can be months newer than the one that produced the dump, which turns a restore into an unplanned upgrade at the worst possible moment. -
PostgreSQL major version mismatch: the dump itself is portable, but confirm the new server is equal or newer, since
pg_restoreinto PostgreSQL 15 from a PostgreSQL 16 archive can reject syntax the older server does not parse. -
A changed base URL: every group link you shared carries the hostname from
NEXT_PUBLIC_BASE_URL, so moving from one domain to another leaves other participants with dead bookmarks even though the group identifier is unchanged. -
The group list nobody backed up: the database is complete, but without the URLs you have no menu, so export them once with
docker compose exec -T postgres psql -U spliit -d spliit -c "SELECT id, name FROM \"Group\";"and store the output alongside your dumps.
How do you rehearse a Spliit restore without touching the live instance?
Run the drill in a second compose project on the same machine. It costs one spare port, a scratch directory and about 15 minutes, and it is the only thing that converts a backup into a plan.
-
Isolate with a project name: copy the backup directory to
/opt/spliit-drill, then run every command withdocker compose -p spliit-drill, which creates separate containers and separate volumes so nothing can collide with production. -
Change three values in the copied
.env: map the web container to an unused port such as 3001, pointNEXT_PUBLIC_BASE_URLathttp://localhost:3001, and set the bucket to a throwaway target, because a drill that writes into the real bucket is not a drill. - Restore from the archive you actually stored: use the most recent nightly file rather than a fresh dump, since the whole point is to test the file your cron job produced, not the command you typed by hand.
-
Check four things in the running copy: one group opens, its balance total matches production, one receipt image loads from the scratch bucket, and
docker compose -p spliit-drill logsshows no migration error. - Write down the elapsed time: the number you record becomes your realistic recovery estimate, and knowing it is 20 minutes rather than an unknown afternoon changes how you react during a real outage.
-
Tear it down completely:
docker compose -p spliit-drill down -vremoves the drill volumes, and the-vmatters because a forgotten copy of household finances is an avoidable data exposure.
Repeat the exercise once a quarter, and always after you change the Spliit image tag or the PostgreSQL major version.
Where should you run Spliit, and where should the backups land?
These are two separate decisions, and the second one matters more. A self-managed VPS, a NAS running Container Manager, a mini PC at home and Yundera all run the same stack, where each app is reachable on a public HTTPS subdomain via NSL.SH mesh routing so no static IP or port forwarding is required in that last case. What changes your risk profile is how far the backup copy sits from the running container.
| Backup destination | Protects against | Does not protect against |
|---|---|---|
| Second disk in the same host | A failed data disk, a bad pg_restore, a deleted group |
Theft, fire, a ransomware event that reaches every mount |
| NAS on the same LAN | Total host loss, a reinstalled operating system | Anything affecting the building, including a power surge |
| Object storage off-site, such as Backblaze B2 or Wasabi | Site loss, hardware theft, accidental docker volume prune
|
Credential compromise, unless the key is write limited |
| A second machine you control elsewhere | Site loss plus provider account lockout | Neglect, since nobody checks a target they never open |
| Encrypted USB disk rotated by hand | Everything online, because it is offline most of the time | Human forgetfulness, the copy is only as fresh as your last swap |
The workable minimum for household finances is two destinations from different rows: one nearby for fast recovery, one off-site for the bad day. Point restic at the off-site target, keep the local dated directories for quick access, and confirm both once a month with restic snapshots and a directory listing. A backup you have never listed is a backup you do not have.
Privacy and data sovereignty: what a Spliit backup reveals about your household
A Spliit dump is a financial diary. It names every participant, dates every purchase, and the receipt images often show a shop, a street, a card's last four digits and a time you were somewhere. That is more sensitive than the app feels while you use it, and it is why the destination of the copy matters as much as its existence.
Advantages of keeping the backup under your own control:
- No third party index: the only systems that can read your expense history are ones you chose, so nobody profiles your spending as a side effect of storing it.
- You set retention, not a vendor: deleting a group deletes it from your dumps too, once your 30 day window rolls over.
- Jurisdiction is a choice: picking a storage region puts the data under a legal regime you selected rather than one attached to a product.
-
Portability by default: a
.dumpfile plus a bucket directory is a complete export, with no API rate limit between you and your own records.
Checklist before you call the backup private:
-
Encrypt at rest: wrap every copy with
ageorrestic, which encrypts client side, so the storage provider holds bytes it cannot read. - Restrict the upload key: give the off-site key append and write permission only, so a compromised host cannot delete history.
- Store the passphrase off the server: a key living next to the encrypted archive protects nothing.
-
Check file permissions:
chmod 600on.envandchmod 700on/backups/spliit, because a world readable dump on a shared box is the quiet failure. - Delete drill copies: scratch restores contain the same data and deserve the same handling.
Which Spliit backup plan fits you?
Match the effort to what you would actually lose. Six profiles cover almost every self-hosted Spliit instance.
| Profile | Recommendation | Main reason |
|---|---|---|
| Couple, one group, no receipts | Weekly pg_dump to a second disk |
Data volume is tiny and retyping a month of expenses is plausible |
| Flatshare treasurer, 5 people | Nightly dump, 30 dated copies, one off-site target | A wrong split surfaces weeks later, so depth beats frequency |
| Trip organiser with receipt uploads | Nightly dump plus rclone sync of the bucket |
Images are the only unreproducible data in the stack |
| No sysadmin background | One cron script plus restic to object storage |
Fewer moving parts means the schedule survives your attention |
| Homelab with existing backup stack | Add Spliit to the current restic or borg job |
Deduplication makes 90 daily dumps cheap |
| Shared instance for several groups | Nightly dump, quarterly restore drill, exported group list | Other people depend on links you cannot regenerate |
| Running receipt scanning with an API key | Everything above, plus key rotation on restore | A restored key is a credential with unknown exposure |
Next steps:
If you have no backup at all today:
- Run one
pg_dump -Fcby hand and verify it withpg_restore -l. - Copy it off the host.
- Add the cron entry.
If you already dump the database:
- Add the bucket sync.
- Add the encrypted config bundle.
- Set retention to 30 days or more.
If you have all three:
- Book a 20 minute restore drill this quarter.
- Record the elapsed time.
- Export your group URLs and store them with the dumps.
Top comments (0)