Self-hostable, open-source portfolio tracker for crypto and traditional assets.
One view across every asset you care about — exchanges, on-chain wallets, brokerages, and manual entries. Same TypeScript codebase runs three ways: fully self-hosted, against a hosted data-provider, or as a managed service. MIT licensed.
▶︎ Watch Scani in one minute → — a narrated walkthrough of the live demo, with captions.
▶︎ Live demo → — a read-only instance with no
account, no email and no credential. Seeded with one invented cross-border
portfolio (a UK consultant with EUR income, GBP costs, a USD brokerage and
crypto on three chains), so every screen has something on it. Every write is
refused, so you cannot break it, and it resets itself on a schedule. It is the
same scani/frontend-app image docker-compose.prod.yml
pulls, so what you click through is what a self-hoster gets.
▶︎ Try it free → — the hosted version. Its app, api and worker are this codebase; billing and analytics are private. Free for a year at every paid tier during the beta preview, no credit card.
📚 Docs → — quickstart, self-hosting, architecture, provider integrations, and the full env-var reference.
🌐 scani.xyz → — what it does, who it is for, and how the three tiers compare.
You need Bun ≥ 1.3 and Docker (Docker Desktop, OrbStack, or any compatible runtime).
git clone git@github.com:MGrin/scani-oss.git
cd scani-oss
bun install
bun run dev:stack # boots Postgres, Redis, MinIO, Mailpit, api, worker, data-provider, frontend
open http://localhost:5173The stack is self-contained — no external service credentials required. Auth, holdings, FX pricing, and local screenshot storage (via MinIO) all work without any API key. Provider API keys (CoinGecko, OpenAI, exchange read-only keys, …) unlock specific integrations.
To stop:
bun run dev:stack:down # containers down, volumes preservedNeed to tweak the dev stack locally (different port, mounted init
SQL, custom volume path) without touching the committed compose
file? Copy docker-compose.override.yml.example
to docker-compose.override.yml — the override file is gitignored
and is auto-merged by docker compose on every command.
Dashboard — portfolio overview, allocations, recent activity.
Holdings — every position across every connected source, with cost basis and P/L.
Money — recurring bills and expected income, in every currency you hold, converted at today's rates.
Accounts — exchanges, wallets, and brokerages grouped by institution.
Integrations — the 15 credentialed connectors: exchanges, brokerages
and banks. Read-only API keys, encrypted at rest. Chains, pricing and AI
need no per-user credential and are configured with your own keys in .env.
The same binaries run three ways. You pick by setting env vars — no feature flags, no code-level switches.
| Tier | Data-provider runs on | Use case |
|---|---|---|
| 1 — Fully self-hosted | The same machine as the rest of the stack (bun run dev:stack) |
You run everything; ideal for personal use or operators who want full control |
| 2 — Semi-managed | A hosted data-provider you point at | You run the api + worker + frontend; a hosted endpoint provides object storage, email, OG metadata and token search. You still set your own provider API keys — see below |
| 3 — Fully managed | A fully hosted deployment | Someone else runs the whole stack for you |
The flow between them is just two env vars:
SCANI_CLOUD_URL— where to send object storage, email, OG-metadata and token-search requests (http://data-provider:8082for Tier 1; a hosted endpoint for Tier 2/3)SCANI_CLOUD_API_KEY— the bearer token the api + worker present
Those four are the whole list — they are the only adapters
packages/clients/cloud-client/src/ has. Pricing, AI and chain calls
do not travel. The api, the worker and the data-provider each boot
buildProviderRegistry({ mode: 'direct' }) and call CoinGecko,
DeFiLlama, Frankfurter, Finnhub, Etherscan, Helius and OpenAI
themselves, so the provider API keys below are read by your api and
worker on every tier. Moving to Tier 2 does not remove the need for
them.
The full annotated list lives in .env.example. The
must-set ones for any real deployment:
| Variable | Purpose |
|---|---|
DATABASE_URL |
Postgres 16+ connection string |
REDIS_URL |
Redis 7+ connection string |
BETTER_AUTH_SECRET |
32+ chars; rotates every session if changed |
ENCRYPTION_KEY |
32 hex chars; must match between api and worker |
JOBS_HMAC_SECRET |
Shared secret for HMAC-gated job admin endpoints |
FRONTEND_URL / BACKEND_URL |
What the browser sees; powers CORS + cookies |
S3_* |
Object storage (any S3-compatible store; MinIO locally, R2 / S3 / B2 / … in prod) |
SCANI_CLOUD_URL / SCANI_CLOUD_API_KEY |
Where the data-provider lives + bearer to reach it |
Optional integration keys, read by the api and the worker on every tier. Most degrade silently rather than refusing — a stack missing them comes up green on every health check and then serves worse data, so check the boot line rather than waiting for an error:
COINGECKO_API_KEY,FINNHUB_API_KEY— pricingOPENAI_API_KEY— screenshot parsingETHERSCAN_API_KEY— EVM wallet balances (one key covers all EVM chains)HELIUS_API_KEY— Solana balancesBINANCE_OAUTH_CLIENT_ID/_SECRET/_REDIRECT_URI— Binance exchange connectionFASTMAIL_API_TOKEN— magic-link email delivery (or useSMTP_URLfor any SMTP server)
Every provider that has a keyless branch reports at boot, and the registry logs one summary line whether or not anything is degraded:
docker compose -f docker-compose.prod.yml logs api worker \
| grep 'provider credentials:'Production logs are JSON (LOG_PRETTY is forced off when
NODE_ENV=production), so the line arrives as a msg field:
{… "mode":"direct","msg":"✅ provider credentials: 5/5 keyed · keyed: coingecko, etherscan, finnhub, openai, solana · degraded: none"}
{… "degraded":["COINGECKO_API_KEY","OPENAI_API_KEY"],"mode":"direct","msg":"⚠️ provider credentials: 3/5 keyed · keyed: etherscan, finnhub, solana · degraded: coingecko [COINGECKO_API_KEY unset → drops to the public rate-limited tier instead of the Pro host]; openai [OPENAI_API_KEY unset → throws on every call, so screenshot and document parsing fail]"}
The degraded line also carries a degraded array of just the unset
variable names, which is the cheaper thing to alert on.
The api serves the same record at /health/deep under
providerCredentials. An unkeyed provider deliberately does not turn
that endpoint red — it is a configuration choice, not an outage.
The repo ships a docker-compose.prod.yml
that pulls pre-built multi-arch images from Docker Hub
(scani/api, scani/worker, scani/data-provider,
scani/frontend-app, plus the opt-in scani/migrate schema runner)
and wires them up with Postgres + Redis + MinIO. Two-command
bring-up — migrations are explicit, not auto-applied:
cp .env.example .env # set real values
# Apply schema migrations (do this on first install AND on every upgrade)
docker compose -f docker-compose.prod.yml --profile migrate run --rm migrate
# Bring the long-running services up
docker compose -f docker-compose.prod.yml up -dSee self-hosting → production → Apply migrations
for the alternative orchestrators (Kubernetes Job, CI deploy step,
standalone docker run) and what the app does if you forget the
migrate step (api's /readyz returns 503, worker logs
Awaiting schema readiness in a restart loop).
For a real deployment, set the required env vars in .env
(BACKEND_URL, FRONTEND_URL, BETTER_AUTH_SECRET, ENCRYPTION_KEY,
JOBS_HMAC_SECRET, DATA_PROVIDER_API_KEY, SCANI_CLOUD_API_KEY,
LOG_ID_PEPPER), and put your own TLS-terminating reverse proxy in
front of the frontend-app container (the only one that needs to be
reachable from the public internet — nginx inside it proxies /api
and /ws to api over the compose network).
To use managed Postgres / Redis / S3-compatible storage, comment out
the corresponding services in docker-compose.prod.yml and point
DATABASE_URL / REDIS_URL / S3_* at the managed endpoints.
Images are published on each v* release tag (cut by release-please):
:1.2.3, :1.2, :1, and :latest (re-pointed to the most recent
release). main pushes are intentionally not a publish trigger — if
you need bleeding-edge builds, clone the repo and build locally. Pin
SCANI_IMAGE_TAG=1.2.3 in .env for reproducible deploys.
Scani's OSS distribution sends no telemetry, ever. Self-hosted
installs do not phone home: no install ID, no anonymous usage
counters, no feature-flag pings, no version-check beacons. The only
outbound calls a self-hosted stack makes are the ones you explicitly
configure — exchange APIs you connect, the pricing / chain providers
whose keys you set in .env, and your email transport.
Two opt-in, default-off exceptions exist:
- Sentry (
SENTRY_DSN/VITE_SENTRY_DSN) — error monitoring. No DSN means the SDK is a no-op; nothing leaves the process. Even when enabled, payloads are scrubbed bypackages/business/shared/src/utils/sentry-scrubber.tsbefore send. - Whatever you point
SCANI_CLOUD_URLat — by default this is the bundleddata-providercontainer on the same host. If you point it at a third-party hosted data-provider instead (Tier 2), upstream requests fan out from there. The OSS code makes no such call by default.
We are not collecting usage analytics for the OSS project itself. We
don't plan to. If we ever change our mind, the new feature will be
opt-in, default-off, fully documented in .github/SECURITY.md, and
shipped as a separate PR you can read end-to-end before deciding.
┌────────────────────────────────────────────────────────────────────────┐
│ Browser ──HTTPS──▶ api (Elysia + tRPC) ──BullMQ──▶ worker │
│ │ │ │
│ └──┬──────────────────────────┘ │
│ │ over tRPC │
│ ▼ │
│ data-provider │
│ (object storage, email, OG metadata, │
│ token search) │
│ │
│ api + worker ──HTTPS──▶ CoinGecko, Finnhub, DeFiLlama, Frankfurter, │
│ OpenAI, Etherscan, Helius, Google Sheets │
│ (direct, with their own keys, every tier) │
│ │
│ Postgres ◀─── api + worker + data-provider (Drizzle; BullMQ queue) │
│ Redis ◀─── rate-limiter buckets + realtime fan-out │
│ S3 ◀─── worker (screenshot uploads, file imports) │
└────────────────────────────────────────────────────────────────────────┘
Three deployable Bun services + one SPA:
apps/backend/api— tRPC + Elysia HTTP server. Owns per-user credentialed integrations (exchange API keys, brokerage tokens) so user creds never cross the tenant boundary.apps/backend/worker— BullMQ consumer. Runs every scheduled job (pricing refresh, balance syncs, historical backfills, transfer linking) and every user-initiated job (screenshot parse, import, delete) in one binary.apps/backend/data-provider— tRPC service the api and worker call for object storage, the email transport, Open Graph metadata and token search. This is the seam between the tiers: in Tier 1 it's onlocalhost:8082, in Tier 2/3 it's a hosted endpoint. It also exposes achains.*router that has no live caller today. Pricing and AI calls never come here at all — the api and worker make them directly, from their own environment's keys.apps/frontend/app— React + Vite SPA. tRPC client end-to-end type-safe with the api.
State splits as you'd expect. Postgres holds everything durable — users,
holdings, transactions, balances, audit log, and the BullMQ job queue in
its own bullmq schema.
Redis holds the per-provider rate-limiter buckets and realtime fan-out,
none of which has to survive a restart. An S3-compatible store holds
binary uploads.
- Runtime: Bun (end-to-end — no Node)
- Type-check:
tsgo(@typescript/native-preview) — 5–10× faster thantscon this monorepo - Lint + format: Biome (no ESLint, no Prettier)
- HTTP: Elysia + tRPC
- Database: PostgreSQL via Drizzle ORM
- Async jobs: BullMQ on its Postgres backend, with Postgres advisory locks for cron idempotency
- Auth: Better-Auth (sessions in Postgres)
- Storage: S3-compatible via
@aws-sdk/client-s3 - Email: Fastmail JMAP API or any SMTP server
- Frontend: React + Vite + Tailwind + shadcn/ui
- Dependency injection: typedi (class-field pattern — see
CLAUDE.md) - Testing:
bun testwith per-test transactional rollback for repository tests
Out of the box, Scani knows how to talk to:
Exchanges: Binance, Bitget, Bitstamp, Bybit, Coinbase, Gate.io, Gemini, Huobi, Kraken, KuCoin, MEXC, OKX
Brokerages / banks: Interactive Brokers (Flex Web Service), Wise, Airwallex
On-chain: Ethereum + every EVM chain Etherscan V2 supports (Polygon, Arbitrum, Optimism, Base, …), Solana (via Helius), Bitcoin, Tron, TON. Ethereum addresses are labelled by ENS reverse-resolution; an ENS name is not accepted as input.
Pricing: CoinGecko, Finnhub, DeFiLlama, ExchangeRate-API, Yahoo Finance, Google Sheets (for manual-asset prices)
AI: OpenAI (screenshot parsing), Perplexity, DeepSeek
Every provider has a directory under
packages/clients/providers/src/providers/
with a typed adapter behind a capability interface. Adding a new
provider is one of the highest-leverage contributions — see
CONTRIBUTING.md.
Pull requests welcome — start with
CONTRIBUTING.md, then read
CLAUDE.md for the engineering conventions.
Contributor benefit: every merged, non-trivial PR earns free permanent
access to every paid tier of the hosted Scani service at
app.scani.xyz. Eligibility and claim flow are
documented in
CONTRIBUTING.md#contributor-benefits.
High-leverage entry points if you're looking for somewhere to start:
provider integrations (new exchanges / brokerages / chains under
packages/clients/providers/) and
translations (drop a JSON file into
apps/frontend/app/src/i18n/locales/ —
no other code needs to change, partial translations are accepted, see
locales/CONTRIBUTORS.md).
Security findings should go to security@scani.xyz, not a public
issue. See .github/SECURITY.md for the full
disclosure flow.
Thanks goes to these people (emoji key):
MGrin 💻 🚧 📖 🚇 👀 |
This project follows the all-contributors specification. Contributions of any kind welcome — comment @all-contributors please add @username for code (or any other contribution type) on any PR or issue.
Questions, ideas, and show-and-tell live in
GitHub Discussions —
the best place to ask "is this the right approach" before you open a PR,
or to share what you've built on top of Scani. Security issues should go
through the private flow in .github/SECURITY.md
instead of Discussions or public issues.
Scani is built and maintained by one person in the open. If it saves you time — whether you self-host or use the hosted service — and you'd like to help keep development going, GitHub Sponsors is the way to chip in. Sponsorship funds the ongoing work that keeps the OSS distribution feature-complete and operator-friendly: new provider integrations, performance work, docs, and the time it takes to review every contribution carefully.
MIT. See LICENSE.
Tracked in GitHub issues.