Skip to main content

Docker Compose

$ docker compose up -d # server + 1 worker
$ docker compose up -d --scale worker=4 # server + 4 workers

Open http://localhost:4317. The server logs a one-time admin password on first boot — see Authentication.

The file

compose.yml
services:
flowwright:
image: flowwrightdev/flowwright:${FLOWWRIGHT_VERSION:-latest}
ports:
- "4317:4317"
volumes:
- data:/data
environment:
FW_WORKERS: "0"
command: ["/app/apps/server/dist/main.js"]
restart: unless-stopped

worker:
image: flowwrightdev/flowwright:${FLOWWRIGHT_VERSION:-latest}
depends_on:
- flowwright
volumes:
- data:/data
environment:
FW_CONCURRENCY: "2"
healthcheck:
disable: true
command: ["/app/apps/worker/dist/main.js"]
restart: unless-stopped

volumes:
data:

Four things in there are load-bearing.

FW_WORKERS: "0" makes the server enqueue-only. Without it the server would also run runs in-process, and you'd have two things competing for the same queue. Delegating is what lets you scale workers independently.

Both services mount the same data volume. On SQLite that volume is the queue — the worker claims runs by writing to the same database. It also has to write run logs where the server will read them.

healthcheck: disable: true on the worker. The image's healthcheck probes /healthz, and the worker serves no HTTP. Without this it reports unhealthy forever.

restart: unless-stopped hides crash loops. A worker that dies on startup and restarts still shows as running in docker compose ps. Check the logs for a workerId line to know it actually came up.

Pinning a version

$ FLOWWRIGHT_VERSION=0.1.0 docker compose up -d

latest is fine for evaluating and a bad idea for anything you depend on — an image update lands on your next docker compose pull.

Upgrading to the non-root image

The container runs as uid 1000, not root. A volume created by an earlier image is root-owned, and the server will refuse to start:

Error: unable to open database file

That's loud on purpose. One command fixes it:

$ docker compose down
$ docker volume ls # find the real name
$ docker run --rm -v flowwright_data:/data alpine chown -R 1000:1000 /data
$ docker compose up -d
Get the volume name right

Compose prefixes volume names with the project — the directory name — so it's flowwright_data, not data. Run docker volume ls first: pointing the command at a name that doesn't exist silently creates an empty volume and chowns that, and you'll be looking at the same error afterwards.

A new volume needs nothing. Docker initialises an empty volume from the image's mount point, ownership included.

For a bind mount, ownership comes from the host — sudo chown -R 1000:1000 ./data.

If you need to defer the migration, user: "0:0" on the service restores the old behaviour. Set it on the server and every worker: a root worker writing run directories a non-root server can't unlink means garbage collection quietly stops working.

Scaling workers

$ docker compose up -d --scale worker=4

Each worker runs FW_CONCURRENCY stages at once, so four workers at 2 is eight concurrent stages. Scale on the box you have; on SQLite they all need the same volume, so scaling past one host means moving to PostgreSQL and S3.

Secrets

Set FW_SECRET_KEY explicitly for anything real — 32 bytes as 64 hex characters or base64. Left unset, the server generates one into the data volume, which means your encryption key lives next to the data it encrypts.

The same value must be set on the server and every worker. They both decrypt credentials.

environment:
FW_SECRET_KEY: "${FW_SECRET_KEY:?set FW_SECRET_KEY}"

The :? form fails the up with a message instead of silently generating a key.

What compose doesn't give you

No TLS — put it behind a reverse proxy and set infra.externalBaseUrl so webhook and OAuth callback URLs are right. No backups; see Operations. And no container-stage execution — the image ships no docker binary, so container stages fall back to the host. That's on Worker.