Worker
A worker claims queued runs and executes them. Run as many as you like; they coordinate through the shared store.
$ docker run -d -v flowwright-data:/data --no-healthcheck \
flowwrightdev/flowwright /app/apps/worker/dist/main.js
It needs to reach the same state the server uses — the same FW_DATA_DIR volume on
SQLite, or the same FW_DATABASE_URL and FW_ARTIFACTS_S3_BUCKET on Postgres and S3 —
and the same FW_SECRET_KEY, because it decrypts credentials.
Concurrency
FW_CONCURRENCY (default 2) is how many stages one worker runs at once. Total capacity
is workers × concurrency.
FW_WORKERS is accepted as an alias and wins silently if both are set. Pick one.
Labels and capabilities
Labels are what you assert about a worker: linux, gpu, prod. Capabilities
are what it can do — platform and architecture are detected automatically, and
FW_WORKER_CAPABILITIES adds more.
FW_WORKER_LABELS=linux,gpu
FW_WORKER_CAPABILITIES=docker
A project can require labels or capabilities, and only a matching worker claims its runs. That's how you keep a GPU job on the GPU box.
What the published image can execute
The image ships no docker binary. Container stages therefore fall back to running
on the host inside the worker container, and the worker never advertises the docker
capability.
This is the thing most likely to surprise you. A pipeline whose stages declare
container: "node:24" will run — just not in node:24, and not with that image's
toolchain. If a stage needs a specific image, either build a worker image with the Docker
CLI and mount /var/run/docker.sock, or use the Kubernetes executor.
Set FW_DOCKER=required to make the worker refuse to start rather than degrade quietly.
That's the right setting if container isolation is load-bearing for you.
A worker container that mounts the Docker socket also needs group access to it, since the
container runs as uid 1000 — group_add: ["<host docker gid>"] in compose.
Credential providers
Credentials are resolved by the worker, just-in-time, immediately before the stage that declares them. The plan carries references only, never values — see Credentials.
External providers are declared in the worker's config file:
credentialProviders:
vault:
address: https://vault.example.com
token: { fromEnv: VAULT_TOKEN }
mount: kv
This belongs in the worker file, not the server's. The server has no credential
resolver. A top-level credentialProviders block in the server file is rejected — but
nested under security:, where you'd most likely put it, it is stripped in silence.
Creating credentials is Credentials; this file only says
where their values are read from.
Policy
A policy is declarative rules a plan must satisfy, checked before any stage runs. A violation is recorded as a durable run error and the run fails.
policy:
forbidShell: true
requireDigestPinnedImages: true
allowedContainers: ["node:24@sha256:..."]
productionLabels: ["prod"]
productionBranches: ["main"]
Or a JSON file via FW_POLICY_FILE. An inline policy: block wins over policyFile.
The rules
Checked against the execution plan:
| Rule | Effect |
|---|---|
requireStages | These stage ids or names must be present |
forbidStages | These must not be |
forbidCommands | These substrings may not appear in any step command |
forbidShell | No step may use the raw shell form |
allowedContainers | Stage containers must be in this list |
requireDigestPinnedImages | Containers must be image@sha256:…, not a mutable tag |
Checked against routing and trust:
| Rule | Effect |
|---|---|
allowedLabels / allowedCapabilities | A run may only require these |
requireLabels | Every run must require these |
requireDockerForContainers | A plan with container stages must require a container runtime |
productionLabels | Runs requiring one of these are branch/tag-gated |
productionBranches / productionTags | The allowed branches, or tag globs |
A run requiring a production label can only be claimed by a worker the server marks trusted for that label. A worker asserting the label itself doesn't qualify — trust is never self-declared, and it fails closed when trust is unknown.
Two limits worth knowing
forbidCommands scans commands, not file contents. For a file.write step the
scanned string is write <path>. Forbidding curl does not stop a stage writing a
script that contains curl and then running it.
productionLabels without productionBranches or productionTags skips branch and
tag gating entirely. The worker-trust gate still applies, so it doesn't fail open — but
it isn't the restriction you probably meant.
A typo is an error
The worker validates the policy at startup and refuses to boot on an unrecognised rule:
policy-invalid: unknown config key(s): forbidCommand
This used to be accepted and enforce nothing, which is the worst outcome for a security control: you believe you're covered and you aren't. If the worker starts, every rule you wrote is a rule the engine knows.
Draining
worker:drain stops a worker claiming new runs while it finishes what it holds — the
polite way to take one out for an upgrade.