Deployment Guide

April 7, 2026 · View on GitHub

toll-booth runs anywhere Node.js runs. This guide covers common deployment patterns, from Docker Compose to serverless edge functions.

Deployment architecture

graph TB
    subgraph "Option A: Sidecar"
        A_LB[Load balancer / reverse proxy]
        A_TB[toll-booth<br/>Express or Hono]
        A_API[Your API<br/>any language]
        A_LB --> A_TB --> A_API
    end

    subgraph "Option B: Embedded"
        B_LB[Load balancer / reverse proxy]
        B_APP[Your Express/Hono app<br/>with toll-booth middleware]
        B_LB --> B_APP
    end

    subgraph "Option C: Edge / Serverless"
        C_CDN[CDN / Edge network]
        C_TB[toll-booth<br/>Web Standard adapter]
        C_API[Upstream API]
        C_CDN --> C_TB --> C_API
    end

Sidecar - toll-booth runs as a separate process in front of your API. Best when the upstream is written in another language (Go, Python, C++, etc.). See valhalla-proxy for a complete example.

Embedded - toll-booth middleware is wired directly into your Express or Hono application. Best when you're already running a Node.js/Bun server. Fewer moving parts.

Edge / Serverless - toll-booth runs on Cloudflare Workers, Deno Deploy, or similar platforms using the Web Standard adapter. Requires Cashu-only mode (no persistent Lightning node) or an external Lightning backend accessible via HTTP.


Docker Compose

The most common production deployment. See examples/valhalla-proxy/ for the complete reference.

services:
  toll-booth:
    build: .
    restart: always
    ports:
      - "0.0.0.0:3000:3000"
    environment:
      - PHOENIXD_URL=http://phoenixd:9740
      - PHOENIXD_PASSWORD=${PHOENIXD_PASSWORD}
      - ROOT_KEY=${ROOT_KEY}
      - TOLL_BOOTH_DB_PATH=/data/toll-booth.db
      - TRUST_PROXY=true
    volumes:
      - toll-booth-data:/data
    depends_on:
      - phoenixd

  phoenixd:
    image: ghcr.io/acinq/phoenixd:latest
    restart: always
    volumes:
      - phoenixd-data:/root/.phoenix
    command: ["--agree-to-terms-of-service", "--http-bind-ip", "0.0.0.0"]

volumes:
  toll-booth-data:
  phoenixd-data:

Key points:

  • Mount a Docker volume for TOLL_BOOTH_DB_PATH so the SQLite database survives container restarts
  • Keep Phoenixd's port on 127.0.0.1 (or internal Docker network only); it should not be publicly accessible
  • Set TRUST_PROXY=true if running behind a load balancer or reverse proxy

Dockerfile

FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY dist/ ./dist/
EXPOSE 3000
CMD ["node", "dist/server.js"]

Reverse proxy (nginx / Caddy)

When running toll-booth behind a reverse proxy, configure it to forward client IPs and set trustProxy: true in toll-booth.

nginx

server {
    listen 443 ssl http2;
    server_name api.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Rate-limit invoice creation
    location = /create-invoice {
        limit_req zone=invoices burst=5 nodelay;
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Caddy

api.example.com {
    reverse_proxy localhost:3000
}

Caddy automatically provisions TLS and sets X-Forwarded-For.


Cloudflare Workers (Cashu-only)

Serverless deployment using the Web Standard adapter. No Lightning node required; payments are accepted via Cashu ecash tokens.

import { Booth } from '@forgesworn/toll-booth'

const booth = new Booth({
  adapter: 'web-standard',
  redeemCashu: async (token, paymentHash) => {
    // Call your Cashu mint's API to verify and redeem the token
    const res = await fetch('https://mint.example.com/v1/melt', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ token }),
    })
    const data = await res.json()
    return data.amount
  },
  pricing: { '/api': 5 },
  upstream: 'https://your-api.example.com',
  storage: memoryStorage(), // Workers have no filesystem; use in-memory
})

