Skip to content

mdolton/bitcoin-tracker

Repository files navigation

Bitcoin Tracker

Self-hosted bitcoin & mining-business tracker. See docs/superpowers/specs/2026-05-02-bitcoin-tracker-design.md for the full design.

Prerequisites

  • Docker + Docker Compose
  • Node.js 22+ (only required for local development)
  • A reverse proxy (Caddy recommended) on the host, fronting Tailscale

Quick start

cp .env.example .env
# Fill in: SESSION_SECRET (32+ chars), BOOTSTRAP_EMAIL, EMAIL_PROVIDER + provider creds,
# APP_URL, RP_ID, RP_ORIGIN.
# DATABASE_URL must point at the postgres service name, e.g.
#   DATABASE_URL=postgresql://btc:btc@postgres:5432/btc_tracker
docker compose build
docker compose up -d
docker compose run --rm worker npx tsx src/lib/db/migrate.ts

App is reachable at http://localhost:3000 (proxy via Caddy as desired).

Local development

npm install
docker compose -f docker-compose.dev.yml up -d   # Postgres only
cp .env.example .env.local
# In .env.local set DATABASE_URL=postgresql://btc:btc@localhost:5432/btc_tracker
npm run db:migrate
npm run dev          # in one terminal
npm run worker:dev   # in another

Tests

docker compose -f docker-compose.dev.yml up -d
npm run db:migrate
npm test                    # unit + integration
npm run test:integration    # integration only (requires Postgres)

Importing historical data

Fresh-start workflow to bring in a full history of buys, sales, transfers, mining payouts, and pool data.

1. Stand up the stack and the bootstrap user

docker compose up -d
docker compose run --rm worker npx tsx src/lib/db/migrate.ts
docker compose run --rm worker npx tsx src/lib/db/seed.ts

Visit http://localhost:3000, request a magic link to your BOOTSTRAP_EMAIL, sign in, and enroll a passkey at /settings/passkeys.

2. Create wallets via the UI

The CSV importer matches wallets by exact name — wallets must exist before you import. Visit /wallets and create one row per wallet you'll reference in your CSVs (e.g. Cash App, Coldcard, Coinbase). Pick the right type for each (exchange / self_custody_hardware / etc.).

3a. Bulk-import via CSV (recommended)

Prepare four CSVs in this layout (one per record type — pass only the ones you have):

buys.csv
  occurred_at,wallet_name,amount_btc,usd_price,usd_fees,source_label,notes

sales.csv
  occurred_at,wallet_name,amount_btc,usd_price,usd_fees,buyer_label,notes

transfers.csv
  occurred_at,from_wallet_name,to_wallet_name,amount_sent,amount_received,
  network_fee_btc,usd_price_at_time,notes

mining-payouts.csv
  occurred_at,wallet_name,pool_label,amount_btc,usd_price_at_time,external_id,notes

Dates are ISO 8601 UTC (2024-01-15T22:24:00Z). BTC amounts are 8-dp strings; USD amounts are 4-dp strings. The notes cell may be empty; for mining-payouts.csv the external_id cell may also be empty.

Copy the files into the worker container, then dry-run, then import:

docker compose cp ./buys.csv           worker:/tmp/buys.csv
docker compose cp ./sales.csv          worker:/tmp/sales.csv
docker compose cp ./transfers.csv      worker:/tmp/transfers.csv
docker compose cp ./mining-payouts.csv worker:/tmp/mining-payouts.csv

# Validate without writing
docker compose exec worker bitcoin-tracker import csv \
  --buys /tmp/buys.csv --sales /tmp/sales.csv \
  --transfers /tmp/transfers.csv --mining-payouts /tmp/mining-payouts.csv \
  --user-email you@example.com --dry-run

# Real import
docker compose exec worker bitcoin-tracker import csv \
  --buys /tmp/buys.csv --sales /tmp/sales.csv \
  --transfers /tmp/transfers.csv --mining-payouts /tmp/mining-payouts.csv \
  --user-email you@example.com

The importer is idempotent on a SHA-256 of each row's canonical form. Re-running the same file is a no-op (each row reports as skipped). Errored rows can be edited and re-imported safely — the importer replaces the prior error row.

3b. Bulk-import via xlsx (legacy)

