DEV Community

John
John

Posted on

How to Back Up Self-Hosted Spliit: PostgreSQL Dumps, S3 Receipts and a Docker Restore That Loses Nothing

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_dump written 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?

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 -Fc is 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 .env file and compose file: DATABASE_URL, NEXT_PUBLIC_BASE_URL, your S3_* 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 as spliit_postgres_data, and it holds the Prisma managed tables for groups, participants, expenses, paid-for splits, categories and recurring expenses.
  • The _prisma_migrations table: 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_dump output 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
Enter fullscreen mode Exit fullscreen mode
  • Use the container's own pg_dump: running the client from inside the image guarantees the client and server versions match, which matters because pg_dump from PostgreSQL 15 refuses to dump a PostgreSQL 16 server.
  • Prefer -Fc over plain SQL: the custom format is compressed by default and lets pg_restore -j 4 rebuild in parallel and restore selected tables, where a plain .sql file only replays top to bottom.
  • Pass -T to docker 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_USER and POSTGRES_DB, and docker compose exec postgres env | grep POSTGRES confirms them in one line.
  • Verify every dump immediately: pg_restore -l spliit-2026-09-26-0300.dump | wc -l lists 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
Enter fullscreen mode Exit fullscreen mode
  • Pull the credentials from your existing configuration: the S3_UPLOAD_KEY, S3_UPLOAD_SECRET, S3_UPLOAD_BUCKET, S3_UPLOAD_REGION and endpoint values already in your Spliit environment are exactly what the rclone remote needs, so create a read only key if your provider supports one.
  • Use sync with --checksum, not copy with timestamps: object storage timestamps change on rewrite, and checksum comparison avoids both re-uploading unchanged images and skipping a changed one.
  • Never let sync run 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.dump and 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_PASSWORD and POSTGRES_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.yml and the image tags it pins: record the exact tag you run, for example postgres:16-alpine alongside your Spliit tag, since latest on 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
Enter fullscreen mode Exit fullscreen mode

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 {} +
Enter fullscreen mode Exit fullscreen mode
  • set -euo pipefail is 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 -l check runs before the find deletion, 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>&1 puts 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.yml and .env in /opt/spliit with the same image tags you recorded, not latest.
  • Start PostgreSQL alone: docker compose up -d postgres creates an empty database from POSTGRES_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.dump drops 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 -d lets Spliit apply any pending Prisma migrations against real data, and docker compose logs -f shows 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_migrations and 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.
  • latest drift on the host: if your compose file says latest, 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_restore into 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 with docker 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, point NEXT_PUBLIC_BASE_URL at http://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 logs shows 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 -v removes the drill volumes, and the -v matters 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 .dump file 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 age or restic, 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 600 on .env and chmod 700 on /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:

  1. Run one pg_dump -Fc by hand and verify it with pg_restore -l.
  2. Copy it off the host.
  3. Add the cron entry.

If you already dump the database:

  1. Add the bucket sync.
  2. Add the encrypted config bundle.
  3. Set retention to 30 days or more.

If you have all three:

  1. Book a 20 minute restore drill this quarter.
  2. Record the elapsed time.
  3. Export your group URLs and store them with the dumps.

Top comments (0)