skip to content
reuk
← money

self-hosting

Run money on your own box.

money ships as a single public Docker image — rohitkaushal7/money (amd64 / x86-64). 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.

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


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/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   # 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-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'

Updating

Upgrading is just a pull — migrations run automatically on start:

docker compose pull && docker compose up -d   # upgrade to the latest 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.

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.