Docker Deployment
Run Flo with Docker and Docker Compose.
Docker Image
docker pull ghcr.io/floruntime/flo:latest
Quick Start
docker run -d \
--name flo \
--security-opt seccomp=unconfined \
-p 9000:9000 \
-p 9001:9001 \
-p 9002:9002 \
-v flo-data:/data/flo \
ghcr.io/floruntime/flo:latest
:::note
--security-opt seccomp=unconfined is required on Linux hosts. Flo's event
loop uses io_uring, which Docker's default seccomp profile blocks — without it
the server starts, logs topology manifest created, and then exits. See
io_uring and seccomp for the details and for a
narrower profile.
:::
| Port | Purpose |
|---|---|
| 9000 | Binary wire protocol (client traffic) |
| 9001 | Prometheus metrics — only when [metrics] enabled = true |
| 9002 | Dashboard REST API — only when [dashboard] enabled = true |
Flo does not listen on any other port in a single-node deployment. In
particular the Raft port (listen_port + 500, so 9500) is bound only when the
node is actually clustered — see Clustering.
io_uring and seccomp
On Linux, Flo's event loop is built on io_uring. Docker's default seccomp
profile does not allow the io_uring_setup, io_uring_enter and
io_uring_register syscalls, so under that profile Flo cannot start:
INF topology manifest created: shards=1 partitions=0 path=/data
ERR io_uring is unavailable (PermissionDenied). ...
Error starting runtime: error.PermissionDenied
The simplest fix is to run unconfined:
--security-opt seccomp=unconfined
If you would rather not drop the whole profile, copy Docker's default profile and add the three syscalls to its allowlist:
{
"names": ["io_uring_setup", "io_uring_enter", "io_uring_register"],
"action": "SCMP_ACT_ALLOW"
}
Then run with --security-opt seccomp=/path/to/flo-seccomp.json.
:::note io_uring requires Linux 5.1 or newer. On older kernels Flo cannot start at all, and no seccomp profile will help. Kubernetes, Podman and containerd apply their own seccomp defaults — the same allowance is needed there. :::
Docker Compose — Single Node
version: "3.8"
services:
flo:
image: ghcr.io/floruntime/flo:latest
security_opt:
- seccomp:unconfined
ports:
- "9000:9000"
- "9001:9001"
- "9002:9002"
volumes:
- flo-data:/data/flo
restart: unless-stopped
volumes:
flo-data:
Docker Compose — 3-Node Cluster
The first member starts with --cluster; the others join it at its peer port
(listen_port + 500). Every member proves the same secret, here from the
environment. The image's own config (/etc/flo/flo.toml) stays on the command
line: it is what puts the data under the mounted volume and binds the metrics
and dashboard ports to every interface. A cluster replicates one shard, and
the image's automatic shard count resolves to one.
version: "3.8"
x-flo: &flo
image: ghcr.io/floruntime/flo:latest
security_opt:
- seccomp:unconfined
environment:
- FLO_CLUSTER_SECRET=change-me-same-on-every-node
restart: unless-stopped
services:
flo-1:
<<: *flo
hostname: flo-1
command: ["server", "start", "-c", "/etc/flo/flo.toml", "--cluster"]
ports:
- "9000:9000"
- "9001:9001"
- "9002:9002"
volumes:
- flo-1-data:/data/flo
flo-2:
<<: *flo
hostname: flo-2
command: ["server", "start", "-c", "/etc/flo/flo.toml", "--join", "flo-1:9500"]
ports:
- "9010:9000"
- "9011:9001"
- "9012:9002"
volumes:
- flo-2-data:/data/flo
flo-3:
<<: *flo
hostname: flo-3
command: ["server", "start", "-c", "/etc/flo/flo.toml", "--join", "flo-1:9500"]
ports:
- "9020:9000"
- "9021:9001"
- "9022:9002"
volumes:
- flo-3-data:/data/flo
volumes:
flo-1-data:
flo-2-data:
flo-3-data:
Members find each other on the Compose network by service name; the peer port
is never published. A joiner started before flo-1 resolves exits, and
restart: unless-stopped brings it back. A restarted member keeps the same
command line: its membership comes from its volume. flo cluster status
against any published port shows the role of that node.
Environment Variables
The image reads two variables; everything else comes from flo.toml (mounted
at /etc/flo/flo.toml) or from flags on the command line:
| Variable | Default | Description |
|---|---|---|
FLO_DATA_DIR | /data/flo | Data directory the entrypoint prepares for the flo user |
FLO_CLUSTER_SECRET | — | The shared secret every member proves at the peer port |
Health Check
For a liveness probe inside the container, use the dashboard's /health
(the image's own HEALTHCHECK does the same):
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:9002/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
For an orchestrator readiness probe that comes from outside the container,
use GET /health on the dashboard port (see
REST API) — and set the dashboard's bind
address, because it defaults to localhost only:
[dashboard]
enabled = true
bind = "0.0.0.0"
:::note
[dashboard] bind defaults to 127.0.0.1. A probe originating outside the
container cannot reach a listener bound to loopback, so leaving the default in
an orchestrated deployment presents as a service that never becomes ready —
which reads like a broken health check rather than a bind address. Bind to
0.0.0.0 only where the dashboard port is not publicly routable.
:::
Volumes and Data Persistence
The image's config puts data under /data/flo; mount a volume there so it
survives container restarts:
docker volume create flo-data
docker run -v flo-data:/data/flo ghcr.io/floruntime/flo:latest
The data directory structure:
/data/flo/
└── 00000/ # one directory per shard
├── segs/ # the durable log
├── snaps/ # projection snapshots
├── HARDSTATE # the node's id and its Raft term and vote
└── MANIFEST
Building the Image
cd flo
docker build -t flo:custom .