Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Self-hosting

rflow is one binary plus one Postgres. There is nothing else to run.

Ports

rflow exposes one port — config.port (default 3940 in the scaffold), serving health/status. The embedded engines are internal: rindexer's health server does not bind, and rrelayer's API is off unless you opt in via config.relayer_api. Expose 3940 to your monitoring; expose nothing else.

Securing the port

By default the port is unauthenticated — fine on loopback or a private network, not for shared clusters or anything internet-adjacent. Two ways to harden it, usually combined:

Bearer auth (built in). Set config.server to require Authorization: Bearer <token> on the viewer / and /api/*:

config:
  server:
    bind: 127.0.0.1:3940                       # keep the port on loopback behind a proxy
    auth:
      token: "${{ secrets.RFLOW_API_TOKEN }}"
    cors:
      origins: ["https://ops.example.com"]     # allow your dashboard's origin

/health stays open for k8s probes (health_public defaults true); /metrics follows auth unless metrics_public: true. Mint extra tokens with rflow token create and revoke leaked ones without a restart. /hooks/* webhook routes keep their own HMAC auth:.

Reverse proxy + TLS. rflow serves plain HTTP; terminate TLS at a proxy (nginx, Caddy, Traefik, or a cloud load balancer / k8s Ingress) in front of it. Bind rflow to loopback (bind: 127.0.0.1:3940) so only the proxy can reach it, and let the proxy handle certificates and (optionally) an extra auth layer. The chart's ingress values wire this up on Kubernetes.

DATABASE_URL

All state lives in Postgres, configured once via config.db_connection — conventionally:

DATABASE_URL=postgresql://postgres:rflow@localhost:5448/postgres

Point it at any Postgres 14+ (managed is fine). rflow applies its schema on boot, takes a per-project advisory lock (so a stray second replica exits instead of double-running), and the embedded engines get the same connection injected. Backing up this database backs up everything.

Docker compose

rflow new scaffolds a compose file for local Postgres. A full self-contained deployment looks like:

volumes:
  postgres_data:
    driver: local
 
services:
  postgresql:
    image: postgres:16
    shm_size: 1g
    restart: always
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - 5448:5432
    env_file:
      - .env
 
  rflow:
    image: ghcr.io/joshstevens19/rflow:latest
    restart: always
    depends_on:
      - postgresql
    command: ["start", "--path", "/app/project"]
    volumes:
      - ./:/app/project
    ports:
      - 3940:3940
    env_file:
      - .env
    environment:
      DATABASE_URL: postgresql://postgres:rflow@postgresql:5432/postgres

Note the DATABASE_URL override: inside the compose network Postgres is postgresql:5432, not localhost:5448.

Helm

The repo ships a chart at helm/rflow (image ghcr.io/joshstevens19/rflow, service port 3940). Your rflow.yaml goes in values.yaml as rflowConfig, mounted into projectPath; secrets (DATABASE_URL, RPC urls, tokens) come from env — wire them to your secret manager.

helm install my-rflow ./helm/rflow \
  --set-file rflowConfig=./rflow.yaml \
  --set image.tag=latest

Run one replica per project: rflow is a stateful singleton by design, and the advisory lock enforces it — a second replica will refuse to start against the same project + database (unless it runs as a standby).

To turn on bearer auth, mount the token from a Secret with the chart's apiToken values and reference it from rflowConfig:

apiToken:
  enabled: true
  secretName: rflow-secrets     # kubectl create secret generic rflow-secrets --from-literal=rflow-api-token=...
  secretKey: rflow-api-token    # injected as RFLOW_API_TOKEN
# in rflowConfig:
config:
  server:
    auth:
      token: "${{ secrets.RFLOW_API_TOKEN }}"
secrets:
  RFLOW_API_TOKEN: ${RFLOW_API_TOKEN}

High availability (leader/standby)

For failover, run extra replicas as standbys:

rflow start --standby       # or in rflow.yaml:
config:
  ha:
    standby: true           # this instance waits instead of exiting on LockHeld
    takeover_poll: 5s       # how often it re-probes the lock (default 5s)

How it works:

  • exactly one leader executes; it holds the per-project Postgres advisory lock for the lifetime of its session
  • standbys validate, connect and then park before the lock, logging standby: waiting for leadership while they poll
  • when the leader dies — clean shutdown, kill -9, node loss, network partition dropping its database session — Postgres frees the session-scoped lock and a standby wins it within one poll interval
  • the promoted standby runs the normal boot continuation: the journal recovery pass first (resuming/adopting whatever the dead leader left in flight), engines and triggers after — the same exactly-once path as any restart

Fencing: the advisory lock IS the fence — two executors can never run the same project simultaneously, but only because every instance points config.db_connection at the same Postgres. Separate databases means separate locks and two live executors: don't. The leader also watches its own lock session: if that dedicated database session dies while the process is still alive (a Postgres restart, a load balancer dropping the idle connection), the server has already freed the lock — so the leader exits immediately rather than keep executing unfenced, and the standby's takeover boot resumes everything from the journal.

rflow status shows the leadership state (leader running / no leader, via a lock probe).

Graceful shutdown

Send SIGTERM (docker stop / kubernetes termination do this) and rflow stops accepting triggers, brings in-flight steps to a journal-consistent point, drains the relayer, and exits. A hard kill is also safe — see Reliability — recovery resumes from the journal on next boot.

Sizing

rflow is a single tokio process doing in-process function calls; CPU needs are modest (the helm defaults request 100m/128Mi). What actually matters:

  • RPC quality — latency to your provider dominates end-to-end reaction time; a fast HTTP provider and a tight block_poll_frequency matter most (ws: is parsed but not wired to the engines yet)
  • Postgres — every run/step is journaled; give it real storage, and back it up
  • Backfill — historical start_block: earliest runs are indexer-bound; more max_block_range/CU budget helps