Operate / Deploy your app

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:

FileDoes
indexer.tsThe consume() loop: onBatch writes rows, onReorg rolls them back
schema.tsYour tables, plus the checkpoint that makes a restart free
health.tsGET /health on PORT, so the platform can tell live from wedged
DockerfileOne 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 loopback

Loopback 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.

TargetProcess modelThe one thing to get right
Same boxCompose service or systemd unitZero network hop. Docker and systemd
RailwayPersistent containerrailway.json with healthcheckPath: "/health"; PORT arrives injected
RenderPersistent containertype: web, never Background Worker: workers get no health check and no PORT; keep the public route read-only
FlyPersistent VMauto_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.