Skip to main content

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.

note

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:

flowwright.worker.yaml
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.

flowwright.worker.yaml
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:

RuleEffect
requireStagesThese stage ids or names must be present
forbidStagesThese must not be
forbidCommandsThese substrings may not appear in any step command
forbidShellNo step may use the raw shell form
allowedContainersStage containers must be in this list
requireDigestPinnedImagesContainers must be image@sha256:…, not a mutable tag

Checked against routing and trust:

RuleEffect
allowedLabels / allowedCapabilitiesA run may only require these
requireLabelsEvery run must require these
requireDockerForContainersA plan with container stages must require a container runtime
productionLabelsRuns requiring one of these are branch/tag-gated
productionBranches / productionTagsThe 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.