Shared finances, without the noise. SplitDuo is a self-hosted expense splitting app for small groups — couples, housemates, travel companions, or anyone sharing costs. No subscription, no third-party servers, no data you don't control.
A focused alternative to Splitwise and Cospend, deployable in one docker compose up.
Expense tracking that stays out of your way Add expenses, pick a split, move on. SplitDuo handles the arithmetic — proportional splits, unequal amounts, custom breakdowns — and keeps a live balance so everyone always knows where they stand.
Settlement-aware balances Rather than a raw transaction log, SplitDuo computes net balances across the whole group and surfaces clear settlement suggestions — minimizing the number of transfers needed to settle up.
Alias mode for subgroup splitting For groups where members act as sub-units (a couple sharing one slot, a household treated as one), turn on alias mode at creation. Members are grouped into named aliases and expenses split by subgroup instead of by person. Balances settle at the alias level.
AI-powered receipt scanning Point your camera at a receipt and SplitDuo prefills the amount, date, and category. Works with any OpenAI-compatible endpoint — bring your own key, keep your data local.
Mobile-first, installable The interface is designed for a phone screen first. Add it to your home screen as a PWA and it behaves like a native app — no app store required.
Invite by email Add your partner via a secure, time-limited invitation link. No account needed on their end until they accept.
Data portability Import existing data from Cospend, Splitwise, or a SplitDuo CSV export. Alias-mode groups use a dedicated three-section CSV format. Export at any time. No lock-in.
Two-factor authentication Each user can independently enable TOTP-based 2FA on their account.
Fully self-hosted Single Docker container. Your server, your database, your rules.
Requires Docker and Docker Compose.
git clone https://gitlab.com/j1mm0/splitduo.git
cd splitduo
docker compose up -dOpen http://localhost:3000 — default login is admin@splitduo.local / changeme123.
Before going to production: set
SD_JWT_SECRET_KEYandSD_INITIAL_USER_PASSWORDto something you control.
Edit docker-compose.yml or pass environment variables directly.
SD_JWT_SECRET_KEY: your-secret-key
SD_INITIAL_USER_EMAIL: admin@splitduo.local
SD_INITIAL_USER_PASSWORD: changeme123SD_DB_HOST: postgres
SD_DB_NAME: splitduo
SD_DB_USERNAME: splitduo
SD_DB_PASSWORD: splitduoSD_BASE_URL: http://localhost:3000
ASPNETCORE_ENVIRONMENT: ProductionAny OpenAI-compatible endpoint works.
SD_AI_BASE_URL: https://api.openai.com
SD_AI_API_KEY: your-api-key
SD_AI_MODEL: gpt-4oRequired for invitation emails and password reset.
SD_EMAIL_SMTP_HOST: localhost
SD_EMAIL_SMTP_PORT: 587
SD_EMAIL_SMTP_USERNAME: ""
SD_EMAIL_SMTP_PASSWORD: ""
SD_EMAIL_SSL: "false"| Layer | Technology |
|---|---|
| Backend | .NET 10, Entity Framework Core |
| Frontend | Vue 3, Nuxt 4, Nuxt UI, TailwindCSS |
| Database | PostgreSQL 17 |
| Deployment | Single Docker container (multi-stage build) |
The frontend is compiled to static files and served directly by the .NET backend — one container, one port, no reverse proxy needed.
cd sd-backend
dotnet restore
dotnet run --project SplitDuo.Api
# → http://localhost:8080cd sd-frontend
pnpm install
pnpm dev
# → http://localhost:3000 (proxies API to :8080)./scripts/dev.sh # Start both
./scripts/dev.sh postgres # PostgreSQL only
./scripts/dev.sh mailpit # Mailpit only
./scripts/dev.sh -d # Drop and recreate volumes firstMailpit (email preview): http://localhost:8025
cd sd-backend
dotnet ef migrations add <MigrationName> --project SplitDuo.Core --startup-project SplitDuo.Api
dotnet ef database update --project SplitDuo.Core --startup-project SplitDuo.ApiIntegration tests live in sd-backend/SplitDuo.Tests.Integration and run the full API against a real PostgreSQL 17 container via Testcontainers + podman. No Docker daemon required — only podman.
Prerequisites:
- podman installed and the user socket running:
systemctl --user start podman.socket # one-time per session podman info --format '{{.Host.RemoteSocket.Path}}' # verify: unix:///run/user/<uid>/podman/podman.sock
- .NET 10 SDK
Run the tests:
cd sd-backend
./run-integration-tests.shThe script sets the podman socket env vars (DOCKER_HOST, TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED) and the test app config (SD_JWT_SECRET_KEY, SD_JWT_ISSUER, SD_JWT_AUDIENCE, SD_SEED_DEMO_DATA=false), then runs dotnet test. A fresh postgres:17-alpine container is spun up per test run and torn down after.
What's covered: the user-settings feature (PUT /api/v1/users/me/settings, GET /api/v1/users/me) — defaults, persistence, jsonb round-trip, validation, auth enforcement. The harness (SplitDuoApiFactory + IntegrationTest base class) is reusable for future endpoint tests.
Troubleshooting:
Could not connect to Docker daemon→ the podman socket isn't running. Start it withsystemctl --user start podman.socket.Ryuk container failed to start→ rootless podman needs privileged Ryuk. The script setsTESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED=true; if it still fails, addexport TESTCONTAINERS_RYUK_DISABLED=trueto skip the resource reaper.- Tests hang on startup → first container pull takes ~30s; subsequent runs reuse the cached image.
Detailed plan and architecture: Integration Tests Plan
Both test projects collect coverage via coverlet. The run-coverage.sh wrapper runs unit + integration tests with coverage and aggregates the results into an HTML report.
# One-time: install the report generator
dotnet tool install -g dotnet-reportgenerator-globaltool
cd sd-backend
./run-coverage.sh
xdg-open TestResults/coverage-report/index.htmlRaw coverage.cobertura.xml files are written under TestResults/ (gitignored). The script wipes TestResults/ each run so reports never merge stale data.
Releases follow semantic versioning and are driven by scripts/bump-version.sh, which orchestrates commit-and-tag-version (bumps package.json + VERSION) and git-cliff (changelog), then commits, tags vX.Y.Z, and pushes to trigger the GitLab CI pipeline.
./scripts/bump-version.sh patch # or minor / major
./scripts/bump-version.sh --auto # derive bump from Conventional Commits since last tag
./scripts/bump-version.sh patch -d # dry-run previewThe changelog is generated from Conventional Commits — feat, fix, refactor, style, build, etc. chore and docs commits are excluded. To regenerate the full CHANGELOG.md from all past tags:
pnpm install # one-time, installs git-cliff + commit-and-tag-version at repo root
pnpm changelogBase URL: http://localhost:3000/api/v1
Full spec: OpenAPI YAML
- Project Specification
- System Architecture
- Backend Architecture
- Frontend Architecture
- OpenAPI Spec
- Frontend API Composables
- 2FA Implementation
- CSV Import
- Invitation System
- Receipt Scanning
- PWA




