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. :::

PortPurpose
9000Binary wire protocol (client traffic)
9001Prometheus metrics — only when [metrics] enabled = true
9002Dashboard 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:

VariableDefaultDescription
FLO_DATA_DIR/data/floData directory the entrypoint prepares for the flo user
FLO_CLUSTER_SECRETThe 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 .