export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url)
    if (url.pathname === '/cashu-redeem' && request.method === 'POST')
      return booth.cashuRedeemHandler(request)
    if (url.pathname.startsWith('/invoice-status/'))
      return booth.invoiceStatusHandler(request)
    if (url.pathname === '/create-invoice' && request.method === 'POST')
      return booth.createInvoiceHandler(request)
    return booth.middleware(request)
  },
}

Limitations:

  • No SQLite; use memoryStorage() (credits are lost on cold start) or implement a custom StorageBackend backed by Durable Objects or KV
  • No persistent Lightning backend; Cashu-only or an external backend accessible via HTTP
  • Worker memory limits apply; monitor credit map size

Deno Deploy

import { Booth } from '@forgesworn/toll-booth'
import { lndBackend } from '@forgesworn/toll-booth/backends/lnd'

const booth = new Booth({
  adapter: 'web-standard',
  backend: lndBackend({
    url: Deno.env.get('LND_REST_URL')!,
    macaroon: Deno.env.get('LND_MACAROON')!,
  }),
  pricing: { '/api': 10 },
  upstream: Deno.env.get('UPSTREAM_URL')!,
})

Deno.serve({ port: 3000 }, async (req: Request) => {
  const url = new URL(req.url)
  if (url.pathname.startsWith('/invoice-status/'))
    return booth.invoiceStatusHandler(req)
  if (url.pathname === '/create-invoice' && req.method === 'POST')
    return booth.createInvoiceHandler(req)
  return booth.middleware(req)
})

Deno Deploy can reach an external LND node over HTTPS. Use memoryStorage() or implement a custom backend for persistence.


Bun

import { Booth } from '@forgesworn/toll-booth'
import { phoenixdBackend } from '@forgesworn/toll-booth/backends/phoenixd'
import { sqliteStorage } from '@forgesworn/toll-booth/storage/sqlite'

const booth = new Booth({
  adapter: 'web-standard',
  backend: phoenixdBackend({
    url: process.env.PHOENIXD_URL!,
    password: process.env.PHOENIXD_PASSWORD!,
  }),
  pricing: { '/api': 10 },
  upstream: process.env.UPSTREAM_URL!,
})

Bun.serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url)
    if (url.pathname.startsWith('/invoice-status/'))
      return booth.invoiceStatusHandler(req)
    if (url.pathname === '/create-invoice' && req.method === 'POST')
      return booth.createInvoiceHandler(req)
    return booth.middleware(req)
  },
})

Bun supports SQLite natively. toll-booth's better-sqlite3 dependency works with Bun's Node.js compatibility layer.


Hono (multi-runtime)

Hono runs on Node.js, Deno, Bun, and Cloudflare Workers with the same code:

import { Hono } from 'hono'
import { createHonoTollBooth, type TollBoothEnv } from '@forgesworn/toll-booth/hono'
import { createTollBooth } from '@forgesworn/toll-booth'
import { phoenixdBackend } from '@forgesworn/toll-booth/backends/phoenixd'
import { sqliteStorage } from '@forgesworn/toll-booth/storage/sqlite'

const storage = sqliteStorage({ path: './toll-booth.db' })
const engine = createTollBooth({
  backend: phoenixdBackend({ url: 'http://localhost:9740', password: process.env.PHOENIXD_PASSWORD! }),
  storage,
  pricing: { '/api': 10 },
  upstream: 'http://localhost:8080',
  rootKey: process.env.ROOT_KEY!,
})

const tollBooth = createHonoTollBooth({ engine })
const app = new Hono<TollBoothEnv>()

app.route('/', tollBooth.createPaymentApp({
  storage,
  rootKey: process.env.ROOT_KEY!,
  tiers: [],
  defaultAmount: 1000,
}))

app.use('/api/*', tollBooth.authMiddleware)
app.get('/api/resource', (c) => c.json({ message: 'Paid content' }))

export default app

Choosing a Lightning backend

flowchart TD
    A{Need a Lightning node?}
    A -->|No| B[Cashu-only mode]
    A -->|Yes| C{Self-hosted?}
    C -->|Simplest setup| D[Phoenixd]
    C -->|Already running LND| E[LND backend]
    C -->|Already running CLN| F[CLN backend]
    C -->|Want hosted/shared| G[LNbits backend]
    C -->|Any NWC wallet| H[NWC backend]
