Server
The control plane: HTTP API, web UI, run queue, and the system of record. One per deployment. This page is the daemon; using the web app it serves is its own section.
$ docker run -d -p 4317:4317 -v flowwright-data:/data flowwrightdev/flowwright
The request pipeline
request → rate limit → authentication → permission → controller → domain service
Auth and permissions are covered on Authentication.
The rate limit is FW_RATE_LIMIT requests per FW_RATE_WINDOW_MS, or off.
The web UI is served from the same port as the API. If the UI assets are missing the
server still answers /api/health — a green health check does not prove the UI shipped.
Stores
Two independent choices, each one environment variable.
State — runs, projects, users, the queue:
| Selected by | Where | |
|---|---|---|
| SQLite | default | <dataDir>/state.db |
| PostgreSQL | FW_DATABASE_URL set | Your server |
Artifacts, logs and cache:
| Selected by | Where | |
|---|---|---|
| Filesystem | default | <dataDir>/runs |
| S3 | FW_ARTIFACTS_S3_BUCKET set | Your bucket |
They're orthogonal — Postgres with filesystem artifacts is valid, if every worker shares the filesystem. The reason to move both is to stop needing a shared filesystem at all.
Both settings must match on the server and every worker. They're also the two keys where the environment beats the config file.
In-process workers
FW_WORKERS (default 2) runs that many workers inside the server process. Fine for a
single-host deployment and simpler than a second container.
Set it to 0 to make the server enqueue-only and run
workers separately — what the compose file does, and what
lets you scale execution without scaling the API.
Admin endpoints
All require settings:admin.
| Endpoint | Does |
|---|---|
POST /api/admin/reload | Re-read the config file — same as SIGHUP |
GET /api/admin/backup | Download a SQLite snapshot |
POST /api/admin/gc | Run retention garbage collection now |
Plus GET /api/metrics, which accepts either an authenticated principal or
Authorization: Bearer $FW_METRICS_TOKEN — so a scraper needs a token, not an account.
Health
GET /healthz and GET /api/health are the same handler and both public — no
credentials.
{
"status": "ok",
"uptimeMs": 1500,
"schemaVersion": 49,
"serverProtocol": 1,
"version": "0.1.0",
"checks": { "store": "ok" }
}
The check is a cheap store read. A store that's unreachable gives 503 and
"status": "degraded". It says nothing about workers, queue depth or disk — see
Operations.
Behind a proxy
Terminate TLS in front and set infra.externalBaseUrl. Webhook and OAuth callback URLs —
the ones you paste into a GitHub App or GitLab OAuth
app — are derived from that value and never
from request headers, so a wrong value produces
callbacks pointing at the wrong host rather than a header-spoofing hazard.
The session cookie's Secure flag follows the forwarded protocol, so the proxy needs to
send x-forwarded-proto.