Quickstart
You can have a working Scani instance running locally in five minutes. The default stack is completely self-contained — no external accounts, no provider API keys.
Prerequisites
Section titled “Prerequisites”- Bun ≥ 1.3
- Docker (Docker Desktop, OrbStack, or any compatible runtime)
- A free port at: 5173 (SPA), 3011 (api), 8082 (data-provider), 5433 (Postgres), 6380 (Redis), 9000 (SeaweedFS S3), 1026/8026 (Mailpit).
The five-minute path
Section titled “The five-minute path”-
Clone and seed the env file.
Terminal window git clone https://github.com/MGrin/scani-oss.gitcd scani-osscp .env.example .env -
Install workspace dependencies.
Terminal window bun install -
Boot the full stack.
Terminal window bun run dev:stackThis runs
scripts/sync-env.ts(materialises per-app.envfiles from the root.env) thendocker compose --profile full up -d --build. First boot pulls images and builds the app containers — give it a few minutes. -
Open the SPA.
Terminal window open http://localhost:5173Sign in with a magic-link email. Local emails land in Mailpit at http://localhost:8026 — click the link there.
-
Stop the stack when you’re done.
Terminal window bun run dev:stack:downContainers go away; named volumes (your Postgres data, your SeaweedFS bucket) persist until you run
bun run dev:stack:down -- --volumes.
What you get
Section titled “What you get”These are the ports the containerised stack publishes. Running the apps on the host instead changes one of them — see below.
What works out of the box
Section titled “What works out of the box”- Magic-link sign-in (emails land in Mailpit).
- Manual holdings with manual prices.
- FX conversion (the Frankfurter provider needs no key).
- Screenshot uploads (stored in the local SeaweedFS bucket).
- The full SPA, dashboard, vaults, groups.
What needs a key
Section titled “What needs a key”These integrations need an API key for full capability — but a
missing key is not an error. A provider that boots without its key
still registers and degrades: it contributes no rows, or falls back to a
keyless public tier. The call succeeds and the rest of the app keeps
working. On a stack with no FINNHUB_API_KEY, searching for a US equity
returns HTTP 200 with the crypto matches and no equities — nothing
anywhere says a key is missing.
The exception is OpenAI: with no OPENAI_API_KEY, starting a screenshot
or document parse is refused with PRECONDITION_FAILED, and nothing is
queued. Statement files and manual entry still work.
Because the rest degrade silently, check what is configured rather than
waiting for an error — every backend prints a provider credentials:
line at boot naming the providers that came up unkeyed, and the api
serves the same record on /health/deep. See How to tell what’s
enabled.
See Optional integration keys for the full list.
Running services on the host instead of in containers
Section titled “Running services on the host instead of in containers”If you prefer to iterate on api / worker / frontend on the host (faster HMR, easier debugger attach) and only run infra in Docker:
bun run dev:stack:infra # Postgres, Redis, SeaweedFS, Mailpit — nothing elsebun installbun dev # api + frontend/app concurrentlybun dev:worker # separate terminal — the BullMQ consumerbun dev:data-provider # separate terminal — outbound 3rd-party callsOn this path the api binds PORT from the root .env — 3001,
not the 3011 the table above lists. 3011 is the host port compose
publishes for the containerised api; nothing publishes it here. The
data-provider (8082) and the SPA (5173) are the same on both
paths.
bun run dev:stackCommon gotchas
Section titled “Common gotchas”- The stack fails to start with a container-name conflict. One-shot
init containers (
env-sync,deps,migrate) exit cleanly but linger with their container names reserved.bun run dev:stack:downremoves them; bringing the stack up again without a priordownwill hit the conflict. - API keys go in
.env, not in compose..envis the single source of truth.scripts/sync-env.tspropagates it to per-app.envfiles. Editingdocker-compose.ymldirectly will drift. ENCRYPTION_KEYmust match between api and worker. They’re the same value in.env.examplefor a reason — if they differ, the worker cannot decrypt the integration credentials the api stored, and every import silently fails.- Drive the stack with the
dev:stackcommands, not with a baredocker compose. They address two different compose projects.bun run dev:stacknames the project after this checkout’s absolute path (scani_<label>_<digest>); a baredocker composesets no project name, so compose falls back to the directory’s. The two never match, and compose does not complain — a baredocker compose down -vafter abun run dev:stackexits0, prints nothing and wipes nothing, which reads exactly like success. - Persistent data lives in named volumes.
bun run dev:stack:downpreserves them;bun run dev:stack:down -- --volumeswipes them. The Postgres data, the SeaweedFS bucket, and the Redis AOF all live there. - Two scani checkouts run side by side and share nothing. Each
derives its own project name, its own volumes and its own host ports,
so one checkout’s
bun run dev:stack:down -- --volumescannot reach another’s data. If two of them land on the same host port, see Troubleshooting.
Going to production
Section titled “Going to production”The dev stack above is the foundation for the production deployment flow at Self-hosting → Production. Two notable differences:
- Migrations are an explicit step. Prod compose ships them as a
profile-gated one-shot (
docker compose -f docker-compose.prod.yml --profile migrate run --rm migrate) — you run it on first install and on every upgrade. The dev compose runs them automatically as part ofbun run dev:stack. /api/readyzvalidates schema. The prod compose api healthcheck fails (and frontend-app is blocked) until migrations have been applied. Forgetting to migrate is a loud failure, not a silent one.
Next steps
Section titled “Next steps”- Read What is Scani for the product shape.
- Read Mental model for the domain model.
- Read Tier model when you’re ready to put it behind a real domain.
- Read How to contribute if you’d like to send a PR.