BackendBest for
PhoenixdSimplest self-hosted option; auto-manages channels
LNDExisting LND infrastructure; industry standard
CLNExisting Core Lightning infrastructure
LNbitsShared or hosted Lightning; multi-tenant setups
NWCAny Nostr Wallet Connect wallet; no direct node access needed
Cashu-onlyServerless; no Lightning infrastructure at all

Monitoring

Use the event hooks for operational visibility:

const booth = new Booth({
  // ...
  onPayment: (event) => {
    metrics.increment('payments', { amount: event.amountSats })
  },
  onRequest: (event) => {
    metrics.histogram('request_latency', event.latencyMs)
    metrics.increment('requests', { auth: event.authenticated ? 'l402' : 'free' })
  },
  onChallenge: (event) => {
    metrics.increment('challenges', { endpoint: event.endpoint })
  },
})

No PII is included in events. See the security guide for details on what data is collected.


Horizontal scaling

Macaroon-based authentication is inherently stateless from a verification perspective. The only secret required to verify any macaroon is the rootKey. This makes toll-booth well-suited to horizontal scaling and distributed deployments.

What is stateless

Macaroon verification requires only the rootKey (a 32-byte shared secret). Every toll-booth instance that shares the same rootKey can verify any macaroon minted by any other instance. There is no session table, no token store, no central auth database. The macaroon itself carries all the information needed for verification: the HMAC signature chain, the payment hash, the credit balance, and any caveats. Verification is a pure cryptographic check against the root key.

Caveat enforcement (route, expires, ip) is evaluated against the current request context. No external state needed.

Preimage verification (SHA256 check) is a pure computation. No state needed.

What is stateful

Credit balances are stored in the StorageBackend (SQLite by default). This is the only component that requires shared state for multi-instance deployments.

Invoice records and Cashu claims are stored in the same backend for crash recovery and status polling.

Scaling strategies

                        ┌──────────────┐
                        │ Load balancer │
                        └──────┬───────┘
               ┌───────────────┼───────────────┐
               ▼               ▼               ▼
        ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
        │ toll-booth-1 │ │ toll-booth-2 │ │ toll-booth-3 │
        │ rootKey: ABC │ │ rootKey: ABC │ │ rootKey: ABC │
        └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
               └───────────────┼───────────────┘

                     ┌──────────────────┐
                     │ Shared storage   │
                     │ (external DB or  │
                     │  sticky sessions)│
                     └──────────────────┘

Single node (simplest): One toll-booth instance with SQLite. Handles thousands of requests per second. This is sufficient for most deployments.

Sticky sessions: Run multiple instances, each with its own SQLite database. Use session affinity at the load balancer (hash on the Authorization header or client IP) so each client always hits the same instance. No shared storage needed; each instance manages its own credit balances.

Shared storage: Implement a custom StorageBackend backed by PostgreSQL, Redis, or another shared data store. All instances share credit balances and invoice records. This is the most flexible approach but requires more infrastructure.

import { Booth } from '@forgesworn/toll-booth'

// Every instance uses the same rootKey and shared storage
const booth = new Booth({
  adapter: 'express',
  backend: phoenixdBackend({ url: '...', password: '...' }),
  rootKey: process.env.ROOT_KEY!,       // same across all instances
  storage: myPostgresStorage(),          // custom StorageBackend implementation
  pricing: { '/api': 10 },
  upstream: 'http://localhost:8080',
})

The StorageBackend interface is intentionally minimal (credit balance, invoice storage, Cashu claims). Implementing it against a shared database is straightforward; see src/storage/interface.ts for the full interface and src/storage/memory.ts for a reference implementation.

Key management for distributed deployments

All instances must share the same rootKey. A macaroon minted by instance A must be verifiable by instance B. Store the root key in a secrets manager (AWS Secrets Manager, HashiCorp Vault, Kubernetes Secrets) and inject it via environment variable. Never derive different keys per instance.

If you rotate the root key, all outstanding macaroons become invalid. Coordinate rotation with your credit tier durations, or implement a brief grace period by checking against both the old and new keys (this requires a thin wrapper around the Booth class).