- GET /v1/capabilities and the extended sync entity allowlist (§0, §1). - Shared ledgers (§7): ledgers, members and hashed one-time invite codes; X-Balance-Ledger selects the space on /v1/sync and /v1/attachments with its own records, change stream and cursor. Personal space keeps the v1 tables and behaviour; the migration only adds tables. - Attachments (§8): SHA-256, size and media type checks, blob files in BALANCE_ATTACHMENTS_DIR (default: attachments next to the database), garbage collection of unreferenced blobs with a grace period for fresh uploads, and ledger deletion removes its blobs. - Tests for every new endpoint and error code; API, sync, data model, security, deployment and README docs updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7.3 KiB
Balance Server
Self-hosted synchronization, account and web server for the Balance iOS, macOS, watchOS and Android applications. It is a single Rust process with a bundled SQLite engine and a complete browser client, and runs on Linux, Windows and macOS.
Quick start with Docker
cp .env.example .env
openssl rand -base64 48
# Paste the generated value into BALANCE_JWT_SECRET in .env
docker compose up -d --build
curl http://localhost:8123/health
Open http://localhost:8123 to register or sign in to the web application. Data is kept in the balance-data Docker volume. Back it up while the container is stopped.
Run without Docker
Install Rust 1.90 or newer, then build the locked dependencies:
cargo build --locked --release
The binary is target/release/balance-server (balance-server.exe on Windows).
SQLite is compiled into the binary; no separate SQLite service is needed.
On Linux or macOS:
export BALANCE_JWT_SECRET="replace-with-a-long-random-secret"
export BALANCE_DB_PATH="./data/balance.db"
./target/release/balance-server
On Windows PowerShell:
$env:BALANCE_JWT_SECRET = "replace-with-a-long-random-secret"
$env:BALANCE_DB_PATH = ".\data\balance.db"
.\target\release\balance-server.exe
For another platform, build on that platform or install its Rust target and C toolchain
for bundled SQLite. cargo build --locked --release --target <target> requires the
matching linker and C compiler.
After starting the binary, open http://localhost:8080 in a browser. The web assets are embedded into the executable, so there is no separate frontend build, Node.js process or static directory to deploy.
Web application
The responsive web client includes:
- registration, login, automatic session refresh and logout;
- dashboard, current balance and manual balance adjustment;
- adding, editing, deleting, searching and filtering transactions;
- analytics for 1, 3 and 12 months, spending breakdown and savings rate;
- monthly category budgets;
- custom categories with 50 icons, custom emoji and 24 colors;
- light, dark and system themes, plus RUB, EUR, USD and SEK formatting;
- automatic synchronization every 30 seconds and whenever the browser tab becomes active;
- installable PWA shell for desktop and mobile browsers.
The browser session uses HttpOnly, SameSite=Strict cookies. Native applications continue to use Bearer access tokens and are fully backward compatible. Only display preferences are saved in browser local storage; tokens and financial records are not stored there.
Configuration
| Variable | Default | Description |
|---|---|---|
BALANCE_ADDR |
:8080 |
Listen address |
BALANCE_DB_PATH |
./data/balance.db |
SQLite file path |
BALANCE_JWT_SECRET |
required | Random secret of at least 32 characters |
BALANCE_ALLOW_REGISTRATION |
true |
Allows new accounts |
BALANCE_ACCESS_TTL |
15m |
Access-token lifetime |
BALANCE_REFRESH_TTL |
720h |
Refresh-token lifetime |
BALANCE_CORS_ORIGINS |
empty | Comma-separated browser origins; native apps do not need CORS |
BALANCE_ATTACHMENTS_DIR |
attachments next to BALANCE_DB_PATH |
Directory for attachment files (receipt photos); Docker: /data/attachments |
HTTPS
Use HTTPS before exposing the server to a network. The Apple applications accept plain HTTP only for localhost and 127.0.0.1; a physical device should connect through an HTTPS reverse proxy such as Caddy, nginx or a private VPN with TLS. Example Caddy configuration:
balance.example.com {
reverse_proxy 127.0.0.1:8123
}
Then enter https://balance.example.com in each application's Settings → Server account.
API
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/health |
Health check |
POST |
/v1/auth/register |
Create account |
POST |
/v1/auth/login |
Sign in |
POST |
/v1/auth/refresh |
Rotate session tokens |
POST |
/v1/auth/logout |
Revoke refresh token |
GET |
/v1/me |
Current account |
POST |
/v1/sync |
Push and pull changes (X-Balance-Ledger selects a shared ledger) |
GET |
/v1/capabilities |
Contract version and features (no authorization) |
GET, POST |
/v1/ledgers |
List / create shared ledgers |
PATCH, DELETE |
/v1/ledgers/{id} |
Rename / delete a ledger (owner) |
POST |
/v1/ledgers/{id}/invites |
One-time invite code (owner) |
POST |
/v1/ledgers/join |
Join a ledger by code |
DELETE |
/v1/ledgers/{id}/members/{userId} |
Remove a member or leave |
PUT, GET |
/v1/attachments/{sha256} |
Upload / download attachment bytes |
The server implements ecosystem contract v2 (docs/CONTRACT_V2.md). The sync endpoint accepts transactions, categories, budgets, accounts, transfers, recurring operations, goals, debts, exchange rates, settings and attachment metadata. The server isolates records by account, stores an append-only change cursor, and resolves competing edits with last-write-wins timestamps. Current Apple and Android clients use push-only requests followed by paginated pulls; update the server and applications together.
Operational notes
- Registration can be disabled after the required accounts have been created.
- Passwords are stored as salted Argon2id hashes. Refresh tokens are stored only as SHA-256 hashes.
- Back up the SQLite database and its
-wal/-shmfiles together with the attachments directory, or stop the server before copying them. - Changing
BALANCE_JWT_SECRETinvalidates access tokens. Existing refresh sessions remain valid and will receive new access tokens after refresh. - The in-process authentication limiter allows 30 authentication requests per IP per minute. Use a reverse proxy for stronger public rate limiting and request logging.
Compatibility and migration
The Rust server replaced the original Go implementation and retains its HTTP/JSON
API, embedded web client, environment variables, SQLite schema, Argon2id password
hashes, JWT format, refresh sessions and sync cursor semantics. The Go sources were
removed after v1.0.0; that tag still contains them for reference or rollback. Docker Compose still maps
host port 8123 to container port 8080 and keeps /data in balance-data.
Direct binary execution defaults to port 8080.
Before replacing a running Go server, stop it, back up its database directory
(including any SQLite WAL/SHM files), and preserve BALANCE_JWT_SECRET. Start Rust
against the same BALANCE_DB_PATH; do not run both writers simultaneously.
Existing accounts, access/refresh tokens and saved device cursors remain usable.
See deployment and rollback for the full procedure.
Verify the server locally with Rust and Python 3:
cargo test --locked
cargo fmt --check
cargo clippy --locked --all-targets -- -D warnings
cargo build --locked
python3 tools/compatibility.py
The Python standard-library suite starts the server on temporary loopback ports
and databases, compares its API responses and assets with the responses recorded
from the original Go server (tools/compatibility_expected.txt), and checks that
SQLite data survives restarts. To test a release or prebuilt executable, pass
--binary PATH. After an intentional API change, review the difference and
re-record the reference with --update.
