Skip to content
Lakefront
Esc
↑↓navigate↵open⌘Jpreview
On this page

Run a cluster

How to deploy a Lakefront cluster, N server nodes and a router sharing one catalog Postgres and one object store, plus a local compose demo of the same topology.

A cluster is N server processes, each either a call to serve() or the container image configured by LAKEFRONT_* environment variables, all pointing at the same catalog Postgres and the same S3-compatible object store. A router sits in front of them and picks which node answers each board. None of this is specific to any particular Postgres or object-store host: bring your own catalog Postgres, and any S3-compatible store works.

Every node attaches the same lake, so every node can serve any board. The router just decides which one should, most of the time.

Configure a node

Every node, whether started with serve() directly or via the container entrypoint, needs the same catalog Postgres (LAKEFRONT_POSTGRES / OpenOptions.postgres) and the same object store prefix (LAKEFRONT_DATA / OpenOptions.data, plus LAKEFRONT_S3_* credentials when that prefix is s3://). Every node in one cluster also needs the same LAKEFRONT_NAMESPACE / OpenOptions.namespace, so they attach to one catalog rather than several. The full set of variables and the option each maps to is in the environment variable reference; the container entrypoint reads them, and embedded use passes the same fields as ServeOptions directly.

topology defaults to "clustered", which is what every node in this guide should run: it polls the lake head in the background so cross-board reads stay bounded-fresh. "single-node" asserts this process is the only writer against the lake and turns that polling off; running two "single-node" engines against one lake is a correctness bug, not a performance trade, so it has no place in a multi-node deployment.

Set writePath to wal

writePath: "wal" (the engine’s default; LAKEFRONT_WRITE_PATH=wal in the container) gives every node a shared, ordered log to sync from, and it is what a multi-node deployment requires. Under writePath: "lake", a write commits synchronously to the lake on whichever node accepted it: durable, but a peer node has no WAL tail to read, so it only sees the write once its own head-refresh poll catches the new lake snapshot. Freshness on any node is a WAL property, not a lake one.

Point the router at the nodes

The router needs a node list, LAKEFRONT_NODES (a comma-separated id=url list) or RouteOptions.nodes embedded, plus LAKEFRONT_HEDGE_MS / RouteOptions.hedgeDelayMs for the hedge delay below. It picks a node per board with rendezvous hashing, so the same board lands on the same node call after call: cache affinity, with no central assignment table to keep in sync.

  • Hedging: if the primary hasn’t answered within hedgeDelayMs, it fires the same read at the next-ranked node and takes whichever answers first.
  • Failover: idempotent procedures, and mutations carrying an idempotencyKey, fail over to the next healthy node on a transport failure, because a WAL-backed retry answers with the original ack rather than double-applying.

The hashing formula, the hedging and failover rules, and why they are safe are covered in full in the routing rules.

Freshness across nodes

Because every node syncs from the same WAL, a read routed to any node, hedged, failed over, or just the primary, can honor a minWatermark from a write that landed on a different node. A write acked through one node is immediately readable at that watermark from every node, including ones the router would never pick for that board. See the consistency model for the watermark model this rests on.

Node loss

Stopping a node does not lose anything, because the node’s local replica file was only ever a cache: the boards it owned re-route to a survivor on the next request, which rebuilds them from the object store plus whatever WAL tail the flusher hadn’t drained yet. Recovery does not depend on the dead node’s disk.

Try it locally

The repo ships docker/compose.yaml as a demo of this same topology: three server nodes, a router, a catalog Postgres, and MinIO standing in for the object store. It exists so the topology above is easy to try locally; bring your own Postgres and S3-compatible store for anything real.

Service Role
pg Postgres 17: the catalog, and under writePath: "wal" the WAL table too
minio S3-compatible object store standing in for the lake’s Parquet files
minio-init One-shot job that creates the lakefront bucket once MinIO is healthy
node-a / node-b / node-c Server nodes
router Rendezvous-hash router in front of the three nodes

The catalog Postgres is available on host port 55432.

bun run cluster:up    # docker compose up -d --build
bun run cluster:logs  # follow every service's logs
bun run cluster:down  # tear down and remove volumes

Once the cluster reports healthy, run bun run demo to exercise routing and node failover. The demo stops node-b to demonstrate recovery.

Run the demo against a cluster that is already up. It targets http://127.0.0.1:58000 by default, or LAKEFRONT_ROUTER/an argv override for anything else.

Last updated on September 10, 2026

Was this page helpful?