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/postgresPoint 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/postgresNote 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=latestRun 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 leadershipwhile 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_frequencymatter 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: earliestruns are indexer-bound; moremax_block_range/CU budget helps