Configuration

Configure Flo via flo.toml and CLI flags.

Flo is configured via a flo.toml file and CLI flags. CLI flags take the highest precedence: CLI flags > flo.toml > built-in defaults.

Config File

By default, Flo looks for flo.toml in the current directory. Override with --config:

flo server start --config /etc/flo/flo.toml

Generate a default config file with all options:

flo config init

Full Reference

[server]

[server]
port = 9000               # Client API port (binary protocol + WebSocket)
bind = "0.0.0.0"          # Bind address
data_dir = "~/.flo/data"  # Data directory for UAL segments and storage files
shards = 0                # Number of shards (0 = auto-detect CPU count)
# partition_count = 0     # Virtual partitions (0 = auto: max(4096, shards × 32))

shards and partition_count define the on-disk data layout. Cannot be changed after data exists without rebalancing.

[storage]

[storage]
durability = "async_flush"       # sync | async_flush | ephemeral
hot_buffer_capacity = 67108864   # Per-partition ring buffer size in bytes (64 MB)
# hot_flush_seconds = 300        # Max seconds before hot → warm flush (0 = disabled)
# max_hot_entries = 0            # Max entries in hot tier before eviction (0 = capacity only)
# max_local_segments = 100       # Max warm segments before archival to cold tier
# enable_wal_truncation = true   # Truncate WAL after safe segment flush

Durability modes:

ModeBehaviourAt risk on abrupt termination
syncSegment written and fsynced before the write is acknowledgedNothing
async_flushSegments written and fsynced by a background task, at most once per second — the defaultUp to ~1 second of acknowledged writes
ephemeralNever written to diskEverything

What async_flush risks, concretely. An acknowledged write lives only in memory until the next segment flush, which runs at most once per second. If the process is killed (SIGKILL, OOM kill, power loss, host failure) you can lose up to roughly the last second of acknowledged writes. Everything older is on disk, fsynced and published by atomic rename, so the store is never left torn or half-written — the loss is a clean tail truncation, not corruption.

A graceful shutdown (SIGTERM, docker stop, flo server stop) flushes before exiting, so a normal restart loses nothing. The one-second window only applies when the process dies without running its shutdown path.

:::note This window is not governed by hot_flush_seconds. That setting controls hot → warm tier migration, which is a storage-layout concern and does not affect what survives a crash. :::

[logging]

[logging]
level = "info"             # debug | info | warn | error

[auth]

[auth]
enabled = false
# jwt_secret = "your-256-bit-secret-key-here"
# jwks_url = "https://your-project.supabase.co/.well-known/jwks.json"

When enabled = true, all client connections require a valid JWT. Supports HS256 (shared secret) and RS256/ES256 (JWKS URL with key rotation).

[websocket]

[websocket]
rate_limit_requests = 1000    # Max requests per window (0 = unlimited)
rate_limit_window_ms = 1000   # Window size in milliseconds
ping_interval_ms = 30000      # Heartbeat ping interval (0 = disabled)
pong_timeout_ms = 10000       # Close connection if no pong within this time

[server] bind is the interface this node serves on — for clients and, in a cluster, for peers. 0.0.0.0 (the default) listens everywhere and lets peers reach the node at whatever address they see it from; a specific address is also what the node advertises to its peers. --bind overrides it on the command line.

[metrics]

[metrics]
enabled = true
# port = 0                # 0 = auto (listen_port + 1), so 9001 by default
# bind = "127.0.0.1"      # Localhost only by default

Binds only when enabled = true. For a Prometheus server scraping from outside the container, set bind = "0.0.0.0" — the default is loopback-only.

[dashboard]

[dashboard]
enabled = true
# port = 0                # 0 = auto (listen_port + 2), so 9002 by default
# bind = "127.0.0.1"      # Localhost only by default
# cors_origins = "http://localhost:5173"

:::note bind defaults to 127.0.0.1. In an orchestrated deployment a readiness probe originating outside the container cannot reach a loopback listener, so the default presents as a service that never becomes ready. Set bind = "0.0.0.0" where the port is not publicly routable. See Docker → Health Check. :::

[cluster]

[cluster]
enabled = false              # true starts the first member of a cluster (same as --cluster)
# secret = "..."             # required whenever the peer port is bound; same value on every node
# node_id = 1
# raft_port = 0              # 0 = auto (listen_port + 500)
# seeds = ["192.168.1.10:9500"]  # peer ports of members to join (same as --join)
# failover_timeout_ms = 1500 # a leader unheard for this long is replaced; minimum 100

secret is what a node proves it holds before another node treats it as a peer: the Raft port moves terms, membership and log contents, so it is never open. Every node in a cluster carries the same value; a node with a different one is refused at the handshake and both sides log it. The secret itself never crosses the wire. Generate one with openssl rand -base64 32. It can also come from the FLO_CLUSTER_SECRET environment variable, for containers without a config file. Starting the Raft listener without it is a startup error that names the key.

node_id is read on first boot and then stored in the data dir, which wins on every later boot; to change a node's id, start it from an empty data dir.

The peer port is bound only when this node can have peers: enabled = true starts the first member of a cluster, seeds joins members that exist, and the two are not combined. A plain single-node server does not listen on listen_port + 500, so there is no undeclared port to account for; a raft_port set without either role is refused at start. The startup banner reports which applies.

failover_timeout_ms is the one timing setting: a leader unheard for between half and all of it is replaced, heartbeats go out at a sixth of it. These are the only keys [cluster] reads; any other key is refused at start, not silently ignored. See Clustering.

[cold_storage]

[cold_storage]
# provider = "none"       # none | file | s3
# upload_workers = 2
# restore_workers = 4
 
# [cold_storage.file]
# base_path = "/var/lib/flo/archive"
# sync_on_write = true
 
# [cold_storage.s3]
# bucket = "my-flo-cold-storage"
# region = "us-east-1"
# endpoint = ""           # For S3-compatible services (MinIO, R2)
# use_path_style = false  # Set true for MinIO
# use_tls = true

CLI Overrides

Common options can be set directly on the command line. These override everything:

flo server start \
  --port 4444 \
  --data-dir ./my-data \
  --shards 4 \
  --partitions 128 \
  --log-level debug \
  --log-format json \
  --metrics-port 9101 \
  --dashboard-port 9102 \
  --no-metrics \
  --no-dashboard

Clustering flags — the first member, then one that joins it:

flo server start --cluster
flo server start --join 192.168.1.10:9500