Prerequisites: Docker with Compose v2 (
docker compose …) on an x86-64 host. The image is published for amd64 only, so ARM boards (Raspberry Pi) and Apple Silicon would need emulation.
1. Create a project directory
Everything lives in one folder — the compose file, your .env, and the data/ directory the container writes to.
mkdir -p ~/apps/money && cd ~/apps/money2. Add a docker-compose.yml
Drop this next to it. It publishes the app on port 3000, bind-mounts ./data for all durable state, and health-checks itself:
name: money
services:
money:
image: rohitkaushal7/money:latest # amd64 / x86-64
container_name: money
init: true
ports:
- "3000:3000" # front this with a reverse proxy for HTTPS — see below
environment:
NODE_ENV: production
DATA_DIR: /app/data
env_file:
- path: ./.env
required: false # .env is optional — see step 3
volumes:
- ./data:/app/data # ALL durable state (databases + raw imports) — back this up
healthcheck:
test:
[
"CMD",
"bun",
"-e",
"fetch('http://localhost:3000/healthz').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped3. Environment variables (optional)
Both variables below are optional. With neither set, a fresh install generates its own auth secret and assumes http://localhost:3000 — so for a local install you can skip this step entirely. Create a .env next to the compose file only to override them:
# OPTIONAL. A 32+ character secret for Better-Auth sessions. If unset, one is
# generated on first boot and persisted at data/auth-secret (inside ./data), so
# sessions survive restarts. Set it only to pin the value; changing it later
# logs everyone out (passwords are unaffected).
BETTER_AUTH_SECRET=
# OPTIONAL. The origin the browser uses to reach the app, no trailing slash.
# Defaults to http://localhost:3000. Set this whenever you reach the app from
# any OTHER origin — a different published port, a LAN IP, or a domain behind
# a reverse proxy.
BETTER_AUTH_URL=https://money.example.comNODE_ENV and DATA_DIR are already baked into the compose file.
Same machine works out of the box; anything else needs HTTPS.Session cookies are issued with
Secure+SameSite=None. Openinghttp://localhost:3000on the machine that runs the container works as-is (localhost is a secure context) — as long as you publish it on port3000to match the defaultBETTER_AUTH_URL. To reach it from another machine — a LAN IP or a domain — put a reverse proxy that terminates TLS in front (Caddy, nginx, Traefik, Nginx Proxy Manager), forward all traffic to port3000, and setBETTER_AUTH_URLto that HTTPS origin. TLS is yours to run.
4. Start it
docker compose up -d
docker compose logs -f # watch it migrate, then serve5. Create your owner account
Open http://localhost:3000 (or your BETTER_AUTH_URL). On a fresh install the first screen is a one-time setup page — fill in your name, email, and an 8+ character password to create theowner account. It appears only while no account exists and closes for good once yours is made; public signup stays disabled. From the in-app Admin dashboard you can then invite and manage everyone else.
Prefer the command line? Seed the first admin from inside the container instead:
docker compose exec money bun scripts/create-user.ts \
--email you@example.com --name "Your Name" --admin --password 'choose-a-strong-password'Updating
Upgrading is just a pull — migrations run automatically on start:
docker compose pull && docker compose up -d # upgrade to the latest imageEvery release is also published under its commit SHA, so you can pin to a known-good build instead of tracking latest — set image: rohitkaushal7/money:<sha> in your compose file.
Backups
Everything durable lives in ./data: the auto-generated auth-secret, a shared control.db (auth + reference data), and one folder per user (users/<id>/ with their databases and raw imports). Back up that directory — ideally stop the container first (docker compose down) for a consistent snapshot. There's no automatic off-site backup, so a dead disk means lost data; back up ./data on your own schedule. You can also use Settings → Datato export your ledger, plan, and spending as CSV for a portable copy.
What leaves your machine
Self-hosted, your financial data stays in ./data and never leaves the box. The app makes exactly one kind of outbound request: fetching foreign-exchange rates from api.frankfurter.dev, and only when you refresh currency rates (Settings → Currencies). If you never touch multi-currency, it makes no outbound calls at all.
Source
The whole thing is open source — read it, fork it, or open an issue at github.com/RohitKaushal7/money.