<!-- source: https://www.secondlayer.tools/docs/deploy -->

# Deploy your consumer

Run your consumer anywhere that can reach your instance: same box, Railway, Render, or Fly, on one Dockerfile.

## What you're deploying

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](/docs/index#build-your-index-on-it); the checkpoint, transaction, and reorg contract live in [Sinks](/docs/sinks).

## Point it at your instance

Your instance is the source, so [run it first](/docs/self-host). The consumer needs a reachable `SECONDLAYER_API_URL`: the same-box address, or a tailnet or private-network host.

```bash
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](/docs/self-host#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](#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:

```ts
.onConflict((oc) => oc.column("tx_id").doNothing())
```

A [sink](/docs/sinks) 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.

```ts
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](/docs/sinks#health-and-shutdown). 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.

```ts
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

```yaml 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"]
```

```bash
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:

```ini 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](#handle-sigterm) 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](/docs/authentication#archive-fetches); consuming history it already has costs nothing.

## Notes for agents

- A consume() loop needs: a Dockerfile, a /health route bound to PORT, and a SIGTERM handler. Pick the matching recipe (Railway, Render, Fly, Vercel cron, Docker).
- Production checks: writes are idempotent (conflict rule on every insert); rows and the checkpoint commit in one transaction; an AbortSignal is wired to SIGTERM so the in-flight batch commits before exit.
- Dockerfile: install deps from the lockfile, bind the health server to PORT, use exec-form CMD so the process receives SIGTERM as PID 1 instead of a shell swallowing it.

## Setup

Ensure setup once, skipping any step already done:
- Skill: `bunx skills add ryanwaits/secondlayer --skill secondlayer -y`
- CLI: `bun add -g @secondlayer/cli`
- Instance: `secondlayer init` writes `INSTANCE_TOKEN`. Loopback `/v1` reads need no key.
- Hosted/archive: export `SECONDLAYER_API_KEY` (`sk-sl_*`) or buy credits with `secondlayer credits buy` (`balance`, `refill`), then `secondlayer whoami`