If your history is already in the Plan 4 xlsx format under data/, run the older two-stage importer:

python -m scripts.import_xlsx.parse data/your-file.xlsx > /tmp/parsed.json
docker compose exec worker npx tsx src/scripts/import_xlsx/load.ts /tmp/parsed.json

For new histories prefer the CSV importer (3a) — it has hash idempotency, FIFO sales allocation, and provenance tracking via the imports / import_rows tables.

4. Configure mining pool sync (Braiins)

If you mine, add the pool at /settings/pools. Set the default destination wallet to the one your payouts land in (e.g. Coldcard).

Once configured, the worker syncs hourly; or trigger a manual sync from the pool detail page. The first sync backfills the external_id column on the historical mining payouts you just imported — matching by (wallet, UTC-day, amount_btc, pool_label) — so future syncs dedupe cleanly via the Braiins TXID.

If you want to verify after the first sync:

-- All Braiins payouts should have an external_id starting with "braiins-tx-".
SELECT COUNT(*) FROM mining_payouts WHERE pool_label = 'Braiins' AND external_id IS NULL;
-- Expected: 0

5. Sanity-check via the UI

  • /dashboard — total BTC + USD, per-wallet breakdown
  • /transactions — full activity list, paginated
  • /wallets — per-wallet balances
  • /reports?year=2024 — tax forms (8949, Schedule C, 4562, 4797) populated from the imported data

Backups

The worker container runs a nightly pg_dump -Fc cron at 02:00 local time to ./backups/btc-tracker-YYYY-MM-DD-HHMMSS.dump. Retention defaults to 30 days (override via BACKUP_RETENTION_DAYS).

Manual snapshot at any time:

docker compose exec worker bitcoin-tracker backup create

Full export bundle (pg_dump + tax-year reports + ledger CSVs in a single .tar.gz):

docker compose exec worker bitcoin-tracker export bundle \
  --user-email you@example.com --year 2024

Restore from a .dump:

docker compose exec postgres pg_restore -U btc -d btc_tracker /backups/btc-tracker-2026-05-05-020000.dump

The Settings page shows the timestamp + size of the most recent backup, with a red badge if it's stale (>36 hours) or failed.

Deploying to production

The repo ships two compose files: docker-compose.yml (dev — builds locally) and docker-compose.prod.yml (prod — pulls pre-built images from GHCR).

One-time workstation setup

make login

Authenticates Docker to ghcr.io using gh auth token. Requires the GitHub CLI; if you don't have it, the Makefile prints a PAT-based alternative.

Per-deploy (from your dev workstation)

make deploy

Refuses if the working tree is dirty or npm test fails. On success, builds ghcr.io/mdolton/bitcoin-tracker-app and ghcr.io/mdolton/bitcoin-tracker-worker for both linux/amd64 and linux/arm64, tagged with the short Git SHA and latest, and pushes them.

Override the dirty check with make deploy ALLOW_DIRTY=1 (avoid in normal use).

One-time NAS setup

echo "$YOUR_GHCR_PAT" | docker login ghcr.io -u mdolton --password-stdin
git clone git@github.com:mdolton/bitcoin-tracker.git
cd bitcoin-tracker
cp .env.example .env  # edit RP_ID, RP_ORIGIN, BOOTSTRAP_EMAIL, etc. for the NAS hostname

The repo is needed on the NAS only for docker-compose.prod.yml, the .env file, and the host-side ./backups/ directory. The actual app code lives in the pulled images.

Per-deploy (on the NAS)

docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d

pull_policy: always in the compose file ensures :latest is re-resolved each up.

Auth

  • Sign-in: magic link to BOOTSTRAP_EMAIL only (single-user app). Once signed in, enroll a passkey at /settings/passkeys for password-free returns.
  • Tailscale gates network access; this app's auth is defense-in-depth.

Troubleshooting

  • npm run db:migrate fails → confirm DATABASE_URL and that Postgres is healthy.
  • Magic link email not arriving → check EMAIL_PROVIDER config; worker and app containers both log to stdout (docker compose logs -f app worker).
  • WebAuthn registration fails → RP_ID must match the hostname you visit (e.g. bitcoin.example.ts.net); RP_ORIGIN must be the full https://... URL.

About

Tracks bitcoin portfolio and miner assets

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors