Deploy your consumer
Run your consumer anywhere that can reach your instance: same box, Railway, Render, or Fly, on one Dockerfile.
Four files, whatever the target:
| File | Does |
|---|---|
indexer.ts | The consume() loop: onBatch writes rows, onReorg rolls them back |
schema.ts | Your tables, plus the checkpoint that makes a restart free |
health.ts | GET /health on PORT, so the platform can tell live from wedged |
Dockerfile | One image; every target below takes it unchanged |
The loop lives in Index; the checkpoint, transaction, and reorg contract live in Sinks.
Your instance is the source, so run it first. The consumer needs a reachable URL:
SL_API_URL=http://127.0.0.1:3800 # same box as the instance
SL_API_URL=http://indexer.tailnet:3800 # tailnet or private network
INSTANCE_TOKEN=… # required once the API is published past loopbackLoopback reads are open. Anything a consumer can reach from another box is past loopback, so it needs INSTANCE_TOKEN set on the instance and in the consumer's env; see Authentication.
| Target | Process model | The one thing to get right |
|---|---|---|
| Same box | Compose service or systemd unit | Zero network hop. Docker and systemd |
| Railway | Persistent container | railway.json with healthcheckPath: "/health"; PORT arrives injected |
| Render | Persistent container | type: web, never Background Worker: workers get no health check and no PORT; keep the public route read-only |
| Fly | Persistent VM | auto_stop_machines = false, min_machines_running = 1, since scale-to-zero suspends an outbound-only loop minutes after deploy. Or skip persistence: run mode: "bounded" on a schedule |
Same Dockerfile everywhere. The remote targets only work if they can reach your instance: a public bind with INSTANCE_TOKEN, or a shared private network.
Delivery is at-least-once. A process killed between the read and the commit re-reads that page on restart, so every write needs a conflict rule:
.onConflict((oc) => oc.column("tx_id").doNothing())A sink commits rows and checkpoint in one transaction and injects the reorg rollback, so use one rather than hand-rolling the contract.
Railway, Render, and Fly mark a service unhealthy unless something answers over HTTP. A bare loop binds nothing and gets restarted forever.
import { consumerHealth } from "@secondlayer/sdk";
const health = consumerHealth({ staleAfterMs: 120_000 });
Bun.serve({ port: Number(process.env.PORT ?? 8080), fetch: health.handler });Feed health.record from onProgress; wiring in Sinks. Gate on freshness, not lag: a genesis backfill sits millions of blocks behind the tip and is perfectly healthy. A wedged loop stops reporting pages, which is the restartable signal.
Every redeploy arrives as SIGTERM. Pass an AbortSignal and consume() checks it at the top of the loop, never mid-batch, so the in-flight transaction always commits.
const shutdown = new AbortController();
process.on("SIGTERM", () => shutdown.abort());
await index.contractCalls.consume({
signal: shutdown.signal,
// …
});PID 1 has to be your process
A shell-form CMD makes /bin/sh PID 1, which swallows SIGTERM, so the loop never hears it and the platform hard-kills after its grace period. Use exec form: CMD ["bun", "run", "indexer.ts"].
DATABASE_URL is the consumer's own Postgres, separate from the instance's. Any Postgres works; keep it in the same region, since the loop commits a transaction per batch, so round-trip latency sets the backfill ceiling.
Backfill depth is your instance's history
fromHeight: 0 reaches as far back as your instance holds. Extending the instance below its bootstrap floor is metered archive credits; consuming history it already has costs nothing.