skip to content
reuk
← money

self-hosting

Run money on your own box.

money ships as a single public Docker image — rohitkaushal7/money (amd64 + arm64). One container serves both the API and the web UI on one origin, applies database migrations on startup, and keeps all state in a bind-mounted data/ folder. Here's the whole setup.

No machine to run it on? Deploy it to Fly.io for about $3.50/year →

Don't want to run it yourself? Use the hosted version →


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/money

2. 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-stopped

3. 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.com

NODE_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. Opening http://localhost:3000 on the machine that runs the container works as-is (localhost is a secure context) — as long as you publish it on port 3000 to match the default BETTER_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 port 3000, and set BETTER_AUTH_URL to that HTTPS origin. TLS is yours to run.

4. Start it

docker compose up -d
docker compose logs -f     # watch it migrate, then serve

5. 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. Ashared-cpu-1x Machine 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 signup

Save 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=false

Exactly 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=false above just makes that explicit. The rule that matters is the one Fly won't enforce for you — never run fly scale count above 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-money

Fly 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 Issued

Prefer 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.com with 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.com is free;example.com is 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 slash

Then 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 image

On Fly.io, the same deploy line re-pulls the image:

fly deploy --config fly.toml --app my-money --ha=false   # re-pulls the image

Every 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.