A monorepo (pnpm + Turborepo) for the Lineage block explorer:
apps/web— the explorer web app (Next.js, App Router)apps/indexer— the block indexer worker (ingests the chain into Postgres)packages/db— Drizzle schema + typed queriespackages/chain— the Lineage node clientpackages/ui— the design-system component kitpackages/config— shared tooling config + brand constants
The explorer reads the chain from a locally-running Lineage node provided by the
fleet repo, and stores it in a local Postgres.
Prerequisites: Node 22, pnpm, Docker.
# 1. Start the Lineage node cluster (in the fleet repo).
# On Apple Silicon, prefix with FLEET_COMPOSE_PLATFORM=linux/arm64.
cd ../fleet && docker compose up
# Publishes: storage-node API :3001 (blocks), mempool-node API :3003 (supply).
# 2. In this repo: install, start a local Postgres, create the schema, run.
pnpm install
pnpm setup # copies .env.example -> .env (edit if needed)
pnpm dev:db # local Postgres on localhost:5432 (docker compose)
pnpm db:migrate # apply the schema
pnpm dev # runs the indexer + web with hot reload- Web: http://localhost:8080
- Indexer health/status: http://localhost:8090/health,
/status
pnpm dev loads .env (via dotenv-cli) and runs both apps under Turborepo.
The indexer ingests blocks from the fleet node into Postgres; the web app reads
Postgres directly. Stop the DB with pnpm dev:stop.
The indexer talks to the fleet's /v1 REST API (/v1/blocks/*,
/v1/blockchain-entries/query, /v1/supply).
| What | Env var | Default | Served by |
|---|---|---|---|
| Blocks / transactions | LINEAGE_STORAGE_NODE_URL |
http://localhost:3001 |
fleet storage node |
| Circulating / total supply | LINEAGE_MEMPOOL_NODE_URL |
http://localhost:3003 |
fleet mempool node |
| Database | DATABASE_URL |
postgres://explorer:explorer@localhost:5432/explorer |
local Postgres |
The supply endpoint (/v1/supply) lives on the mempool node, not the
storage node — so LINEAGE_MEMPOOL_NODE_URL must be set for circulating
supply to resolve. All other tunables (indexer batching, poll interval, log
level, health port) are in .env.example with sane defaults.
# fleet must be running (see step 1 above). Then:
pnpm dev:docker # docker compose -f docker-compose.dev.yml upRuns Postgres + an auto-migrate step + a dev container that bind-mounts the
source and runs turbo run dev (web on :8080, indexer health on :8090) in watch
mode — code changes reflect live, no image rebuild. node_modules live in
named volumes (installed in-container for the right platform); file changes are
picked up via polling (Docker bind mounts don't deliver inotify events on
macOS/Windows). Reaches the fleet node via host.docker.internal:3001/3003.
First run installs deps in-container (slower); subsequent runs reuse the volumes.
# fleet must be running (see step 1 above). Then:
docker compose up # builds + runs postgres, migrate, web, indexerweb + indexer + postgres run in containers, self-contained: they use the
internal postgres service and reach the fleet node on the host via
host.docker.internal:3001/3003 — no .env required. A one-shot migrate
service applies the schema before web/indexer start, so the stack comes up
ready. Use this for a production-like local run; use pnpm dev for iteration.
- Host dev (
pnpm dev): runpnpm db:migrateonce (it uses.env'sDATABASE_URL, i.e.localhost:5432— the Postgres started bypnpm dev:db). - Docker (
docker compose up): migrations run automatically via themigrateservice — nothing to do.
The hostname postgres in docker-compose.yml only resolves inside the
compose network (that is how web/indexer reach the DB). If you see
getaddrinfo ENOTFOUND postgres, a process outside that network is trying to
use it — e.g. running pnpm db:migrate on the host with a stale .env, or
starting web/indexer without the postgres service. From the host, always
use localhost:5432; run the full docker compose up so the postgres service
is present on the network.
A read-only REST API is served under /api/v1: blocks, transactions,
addresses (including the items an address holds), items
(/api/v1/items, /api/v1/items/{genesis}, /api/v1/items/{genesis}/transfers),
two-way trades (/api/v1/trades, /api/v1/trades/{druid}), supply and status. The API reference is the web app's /docs page, and the
OpenAPI 3.1 document is at /api/v1/openapi.json. Access is anonymous with a
soft per-IP rate limit.
Merging to main deploys the explorer to Railway. The Deploy workflow
(.github/workflows/deploy.yml) runs after CI succeeds for a push to main
and:
- deploys the indexer, which applies the database migrations on start;
- deploys the web app once the indexer is up;
- smoke-tests the live site and fails the run if it does not answer.
The order is deliberate: the web app reads the columns the newest migration adds, so it must not go out before the indexer has migrated.
The explorer runs in the Railway project services, environment testnet,
as the services indexer and web (alongside Postgres). Each service builds
its own Dockerfile from the repository root, selected by its
RAILWAY_DOCKERFILE_PATH variable. Neither service is connected to this
repository in Railway, so the workflow is the only thing that deploys them.
One-off setup:
| What | Where | Value |
|---|---|---|
RAILWAY_TOKEN |
repo or testnet environment secret |
a Railway project token for the services project, testnet environment |
RAILWAY_INDEXER_SERVICE |
repo variable (optional) | indexer service name or id; defaults to indexer |
RAILWAY_WEB_SERVICE |
repo variable (optional) | web service name or id; defaults to web |
EXPLORER_URL |
repo variable (optional) | public URL to smoke-test; defaults to https://explorer.lineage.to |
The indexer service has a healthcheck in Railway, set up as:
| Setting | Value | Why |
|---|---|---|
| healthcheck path | /health |
the indexer answers only after its migrations have run, so healthy means migrated |
PORT |
8080 |
tells Railway which port to probe; it is the indexer's default HEALTH_PORT |
INDEXER_LOCK_ON_BUSY |
wait |
Railway keeps the old indexer running until the new one is healthy, so the new one must wait for the indexer lock rather than exit |
While it waits for the lock the new indexer already answers /health; it
starts indexing when the old one stops and releases the lock.
To redeploy by hand, run the Deploy workflow from the Actions tab and
optionally give it a branch, tag or commit.
| Script | Purpose |
|---|---|
pnpm dev |
Run indexer + web on the host (hot reload), loading .env |
pnpm dev:docker |
Run the whole stack in Docker with hot reload |
pnpm dev:db / pnpm dev:stop |
Start / stop the local Postgres container |
pnpm db:migrate |
Apply the database schema |
pnpm build / pnpm typecheck / pnpm lint |
Standard gates |
pnpm test |
Unit/integration tests (needs a Postgres; see CI) |