Self-hosted bitcoin & mining-business tracker. See docs/superpowers/specs/2026-05-02-bitcoin-tracker-design.md for the full design.
- Docker + Docker Compose
- Node.js 22+ (only required for local development)
- A reverse proxy (Caddy recommended) on the host, fronting Tailscale
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.tsApp is reachable at http://localhost:3000 (proxy via Caddy as desired).
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 anotherdocker compose -f docker-compose.dev.yml up -d
npm run db:migrate
npm test # unit + integration
npm run test:integration # integration only (requires Postgres)Fresh-start workflow to bring in a full history of buys, sales, transfers, mining payouts, and pool data.
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.tsVisit http://localhost:3000, request a magic link to your BOOTSTRAP_EMAIL, sign in, and enroll a passkey at /settings/passkeys.
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.).
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.comThe 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.
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.jsonFor new histories prefer the CSV importer (3a) — it has hash idempotency, FIFO sales allocation, and provenance tracking via the imports / import_rows tables.
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/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
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 createFull 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 2024Restore from a .dump:
docker compose exec postgres pg_restore -U btc -d btc_tracker /backups/btc-tracker-2026-05-05-020000.dumpThe Settings page shows the timestamp + size of the most recent backup, with a red badge if it's stale (>36 hours) or failed.
The repo ships two compose files: docker-compose.yml (dev — builds locally) and docker-compose.prod.yml (prod — pulls pre-built images from GHCR).
make loginAuthenticates 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.
make deployRefuses 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).
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 hostnameThe 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.
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -dpull_policy: always in the compose file ensures :latest is re-resolved each up.
- Sign-in: magic link to
BOOTSTRAP_EMAILonly (single-user app). Once signed in, enroll a passkey at/settings/passkeysfor password-free returns. - Tailscale gates network access; this app's auth is defense-in-depth.
npm run db:migratefails → confirmDATABASE_URLand that Postgres is healthy.- Magic link email not arriving → check
EMAIL_PROVIDERconfig;workerandappcontainers both log to stdout (docker compose logs -f app worker). - WebAuthn registration fails →
RP_IDmust match the hostname you visit (e.g.bitcoin.example.ts.net);RP_ORIGINmust be the fullhttps://...URL.