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 SECONDLAYER_API_URL: the same-box address, or a tailnet or private-network host.
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
| Target | Process model | The one thing to get right |
|---|---|---|
| Same box | Compose service or systemd unit | Zero network hop. Docker and EC2 |
| 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.
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:
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.
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.
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
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:
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.