.md

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 SECONDLAYER_API_URL: the same-box address, or a tailnet or private-network host.

$SECONDLAYER_API_URL=http://indexer.tailnet:3800 # tailnet or private network
$INSTANCE_TOKEN=… # required once the API is published past loopback

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 Instance token.

Pick a target

TargetProcess modelThe one thing to get right
Same boxCompose service or systemd unitZero network hop. Docker and EC2
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.

Make writes idempotent

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.

Bind a port

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.

Handle SIGTERM

Every redeploy arrives as SIGTERM; pass an AbortSignal so it stops cleanly.

consume() checks the signal 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. It swallows the signal: the loop never hears it, and the platform hard-kills after its grace period.

Use exec form: CMD ["bun", "run", "indexer.ts"].

Docker and EC2

No platform, no config schema: a container and something that restarts it.

Compose

docker-compose.yml
services:
indexer:
build: .
restart: unless-stopped
env_file: .env
ports: ["8080:8080"]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
depends_on: [postgres]
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: sales
volumes: ["./data/postgres:/var/lib/postgresql/data"]
$docker compose up -d
$docker compose logs -f indexer

restart: unless-stopped covers crashes and host reboots. Docker's own healthcheck reports status but won't restart a wedged container on its own, so pair it with a supervisor or an external probe if you want that.

Stop grace period

docker compose stop sends SIGTERM and kills after 10s by default.

A batch mid-commit against a slow database can exceed that, so raise it with stop_grace_period: 60s on the service.

Bare systemd

Skipping containers:

sales-index.service
[Unit]
After=network-online.target
[Service]
ExecStart=/usr/local/bin/bun run /srv/sales-index/indexer.ts
EnvironmentFile=/srv/sales-index/.env
Restart=always
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=60
[Install]
WantedBy=multi-user.target

Two settings let the shutdown handler finish its transaction before systemd escalates to SIGKILL:

  • KillSignal=SIGTERM
  • A generous TimeoutStopSec

Bring your database

DATABASE_URL is the consumer's own Postgres, separate from the instance's. Any Postgres works; keep it in the same region (or the same compose file), since the loop commits a transaction per batch, so round-trip latency sets the backfill ceiling. Point it at a managed instance when you'd rather not own backups.

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 credits; consuming history it already has costs nothing.

Edit on GitHub