Containers and services
Running a stage in a container
stage("Test on Node 20", {
container: "node:20",
run: async () => {
await sh(["npm", "ci"]);
await sh(["npm", "test"]);
},
});
Each step runs in its own docker run --rm, with your workspace bind-mounted at
/workspace and set as the working directory. Files the commands write land in your
real working tree.
Stages without container still run on the host, so a pipeline can mix both.
The environment cliff
This is the difference that catches people:
| Host step | Container step | |
|---|---|---|
| Inherits your shell's environment | Yes | No |
$PATH, $HOME, $CI, $NODE_ENV | present | absent |
env passed to sh(...) | present | present |
| Bound credentials | present | present |
A container step starts from nothing but what the image defines plus what you pass explicitly. A command that works on the host and mysteriously fails in a container is usually reading a variable that's simply not there.
Pass what you need:
await sh(["npm", "run", "build"], { env: { NODE_ENV: "production" } });
The entrypoint is overridden
FlowWright always sets --entrypoint, so an image's own ENTRYPOINT never runs.
container: "my-tool" will not invoke my-tool — you still say what to run:
stage("Scan", {
container: "aquasec/trivy:latest",
run: async () => {
await sh(["trivy", "fs", "--exit-code", "1", "."]);
},
});
Images built around an entrypoint that performs setup won't get that setup. Pick a plain base image, or do the setup as an explicit first command.
File ownership
Running locally, FlowWright passes no --user, so containers run as the image's
default user — usually root. Files written into the bind-mounted workspace end up
root-owned on your machine, which can leave a dist/ you can't delete without
sudo.
If that's a problem, have the stage clean up after itself, or run the build on the host and only test in the container.
Services
A stage can declare sidecar containers — a database, a queue, a fake S3:
import { pipeline, stage, sh } from "@flowwright/core";
export default pipeline({
name: "acme-api",
stages: [
stage("Integration tests", {
container: "node:20",
services: [
{ name: "db", image: "postgres:16", env: { POSTGRES_PASSWORD: "test" } },
{ name: "redis", image: "redis:7" },
],
run: async () => {
await sh(["npm", "ci"]);
await sh(["npm", "run", "test:integration"], {
env: { DATABASE_URL: "postgres://postgres:test@db:5432/postgres" },
});
},
}),
],
});
Services share a private network with the stage, and the service name is the
hostname — db resolves to the Postgres container. Names must match
^[a-z0-9][a-z0-9-]*$ and be unique within the stage.
Readiness
FlowWright waits for each service before starting the stage, up to two minutes (which has to cover pulling the image).
- If the image declares a
HEALTHCHECK, it waits for healthy. - If it doesn't, it waits for running — which means the container started, not that the service inside it is accepting connections.
That second case is the common failure: a bare postgres:16 is "ready" the instant
the container starts, and your first connection may still be refused. Either use an
image with a healthcheck, or wait in the stage:
await sh.raw("until pg_isready -h db -U postgres; do sleep 1; done");
A service that exits or goes unhealthy fails the stage with its last log lines attached.
Services and retries
Services start once per stage, not once per attempt. A stage with retries: 2
that dirties its database on the first attempt retries against that same dirtied
database. Make the stage's setup idempotent, or reset the state at the top of the
body.
When Docker isn't available
The two features behave differently, deliberately:
| Declaration | No Docker |
|---|---|
container: "node:20" | Silently runs on the host, with one dim warning |
services: [...] | Fails the stage |
(docker not found — running container stages on the host)
That single line is the only sign that container: "node:20" ran against whatever
Node happens to be installed. If pinning the runtime was the point, check for it.
Services fail closed instead, because running tests without their database produces a confusing failure rather than an honest one.
flow run --no-docker forces the host path everywhere — useful on a CI runner that's
already inside a container. See Running pipelines, and
flow doctor reports whether Docker is reachable at all —
Inspecting.