Skip to main content

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 stepContainer step
Inherits your shell's environmentYesNo
$PATH, $HOME, $CI, $NODE_ENVpresentabsent
env passed to sh(...)presentpresent
Bound credentialspresentpresent

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:

pipeline.ts
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 hostnamedb 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:

DeclarationNo 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.