A self-hosted, Splitwise-style shared expense splitter. Groups, multi-currency expenses (with a manual exchange rate), balances, settle-up suggestions, an activity feed, recurring expenses, Splitwise CSV import, and its own accounts — no external service required. The interface is in Spanish or English; the default is Spanish. Default currency is EUR.
It's a Nuxt 4 (Vue 3) app backed by a local SQLite database inside the container. Docker Compose persists the database file in a named volume.
- Groups with emoji, base currency, and members you can add or remove.
- Expenses in any of 19 currencies, converted with a manual exchange rate.
- Five ways to split: equally, by exact amount, by percentage, by share weight, or itemised (assign each line of a bill to whoever had it, with tax and tip). Expenses can be edited or deleted afterwards.
- Balances and settle-up suggestions — who should pay whom, with simplified group netting or direct pairwise debts (see below).
- Recurring expenses (weekly / monthly / yearly) with a start date.
- Splitwise CSV import — drop in an export file, match the members and categories, and the whole file is imported in one go.
- Activity feed per group, and an admin area for managing the shared category list and reviewing users and groups.
- Multi-language UI (es/en), dark-friendly styling, no external services.
- Passwords are hashed with
bcryptjs(cost 10). Hashing is asynchronous, and sign-in and sign-up are rate limited per IP and per account name. - Sessions are a 32-byte random token in an
httpOnly,SameSite=Laxcookie. The database storesHMAC-SHA256(SESSION_SECRET, token), not the token, so a database backup is not a set of usable session cookies. ChangingSESSION_SECRETlogs everyone out everywhere. - The server computes all the money. The browser sends which split was
chosen and who takes part; the server derives the amounts and rejects
anything that does not reconcile. Non-finite amounts (
Infinity,NaN) are refused outright — they would otherwise make every balance in a groupNaN. - Authorization is checked per request. Group roles are separate from the site-wide admin role. Members cannot leave or be removed while the selected settlement plan still involves them.
- Response headers include a Content-Security-Policy,
X-Frame-Options,nosniff, andReferrer-Policy. The framework banner is stripped. - The container runs as an unprivileged user, ships production dependencies only, and includes its SQLite runtime—there is no database service to configure or expose.
-
Copy the env template and fill it in:
cp .env.example .env
Edit
.envand setSESSION_SECRETto a random value.DATABASE_PATHis optional and defaults to/data/pachas.sqliteinside the container. -
From this folder, run:
docker compose up -d --build
This starts the app with its SQLite file in the persistent
pachas-datavolume. Schema migrations are applied automatically before the server starts. -
Open
http://<your-server>:3000and create the first account. The first account created becomes the admin — it gets the admin area for managing categories. Everyone else is a regular user. -
Once your household/group has accounts, set
ALLOW_REGISTRATION: "false"incompose.yamland re-rundocker compose up -d --buildso random visitors can't sign themselves up if the port is ever reachable from outside your LAN.
Set in .env (used by compose.yaml):
| Variable | Description |
|---|---|
DATABASE_PATH |
SQLite database path inside the container. Optional; defaults to /data/pachas.sqlite. Keep custom paths under /data, or mount the custom directory in compose.yaml, so the file persists across container replacement. |
SESSION_SECRET |
Secret used to key stored session digests. Required — the app refuses to start without it (see note below). Generate one with openssl rand -hex 32. |
Note on
SESSION_SECRET: your browser's session cookie holds a random 32-byte token. The database does not store that token — it storesHMAC-SHA256(SESSION_SECRET, token). So a database backup does not hand over usable session cookies; the digests are useless without the secret in your.env.Two consequences worth knowing:
- Sessions survive restarts and rebuilds, because they live in the database rather than in process memory.
- Changing
SESSION_SECRETlogs out every user on every device. There is no way to preserve sessions across a rotation, by design.
Set directly in compose.yaml under the pachas service:
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Port the app listens on inside the container. |
ALLOW_REGISTRATION |
true |
Set to false to close self-service sign-up. |
COOKIE_SECURE |
false |
Set to true only if serving over HTTPS (e.g. behind a reverse proxy doing TLS termination). Leave false for plain HTTP on your LAN — otherwise login cookies won't be sent and you won't be able to log in. |
Everything (users, groups, expenses, settlements, categories, and sessions)
lives in /data/pachas.sqlite in the pachas_pachas-data Docker volume. Stop
the app briefly before copying the file so SQLite can checkpoint its WAL:
docker compose stop pachas
docker cp pachas:/data/pachas.sqlite ./pachas-backup.sqlite
docker compose start pachasTo restore that backup:
docker compose stop pachas
docker cp ./pachas-backup.sqlite pachas:/data/pachas.sqlite
docker compose start pachasTo expose Pachas outside your LAN, put it behind a reverse proxy (Caddy,
Traefik, nginx, or your existing one) that terminates HTTPS, and
set COOKIE_SECURE: "true". Don't expose port 3000 directly to the internet
over plain HTTP — login credentials and session cookies would travel
unencrypted. Only the pachas service needs to be reachable.
docker compose pull # if you're pulling a pre-built image
docker compose up -d --buildYour data stays in the pachas_pachas-data volume across
rebuilds/updates. The app applies pending database migrations on every start,
so new columns/tables in future versions apply automatically — existing data is
never dropped.
Every green build on main publishes a multi-arch image (linux/amd64 and
linux/arm64) to ghcr.io/ivanbeke/pachas, tagged with the branch, the git
SHA, and latest for the default branch. To use it instead of building locally,
point the pachas service at the image:
pachas:
image: ghcr.io/ivanbeke/pachas:latestImages are signed keylessly with cosign, so you can check what you pulled:
cosign verify ghcr.io/ivanbeke/pachas@sha256:<digest>docker compose down # stop and remove the containers
docker compose down -v # also delete all stored data — irreversible- Backend:
server/api/— Nitro server routes. Sessions are token-based (anapp_sessionstable in SQLite, so they survive restarts), and the stored value is a keyed digest rather than the cookie itself; passwords are hashed withbcryptjsin the login and register routes. - The server does all the money maths. The browser is never trusted: the client sends which split was chosen and who takes part, and the server computes the actual amounts and rejects anything that doesn't add up. The same applies to settle-ups (the amount is derived from the real debt) and to the CSV import (the file is re-parsed on the server). The front end only computes a preview, using the same shared module so the two can't drift.
- Startup applies pending Drizzle migrations from the container entrypoint —
no manual migration step. Schema source of truth is
server/db/schema.ts; generate new migrations withnuxt db generate(seeAGENTS.md). - Frontend:
app/pages/+app/components/+app/composables/+app/utils/— Vue 3 SPA (no SSR), no separate build tooling beyond Nuxt/Vite. It polls the server every few seconds so everyone sees roughly-live balances. Each modal owns its form state locally, so background refreshes never wipe what you're typing. - Settle-up suggestions: simplified mode nets the group and suggests debtor-to-creditor transfers that can be allocated along outstanding debt paths, so intermediaries can be netted out. Pairwise mode instead suggests each outstanding debt between the people connected by expense shares. Recorded settlements retain the actual sender/recipient and persist which underlying pairwise debts the payment clears. Members involved in the selected plan cannot leave or be removed until those suggested payments are settled; switching to pairwise mode is blocked if it would involve former members.
- CSV import: a Splitwise export is a file where each member column holds that person's net balance for the row (the payer's own share is already deducted). The importer derives each member's share from the negative balances and gives the payer the remainder, so an equal two-person split lands as two equal shares rather than one person owing everything.
Contributors should read AGENTS.md first — it documents the
project conventions, the testing setup, and the traps that have bitten before.
In short: everything runs through Docker, verification goes through Vitest,
and CI runs both on every push.
pnpm test # unit + property tests (no server or database needed)
pnpm typecheck # vue-tsc, must stay at 0 errorsBoth run through Docker so the host stays clean — the dev service is the pnpm
image with the source bind-mounted and node_modules in a named volume:
docker compose run --rm --no-deps dev pnpm test
docker compose run --rm --no-deps dev pnpm typecheckThe contract tests reach the running app over the compose network. They create
their own throwaway users and group, and skip themselves if no server is
reachable — so pnpm test stays green in CI, where there is no database. To run
just those:
PACHAS_API=http://pachas:3000 docker compose run --rm --no-deps -e PACHAS_API dev pnpm test:apiSome API contract fixtures and throwaway accounts are intentionally retained. Clean up reserved test data afterwards with:
docker compose exec pachas node scripts/cleanup-test-data.mjsCI (.github/workflows/ci.yml) runs install → nuxt prepare → tests →
typecheck on every push and pull request.