A self-hosted finance tracker for startups, solo founders, and small agencies.
Most finance trackers are either too expensive, too complicated, or want access to your bank account. Koin is a self-hosted Nuxt app that runs on your own Cloudflare account — or entirely locally if you prefer.
- Mercury Bank Sync — Import via API (real-time) or CSV; auto-categorizes 100+ vendor patterns.
- Expense Tracking — Log expenses with receipt attachments and IRS Schedule C category mapping.
- Mileage Logging — Track business trips with current IRS standard rates.
- Contractor Tracker — Monitor payments and automatically flag 1099-NEC ($600+) thresholds.
- Client Revenue Forecasting — Track retainers, hourly clients, commitments, project dates, and monthly forecast rolloff.
- Recurring Expenses — Auto-create monthly/quarterly/annual entries for SaaS and subscriptions.
- Tax Reports — Generate CPA-ready deduction summaries by category with 1099-NEC prep.
- IRS Calendar — Built-in reference for 1099-NEC, W-2, and Form 1040 deadlines.
- Privacy First — Self-hosted on your own Cloudflare account. No 3rd party access to your data.
- Backup & Restore — Export all data as JSON, import to any instance.
- Framework: Nuxt
- UI: Nuxt UI + Tailwind CSS v4
- DB: Cloudflare D1 and R2 for file storage | SQLite via Drizzle ORM for local development
- Hosting: Cloudflare Workers (Optional)
git clone https://github.com/nsina/koin.git
cd koin
pnpm install
pnpm devNuxtHub uses a local SQLite file (.data/hub/database) for development — no external database required. Pending migrations are applied automatically when the dev server starts; you can also run them explicitly with pnpm nuxt-db migrate. To reset your local database, delete .data/hub/database and restart.
This is the best way to test the full feature set, including Mercury API integration.
- Generate a Read Only token at Mercury Settings → API Tokens
- In Koin, go to Settings and paste the token into the Mercury API field
- Use the Mercury Import tab to sync transactions by date range
The token is stored server-side in KV and never sent to the browser in plaintext.
Copy the example wrangler config and fill in your own Cloudflare resource IDs:
# 1. Copy and fill in your Cloudflare resource IDs
cp wrangler.example.jsonc wrangler.jsoncEdit wrangler.jsonc with your D1 database ID, R2 bucket name, and KV namespace ID. You can find/create these in the Cloudflare dashboard:
- D1: Storage & Databases → D1 SQL database → Create Database
- R2: Storage & Databases → R2 Object Storage → Create bucket
- KV: Storage & Databases → Workers KV → Create Instance
Keep the binding names DB, BLOB, and KV exactly as they are in the example — NuxtHub's drivers look them up by name.
wrangler deploy uploads the bundle but never applies D1 migrations. They run as an explicit step against the remote database over the D1 HTTP API, which needs three environment variables:
cp .env.production.example .env.productionFill in your account ID, database ID, and a Cloudflare API token with the Account · D1 · Edit permission.
Keep these out of
.env. Onlypnpm db:migrate:prodreads.env.production. It explicitly selects the remote D1 driver and fails before deployment if any credential is missing; normal development always uses local SQLite.
pnpm run deployThat runs three steps in order — build with the Cloudflare preset, apply pending migrations to the remote D1, then upload:
pnpm build:cf # NITRO_PRESET=cloudflare_module nuxt build
pnpm db:migrate:prod # force remote D1, then nuxt-db migrate --dotenv .env.production
pnpm wrangler deployA bare pnpm build produces a node-server bundle with local filesystem drivers, which cannot run on Workers. Verify the deployed schema with:
pnpm nuxt-db sql "select * from _hub_migrations" --dotenv .env.productionNever run
wrangler d1 migrations apply. It writes migration names into_hub_migrationsin a different format than NuxtHub does, so neither runner recognizes the other's rows and the next deploy fails withtable already exists. Usenuxt-db migrateonly.
This app has no built-in authentication. If deployed to a public URL, protect it with Cloudflare Access before sharing:
- Go to Zero Trust → Access → Applications in the Cloudflare dashboard
- Add your Pages domain and configure an identity provider (Google, GitHub, email OTP)
Without Cloudflare Access, your expense data and bank transactions are publicly readable. For local use only, no additional auth is needed.
MIT — see LICENSE

