Deploy your indexer
A consume() loop is a long-running process with a database and a checkpoint. Here's where to put it.
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 checkpoints row 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 |
Runnable original: sales-index. The loop itself lives in Index.
| Target | Process model | Config | Guide |
|---|---|---|---|
| Railway | Persistent container | railway.json | Railway |
| Render | Persistent container | render.yaml | Render |
| Fly | Persistent VM, scale-to-zero off | fly.toml | Fly |
| Vercel | Cron-invoked function, 300s cap | vercel.json | Vercel |
| Docker / EC2 | Your box, your supervisor | compose.yaml | Docker |
| Your own stack | Indexer and decoder too, not just the loop | docker/oss | Run the platform yourself |
Everything above the last row reads from hosted Secondlayer. The last row replaces it.
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())Rows and checkpoint commit in one transaction. With both, a kill mid-batch costs a repeated page — never a gap, never a double count.
Railway, Render, and Fly mark a service unhealthy unless something answers over HTTP. A bare loop binds nothing and gets restarted forever.
onBatch receives progress on its context, so liveness needs no bookkeeping of its own:
let last = { at: 0, ctx: null as ConsumerBatchContext | null };
// in onBatch, before any early return — an empty page still proves it's alive
last = { at: Date.now(), ctx };
Bun.serve({
port: Number(process.env.PORT ?? 8080),
fetch: () =>
Response.json({
ok: Date.now() - last.at < 120_000,
last_delivered_height: last.ctx?.height ?? null,
blocks_behind: last.ctx?.blocksBehind ?? null,
}),
});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 — that's the restartable signal. ctx.height holds across empty pages and rolls back on a reorg, so it never needs reconstructing from the rows.
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 — 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 whole contract — Secondlayer never sees your rows. Any Postgres works; the platform guides each name a local option.
Backfill needs a key
fromHeight: 0 reaches full history on a paid plan or pay-as-you-go credits. Keyless reads cover the last 24 hours, so an uncredited backfill returns 402 UPGRADE_REQUIRED below that window — see Authentication. Live-tail-only deploys need no key.