There are two ways to run it. Put it on a machine you already have — a VPS, a home server, your laptop — or hand it to Fly.io, which boots it only while you're using it for a few dollars a year. Both run the same image; pick one.
Self-host on a VPS
Any box with Docker will do — a $5 VPS, a home server, a Pi, or your own laptop.
Prerequisites: Docker with Compose v2 (
docker compose …). One tag covers both amd64 and arm64, so x86 servers, Apple Silicon Macs, and 64-bit ARM boards (a Raspberry Pi 4 or 5 running a 64-bit OS) all pull a native image — no 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 # multi-arch: amd64 + arm64
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 [email protected] --name "Your Name" --admin --password 'choose-a-strong-password'Deploy on Fly.io
The option above assumes you have a machine to run Docker on. If you don't, Fly.io will run the same image for you. It boots the container when a request arrives and stops it when you're done, so a personal instance costs a few dollars a year. There's no repo to fork and no source to build: Fly pulls the published image, and the config below is the entire deployment.
What it costs
Fly has no free tier and asks for a card. For one person checking their finances a few times a week — call it three hours of actual use a month — the bill looks like this:
- $0.15/month — the 1GB volume your data lives on. 1GB is Fly's minimum, and you pay for it whether the app is awake or not.
- ~$0.12/month — storage for the stopped Machine's root filesystem, charged at $0.15/GB against an image of roughly 800MB.
- ~$0.03/month — the compute itself. A
shared-cpu-1xMachine with 1GB of RAM runs at $0.0082 an hour, and it only runs while you're using it.
That's about $0.30 a month, or $3.50 a year — the two storage lines, not the compute, are what you actually pay for. Visit it far more often and the compute line grows; leave it alone for a month and you still pay the $0.27 of storage. Rates are Fly's published prices for its base region and vary a little by region, so treat this as the shape of the bill rather than a quote.
Deploy from the command line
Install the CLI and sign in:
curl -L https://fly.io/install.sh | sh
fly auth signupSave this as fly.toml in an empty folder — it's the same file the curl below fetches:
app = "my-money" # globally unique across all of Fly — pick your own
primary_region = "bom" # pick one near you: fly platform regions
[build]
# Pulled as-is; no build step runs. Pin to a commit SHA instead of `latest`
# to freeze a known-good build: rohitkaushal7/money:<sha>
image = "rohitkaushal7/money:latest"
[env]
NODE_ENV = "production"
DATA_DIR = "/app/data"
# BETTER_AUTH_URL is derived from $FLY_APP_NAME at boot, so there is nothing
# to configure. Override it here only for a custom domain:
# BETTER_AUTH_URL = "https://money.example.com"
[http_service]
internal_port = 3000
force_https = true # Fly terminates TLS, which is what the Secure +
# SameSite=None session cookies require.
auto_start_machines = true # Fly Proxy holds the request, boots the Machine,
auto_stop_machines = "stop" # then forwards it — so idle costs nothing.
min_machines_running = 0
[http_service.http_options.response]
pristine = true
# Durable state: control.db, users/<uid>/{app.db, analytics.duckdb, raw/} and the
# auto-generated auth-secret. Fly creates this volume on the first deploy.
#
# EXACTLY ONE MACHINE. These are local SQLite files on a host-pinned volume; a
# second Machine would get its OWN empty volume and silently fork your ledger in
# two. Always deploy with `--ha=false`, and never `fly scale count > 1`.
[mounts]
source = "money_data"
destination = "/app/data"
initial_size = "1gb" # 1GB is Fly's minimum — about $0.15/month
[[vm]]
size = "shared-cpu-1x"
memory = "1gb" # 512mb is tight once DuckDB rebuilds; with
# auto-stop you only pay for this while it runs.
# Startup applies SQLite migrations before serving, so allow some grace.
[[http_service.checks]]
grace_period = "30s"
interval = "30s"
method = "GET"
timeout = "5s"
path = "/healthz"Then create the app and deploy it:
curl -O https://raw.githubusercontent.com/RohitKaushal7/money/main/fly.toml
fly apps create my-money # must be globally unique
fly deploy --config fly.toml --app my-money --ha=falseExactly one Machine, always. money keeps its data in local SQLite files on a volume pinned to a single Machine. A second Machine would come up with its own empty volume and silently split your ledger in two — no error, just two diverging copies. Fly is on your side here: it creates only one Machine when a process group has a volume mounted, precisely because volumes aren't replicated.
--ha=falseabove just makes that explicit. The rule that matters is the one Fly won't enforce for you — never runfly scale countabove 1.
Your app name has to be globally unique across all of Fly, not just your account — my-money is certainly taken, so pick something of your own and use it in both commands. Whatever you choose becomes your URL: open https://<your-app>.fly.dev and you'll get the same one-time setup page as step 5 above. There are no environment variables to set — the container reads its own Fly app name to work out its HTTPS origin, and generates its auth secret on the volume.
Using your own domain
Optional — <your-app>.fly.dev is already HTTPS and works fine. If you'd rather use your own domain, ask Fly for a certificate first:
fly certs add money.example.com --app my-moneyFly prints the DNS records it wants. For a subdomain, that's a single CNAME at your DNS provider pointing to your app's.fly.dev hostname:
CNAME money my-money.fly.dev.Certificates are issued by Let's Encrypt and are free (the first ten hostnames per account). Validation usually lands within a minute or two of the DNS record propagating:
fly certs show money.example.com --app my-money # poll until IssuedPrefer a subdomain over an apex domain. A CNAME sends traffic through the shared IPv4 that every Fly app gets for free. An apex domain (
example.comwith no subdomain) can't use a CNAME properly, so it needs A/AAAA records — and a dedicated IPv4 costs $2/month, which is roughly seven times the rest of your bill.money.example.comis free;example.comis not.
Last step, and skipping it will lock you out: the container works out its own HTTPS origin from the Fly app name, so on a custom domain it would still issue session cookies for <your-app>.fly.dev. Your browser won't send those back from money.example.com, and the login screen will simply refuse you with no visible error. Pin the real origin in fly.toml:
[env]
NODE_ENV = "production"
DATA_DIR = "/app/data"
BETTER_AUTH_URL = "https://money.example.com" # no trailing slashThen redeploy with the same fly deploy line as above. If you're already signed in on the .fly.dev address when you make this change, you'll be logged out once — your account and data are untouched, so just sign in again on the new domain.
Updating
Upgrading is just a pull — migrations run automatically on start. On your own box:
docker compose pull && docker compose up -d # upgrade to the latest imageOn Fly.io, the same deploy line re-pulls the image:
fly deploy --config fly.toml --app my-money --ha=false # re-pulls the 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, or image under [build]in fly.toml.
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.