# Docker Compose
Source: https://docs.qlane.ai/connect/docker-compose
Run your full stack — app, database, cache, workers — from a compose file on every pull request. What the file must do, how to add a testing overlay without touching what you ship, how builds are cached and budgeted, and a checklist to run before you push.
Real apps aren't one process. They're a web tier, an API, a worker, a database, a queue, maybe a search index. This mode runs `docker compose up` on a compose file in your repository, brings the **whole stack** up in a fresh sandbox for every pull request, and points the agent at the service you choose.
**Name in the product:** this mode is a **Composed sandbox**. The environment page's header calls it that, and when you create the environment it is the **Multi-service via docker-compose** option. This page says Composed sandbox from here on; "Compose" on its own means the Docker Compose tool and its files.
## When to use this mode
Use a Composed sandbox when **any** of these is true:
* Your app needs a **database, cache, queue, or object store** before it can start.
* You have **two or more services** that talk to each other.
* Your repository already ships a `compose.yaml` or `docker-compose.yml`.
If your app runs on its own — a front end, or an API that only talks to hosted services — use [Single repo](/connect/single-repo) (a **Simple sandbox**) instead. It needs no Docker and boots faster.
## The one requirement that matters most
**The service that carries your code must build from source.** A service that names a prebuilt image will start, pass its healthcheck, and serve a working preview — of your **published release**, not your pull request. Every result would be real, and none of it would be about your change.
```yaml theme={null}
# ✗ The pull request is not in this image
services:
app:
image: ghcr.io/acme/checkout:latest
```
```yaml theme={null}
# ✓ The pull request's code is what runs
services:
app:
build:
context: .
dockerfile: Dockerfile
```
Qlane refuses an environment whose previewed service is `image:`-only, before anything is provisioned — nothing later in the run could tell the difference. The same problem has a second shape: a compose file with **no application service at all**, only databases, caches, mail catchers and object storage. Repositories often ship one of those for running backing services while the app runs on the host. Look for a sibling file that builds the app, often under a `testing/`, `ci/` or `full/` directory.
Backing services are the opposite case: `postgres`, `mongo`, `redis`, `minio` and friends **should** stay as `image:` tags. Only the services built from the repository under test need `build:`.
Many open-source and self-hosted apps ship compose files that pull a published image, because that's what their users run. **Don't edit that file** — add a testing overlay beside it instead (below).
## Step 1: Choose or write the compose file
Repositories often ship several compose files, and the conventional name is usually the "run the published release" one. Check each candidate:
1. Does it contain a service that **builds** your app from source? If not, keep looking.
2. Does that service **publish a port** (`ports: - "3000:3000"`)? Something must be reachable from outside the stack.
3. Does it start on a plain `docker compose up`, or is the app behind a `profiles:` gate? Either works — you [select profiles](#compose-profiles) on the environment — but you need to know which.
If several qualify, prefer the one with the fewest services that still starts the app: every extra service is build or pull time on every pull request. If a file you expect isn't offered, see [Your compose file isn't in the list](#compose-file-not-listed).
If none qualify, use one of the two options below.
### Option A: add a testing overlay
An overlay is a second compose file, used only for testing, that `include:`s the file you already ship and overrides the one service that must build. Nothing you ship changes.
```yaml compose.qlane.yaml theme={null}
# Testing-only overlay. Changes nothing you ship.
include:
- path: docker-compose.yml # your existing file, used as-is
services:
app: # must match the service name in the included file
image: myapp-pr:test # a name that exists in no registry
pull_policy: build # without this, the included file's image is still pulled
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000" # re-declare the published port here
```
Everything else — the other services, `depends_on`, `environment`, healthchecks — merges in from the included file. Six things to know, in the order they bite:
* **`pull_policy: build` is not optional.** `include:` merges rather than replaces, so the base file's `image:` survives alongside your `build:`, and a plain `docker compose up -d` **pulls the published image and never builds**. The overlay passes every check and still tests the release. The unregistrable `image:` name is a second guard: if anything ever pulls anyway, it fails loudly instead of serving the wrong code, and it is what lets you [verify the overlay locally](#verify-an-overlay). Nothing is wasted by either: a pull-request sandbox starts with no images of its own, and where [build caching](#build-caching) is on, the build still reuses unchanged steps.
* **Name it `compose.qlane.yaml`.** That name is recognised and listed **first** when you pick a compose file. `compose.qlane.yml`, `docker-compose.qlane.yaml` and `docker-compose.qlane.yml` work too, at any depth in the repository. `qlane` must be the whole variant: `compose.qlane-staging.yaml` is still listed, but not first, so you have to select it yourself.
* **Re-declare the published port on the overridden service — the same mapping the base file uses.** The file you select is read as written, and `include:` targets aren't followed when you pick the primary service, so a port declared only in the base file is invisible there, and the environment is refused. Repeat it exactly: an identical mapping collapses into one, but a different one is *added*, and the service then publishes both. The same goes for any service you want to [expose](#exposed-services) and any profile you want offered.
* **Keep the include inside the repository.** A relative path is the normal case. Absolute paths, `https://` or `oci://` URLs, and `../` paths that climb above the overlay's own directory are flagged: the sandbox only has your repository's clone.
* **Override, don't rename.** The service key must match the base file, or you add a second service instead of replacing the first.
* **Settings merge into the base service; most lists don't replace.** Writing only `healthcheck: { start_period: 120s }` keeps the base file's `test`, `interval` and `retries` and adds a grace window — worth doing whenever the first boot runs migrations before the app listens, or the service is reported unhealthy for that whole first start. `command` and `entrypoint` replace the base value. `ports` and `env_file` are combined with the base file's, which is why the port above must match.
Because `include:` targets aren't read at configuration time, anything risky that lives only in the base file — an `env_file:` you never committed, an absolute bind mount, `privileged: true` — passes configuration and fails at start-up. Run the [self-check](#checklist) against the **merged** result: `docker compose -f compose.qlane.yaml config`.
**When the base file fights you, write a standalone file instead.** Some shipped compose files bind-mount host files that won't exist in the sandbox, run containers as `${UID}:${GID}`, or pull extra prebuilt services you don't need for testing. Overriding each of those one key at a time gets fragile; a short standalone `compose.qlane.yaml` with no `include:` is usually clearer. See the [worked example](#worked-example).
### Option B: write a standalone test compose file
If there's no compose file at all, write one specifically for testing — don't adapt a production file that pulls published images. Name it `compose.qlane.yaml` so it's listed first.
```yaml compose.qlane.yaml theme={null}
services:
app:
build:
context: .
dockerfile: Dockerfile # builds the pull request's code
ports:
- "3000:3000" # something must be reachable
environment:
HOST: 0.0.0.0 # listen on every interface, not loopback
DATABASE_URL: postgres://app:app@db:5432/app
APP_URL: ${QLANE_SANDBOX_PUBLIC_URL:-http://localhost:3000}
LOG_LEVEL: ${LOG_LEVEL:-info}
depends_on:
db:
condition: service_healthy
pre_start:
- command: ["npm", "run", "db:migrate"] # runs before the app starts
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 5s
timeout: 3s
retries: 20
start_period: 60s # a slow first boot isn't counted as a failure
db:
image: postgres:17-alpine # a backing service — prebuilt is correct
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 3s
timeout: 3s
retries: 20
```
Choices worth keeping:
* **No named volume on the database.** The sandbox is discarded after every run, so a volume buys nothing, and it is what makes `docker-entrypoint-initdb.d` behave inconsistently.
* **A healthcheck on every service.** `condition: service_healthy` means nothing without one, and without one on the app, readiness falls back to "the container is running", which is true well before it can serve. Common backing services have a one-line check:
| Service | `healthcheck.test` |
| - | - |
| PostgreSQL | `["CMD-SHELL", "pg_isready -U app -d app"]` |
| MongoDB | `["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]` |
| Redis | `["CMD", "redis-cli", "ping"]` |
| MySQL / MariaDB | `["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]` |
* **Probe readiness, not liveness.** The app's healthcheck decides when the postdeploy hook runs and when tests start, so it must pass only once the app can serve a real request — after its migrations have finished. Many apps expose a liveness endpoint (often `/healthz`) that answers as soon as the server listens, before the schema exists; a seed that waits on it writes to tables that are not there yet. Probe a readiness endpoint if the app has one, or a page or API route that reads from the database.
* **Probe `127.0.0.1`, not `localhost`.** In many images — Alpine-based ones especially — `localhost` can resolve to the IPv6 address `::1`. A server listening on `0.0.0.0` accepts IPv4 only, so a `localhost` probe is refused and the service never reports healthy, even though the app is fine.
* **Use a probe tool your image has.** Alpine images have `wget`; slim Debian images often have neither `wget` nor `curl`. Then use your runtime, for example `node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"`.
* **Serve the production build.** The Dockerfile's final command should serve built output, not run a dev server — the reasons are the same as for a [Simple sandbox](/connect/single-repo#serve-the-production-build).
* **Throwaway credentials, committed.** The stack is ephemeral and unreachable from outside, so these aren't secrets. Never do this with real ones.
Then work through [Build time, caching and memory](#build-time-and-memory) and the [self-check](#checklist).
## Step 2: Environment variables
### `.env` is written for you — other env files must be committed
At start-up qlane writes a `.env` file next to your compose file, built from your repository's committed `.env` or `.env.example`, overlaid with the variables you configure on the environment. With [several repositories](#several-repositories), each component gets its own `.env` beside its own compose file, built the same way; a repository that commits no example file still gets one, holding the environment's variables alone.
```yaml theme={null}
env_file: .env # ✓ provided for you
```
```yaml theme={null}
env_file: .env.docker # ✗ must be committed, or the run is refused
```
Any other path works only if that file is **committed**. A required reference to an uncommitted file is refused at start-up, before anything builds. The configuration screens see your compose file but not the rest of your repository, so there the same reference shows only as a warning; start-up's refusal is the authoritative answer. Fix it by renaming the reference to `.env`, committing a safe default under the referenced name, or marking the reference optional:
```yaml theme={null}
env_file:
- path: .env.local
required: false # skipped when absent instead of failing
```
Writing the file doesn't inject anything by itself: a service still has to ask for the values, under `environment:` or with `env_file: .env`. A variable present in `.env` but named in neither place reaches nothing. When both set the same key, `environment:` wins — so if your committed `.env.example` carries local-machine values such as `HOST=localhost` or a `localhost` database URL, override them under `environment:` rather than editing the example.
**The written `.env` files stay out of cacheable builds.** They hold the environment's decrypted values, and the [build cache](#build-caching) keeps what a build copies. So wherever build caching is available — whether or not it's on for this environment — they are excluded from the context of every build that can save to the cache: `COPY . .` doesn't copy them, and `COPY .env …` fails as a missing file. Builds that [never save to the cache](#build-caching) are the exception and keep them in the context, as on your machine. Compose still reads them for `${VAR}` interpolation and `env_file:` either way. If an image must never contain them — for example because you push it somewhere — add `**/.env` to the context's `.dockerignore` yourself (or to a `Dockerfile.dockerignore` beside your Dockerfile, which takes precedence). A value the build needs goes through `build.args`, or, for a credential, a [build secret](#private-registries).
### `${VAR}` interpolation
A `${VAR}` with no default and no value interpolates to an empty string, and the app usually fails later and less clearly — at boot rather than when Compose parses the file. Either give a default, or make Compose refuse up front with a message you write:
```yaml theme={null}
environment:
LOG_LEVEL: ${LOG_LEVEL:-info} # default when unset
API_TOKEN: ${API_TOKEN:?set API_TOKEN on the environment} # refused when unset or empty
REGION: ${REGION?set REGION on the environment} # refused when unset only — empty passes
```
The colon is the whole difference, and `:?` is almost always the one you want.
**Empty is not the same as unset, and it is often worse.** An app can mark a configuration field required and validate the whole configuration at boot, so an empty value kills it with an error about a feature you weren't using. Code that tolerates a missing value in development often refuses to start without it under `NODE_ENV=production`. Both fail the same way from outside: the stack builds, then the container exits. When you blank a credential you don't intend to exercise, give it an obviously fake **non-empty** value and let the vendor call fail. The exception runs the other way: where a variable's *presence* switches a client on (a tracing exporter, a job runner, a metrics agent), a fake value makes it retry forever and can hold the healthcheck back — use the feature's own off switch instead. Ask of each variable "what does supplying it cause to happen?", not "is it required?". Don't get past a production-only guard by changing `NODE_ENV`; that tests a configuration you don't ship.
**Never commit real secrets.** Configure them on the environment instead. Configured values are masked in the output qlane shows you; a committed value isn't in the set to mask against, and `docker compose config`, which runs during provisioning, prints it back verbatim. It is also identical in every sandbox, for anyone who can read the repository. If one is already committed, move it **and** rotate it: it is in your git history.
### Public address of your app
Apps that build absolute links, OAuth redirects or webhook callbacks, or that call their own API by full origin, need to know the address they're reached at. In the sandbox that is **not** `localhost` — the browser testing your app isn't on the sandbox's loopback.
Qlane sets `QLANE_SANDBOX_PUBLIC_URL` for every Composed sandbox: the public URL of the service you preview, scheme and host, with no trailing slash, so a path appends cleanly. It looks like `https://-.e2b.app`, or your environment's custom domain where one is set. A [Simple sandbox](/connect/single-repo) doesn't set it.
```yaml theme={null}
services:
app:
environment:
APP_URL: ${QLANE_SANDBOX_PUBLIC_URL:-http://localhost:3000}
OAUTH_REDIRECT_URI: ${QLANE_SANDBOX_PUBLIC_URL:-http://localhost:3000}/callback
```
In the compose file it is an ordinary Compose variable, so a default is the right way to write it — the same file then still works on your machine. Getting this wrong rarely fails loudly: the page renders, can't reach its own backend, and looks like a real bug. Ask of every URL-shaped value in your configuration: "is this the address of the app itself?"
* **In a variable configured on the environment**, write exactly `${QLANE_SANDBOX_PUBLIC_URL}` — braces, no `:-` default. qlane expands it before anything starts, and the rules are stricter than Compose's: the unbraced `$QLANE_SANDBOX_PUBLIC_URL` isn't expanded, and a modifier such as `:-fallback` is refused as an unknown name, like a typo would be, so a misspelt reference never reaches your app as a literal string. To pass the text through unexpanded, write `$${QLANE_SANDBOX_PUBLIC_URL}`.
* **Not inside a committed `.env` or `.env.example`.** Compose expands an entry there using only the entries above it, and the variable is added at the end of that file, so it silently becomes empty or your `localhost` fallback. Reference it from the compose file or an environment variable instead.
* **Build-time values need build args, declared late.** Front-end frameworks that bake configuration into the bundle (`VITE_*`, `NEXT_PUBLIC_*`) read it while the image builds, before `environment:` applies. Pass it through `build.args` and declare the matching `ARG` in the stage that runs the build (no `ENV` needed — an `ARG` is already in the environment of every later `RUN` in its stage). The address differs in every sandbox, so every step from the first `RUN` after the `ARG` is rebuilt on every run, even with [build caching](#build-caching). Declare it just before the build step, not at the top:
```yaml theme={null}
services:
app:
build:
context: .
args:
NEXT_PUBLIC_APP_URL: ${QLANE_SANDBOX_PUBLIC_URL:-http://localhost:3000}
```
```dockerfile theme={null}
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
ARG NEXT_PUBLIC_APP_URL # here, after the install, not at the top
RUN yarn build
```
* **Names starting with `QLANE_` are reserved.** Saving a variable of your own with that prefix is refused. A line of that name in your committed `.env.example` is left alone as long as you don't edit it; it never becomes one of your variables, and the value qlane sets replaces it.
### A literal `$` inside `configs:`
Compose substitutes `${VAR}` and `$VAR` in **every** string it parses, including the body of an inline `configs:` entry — an nginx config, a Prometheus rule, an init script. A `$` meant for that program is consumed first, and an undefined name becomes an empty string rather than an error. Escape it as `$$`:
```yaml theme={null}
configs:
proxy_conf:
content: |
proxy_set_header Host $$host; # ✓ nginx receives $host
proxy_set_header Host $host; # ✗ Compose substitutes it; nginx receives an empty header
```
`docker compose config` can't confirm this: its output is re-escaped, so a correct `$$host` prints back as `$$host` either way. Read the file from inside the running container instead.
### Private packages during the build
A build that installs from a private registry — a scoped npm package, a Maven artifact, a wheel from an internal index — needs a credential at build time, before any of your services start. Configure it as a variable on the environment and hand it to the build as a **build secret**, not a build argument:
```yaml theme={null}
services:
app:
build:
context: .
secrets:
- npm_token
secrets:
npm_token:
environment: NPM_TOKEN # configured on the environment, not committed
```
```dockerfile theme={null}
RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN yarn install --frozen-lockfile
```
The value is in that one step's environment while it runs (an `.npmrc` that reads `${NPM_TOKEN}` picks it up) and nowhere else. To get it as a file instead, drop `env=` and read `/run/secrets/npm_token`.
* **Why not a build argument.** A build argument is recorded in the image's history, so anyone who can read the image can read the token. A secret mount is never written into a layer or the history, and it isn't part of the step's cache key, so rotating the token doesn't force a rebuild.
* **Never leave the credential in a file a build step writes.** A step that writes an `.npmrc` holding the token, or a `~/.m2/settings.xml` with a password, puts it in that step's layer, and layers are what [build caching](#build-caching) saves — including the early stages of a multi-stage build. If a tool insists on a config file, have it read the token from the environment, or write and delete it within the same `RUN`.
* **Use a read-only token scoped to package downloads**, never a general-purpose or write-capable one.
* **A credential committed in your repository works without any of this**, because the checkout carries it. That isn't a reason to do it: see [never commit real secrets](#variable-interpolation). A committed credential that a build copies is also in the cached layers.
* **Fetching from your git host during the build** (`git fetch`, `git submodule update`, a private git dependency) can't rely on qlane's access to your repositories. Give that step its own read-only credential as a build secret, and don't put a token in a clone URL: `git clone` writes it into `.git/config`, and so into the layer. Pass it through a credential helper or an `Authorization` header instead.
A private **base image** (`FROM` a registry that needs a login) isn't supported: nothing logs in to a registry on your behalf, and the pull fails after the rest of the build has run. It works on your machine because you are already logged in. Build from a public base image, or one of your own in a public registry.
## Step 3: Get the app into a testable state
Most apps need nothing here: if yours runs its own migrations at boot — a Prisma `migrate deploy` in the entrypoint, a Django or Rails migrate on start — the schema exists by the time a [readiness healthcheck](#step-1-choose-or-write-the-compose-file) passes. A liveness check can pass first, while migrations are still running.
### Migrations — `pre_start`
Compose's native init steps run to completion, in order, before the service starts; a non-zero exit stops the bring-up and names the failed step.
```yaml theme={null}
services:
app:
build: .
depends_on:
db:
condition: service_healthy
pre_start:
- command: ["npm", "run", "db:migrate"]
```
`pre_start` needs Compose 5.3 or newer; the sandbox has it. Since Compose 5.6, which the sandbox runs, a step inherits every container setting of its service except volumes, including `entrypoint`, `user` and `read_only` — so a step that works on an older local Compose can behave differently in the sandbox:
* **The service has a custom `entrypoint`.** The step's command is passed to it as arguments: with `entrypoint: ["./start.sh"]` the step runs `./start.sh npm run db:migrate`, and if the script starts the server instead, the step never finishes. Give the step its own `entrypoint: ["sh", "-c"]` with `command: ["npm run db:migrate"]`.
* **The service runs as a non-root `user` or with `read_only: true`.** The step does too, so a migration that writes inside the container fails. Give the step its own `user:` or `read_only: false`, or write to a volume.
**The older equivalent is a one-shot service** — a `migrate` service with `restart: "no"` that the app depends on with `condition: service_completed_successfully`. That condition is what makes a failed migration stop the start-up. A one-shot that **nothing depends on** fails silently: `docker compose up -d` still exits `0`. If the one-shot sits behind a profile, `required: false` on the dependency makes the failure optional too, so prefer not to profile-gate it — let it always run and exit `0` itself when its inputs are absent. Qlane also refuses a stack in which any service has exited non-zero once the stack reports healthy, and names that service; keep the gate anyway, because it stops the app before it starts against an unmigrated database, and it makes the failure loud on your machine too.
### A test account and seed data — the postdeploy hook
The agent needs something to log in as, and your pages need something to show. Declare a **postdeploy hook**: a label on a long-running service whose command runs inside that container once the whole stack is healthy and the app is answering on its port. Every run shows a **Postdeploy hook** step with what was found and what it printed.
```yaml theme={null}
services:
app:
build: .
labels:
ai.qlane.postdeploy: "npm run db:seed"
```
* **A failure fails the run** and shows the hook's output. Nothing is tested against a half-seeded stack.
* **Several services may each declare one.** They run one at a time, in no guaranteed order — if one must precede another, chain them in a single command.
* **The command runs through `sh -c`**, so the image needs a shell (not `distroless` or `scratch`), and the service must still be running — a hook on a one-shot that has exited fails with `service "" is not running`.
* **All hooks share one deadline of about two minutes**, and there is no timeout label. The deadline grows with the provision timeout only up to that cap, which it reaches at about ten minutes. Keep seeds small, and move slow work that needs only your backing services into `pre_start`, which runs inside the build budget. A seed that calls the app's own API can't move there, because `pre_start` runs before the app starts.
* **Declaring a hook takes about two minutes off the build budget** (see [Build time, caching and memory](#build-time-and-memory)). Merely adding the label can push a build that used to fit over the limit.
Many apps ship a CLI for exactly this — a `create-user` script, a `seed` task, an admin bootstrap command. Call it from the hook with fixed, throwaway credentials, then give those same credentials to qlane when you create the environment, so the agent can log in.
**When the seed needs a healthy app, invert the edge.** A one-shot `seed` service that `depends_on` the app with `condition: service_healthy` runs *after* it — the shape you need when the app's own start-up creates the schema, or the fixture goes in through a running API. Give it an edge to every service it touches directly, not just the one it waits behind. Nothing depends on it, so it is unguarded: make the script fail on the first error and end by asserting the row counts it wrote, and make it idempotent.
Two seeding mistakes produce data that looks fine and isn't:
* **An app's own ingest endpoint stamps rows with the server's clock.** For anything that shows a trend or "the last N days", write those rows to the database directly, with the timestamps you want.
* **Randomness that doesn't reference the row can be evaluated once.** `random()` in a sub-select that doesn't reference the outer row can yield one value for the whole dataset. Derive the varying value from a hash of the row's own key (`md5(id::text || ':' || n::text)`), and verify by counting distinct values, not rows.
Don't rely on `docker-entrypoint-initdb.d` as your main mechanism: those scripts run only when the data directory is empty, and don't resume after a partial failure.
## Step 4: Create the environment
Commit the compose file to your default branch first — the wizard reads it from there. Then open your project, click **Add environment**, and choose **Test every pull request**.
Pick the **Repository** and **Branch** (the branch the environment reads its compose file from — usually your default branch), and name the environment.
Under **How does this app run?**, choose **Multi-service via docker-compose**. It's pre-selected when compose files are found in the repository. Multi-service sandboxes are available on the Pro plan.
If your app needs a login, click **Add a test account** and enter the credentials your postdeploy hook creates. Add several if the agent should test as different roles. Under **Facts and notes**, describe what the account is — its plan, what it owns — and anything the agent must not do with it. Skip this step if your app has no login.
Pick the **Compose file** — `compose.qlane.yaml` is listed first — and the **Primary service** the agent drives. The port is the service's first published port; a service that publishes none can't be chosen. Problems that would stop a run are reported here, before you continue. Then tick any [**Compose profiles**](#compose-profiles) and [**Exposed services**](#exposed-services) you need.
The **Env file** is pre-filled from the `.env` or `.env.example` committed next to your compose file. Add or change the values your stack needs — API keys, tokens, `COMPOSE_PROFILES` — one `KEY=value` per line. Only lines that differ from the committed file are saved, values are stored encrypted, and keys that look like secrets (`…_KEY`, `…_TOKEN`, `…PASSWORD`, `…SECRET`) are masked.
Choose the **Compose size** (Medium by default), the **Provision timeout** (30 minutes by default, the maximum), and whether [**Build caching**](#build-caching) is on (it is by default). Pick **Large** for heavy front-end builds — see [Build time, caching and memory](#build-time-and-memory).
Check the summary — it also shows any postdeploy hook found in the file — and click **Test configuration**. Qlane brings the stack up exactly as a pull request would and reports whether the primary service answered. If it passes, click **Create environment**. If it fails, fix the cause and test again, or click **Create anyway** and fix it later — see [Troubleshooting](#troubleshooting-a-failed-configuration-check).
After creation, the environment page lets you change the compose file, profiles, exposed services, env file, test accounts, Compose size, provision timeout, build caching, and **Watch paths** (see below). To add another repository to the same stack, use **Connect a component** — see [A stack that spans several repositories](#several-repositories).
## Step 5: Turn on pull-request testing
An environment added with **Add environment** starts with only **Mention triggers** on, so it runs when someone asks for it: comment `@qlane-ai test` on a pull request to start a run, or `@qlane-ai test /` to pick where it runs. See [Triggers](/triggers).
To test every pull request automatically, open **Project settings → PR testing**, find the environment under **Per environment**, and turn on **Automatic runs**. Automatic runs skip draft pull requests; a mention runs on a draft too. This needs a passing configuration check — an environment saved with **Create anyway** shows **Check needed** until you run the check again and it passes.
To skip pull requests that can't affect this stack, add **Watch paths** on the environment page: gitignore-style patterns, one per line. A pull request that touches none of them isn't tested here. Leave them empty to test every pull request.
## Profiles, exposed services and several repositories
### Compose profiles
Services behind a `profiles:` gate don't start unless the profile is active. When **every** service is gated, nothing starts and the run fails at once with `no service selected`.
The environment has a profile selection — one checkbox per profile your compose file declares, on the creation wizard and later in the environment's settings.
* **It is pre-filled.** Once you pick a primary service, the profiles it needs are ticked: its own, those gating anything it `depends_on`, and those gating what those bring in. Each row says which services it gates and why it was ticked. **Reset to suggested** brings the pre-fill back.
* **You can change it.** If your edit leaves nothing that starts the primary service, you're told on the spot, and a run configured that way is refused before anything is provisioned.
* **Only profiles declared in the file you select are offered**; profiles in an `include:`d file get no row.
* **Ticking nothing leaves `COMPOSE_PROFILES` alone.** A value committed to your `.env`, or configured as a variable on the environment, then applies — and the environment's variable wins over the committed one, as Compose ranks the process environment above `.env`. The selector lists each value and names the one that applies. With anything ticked, the selection wins.
Check what a selection will start: `COMPOSE_PROFILES=web,worker docker compose -f config --services`.
### Exposing more than one service
The preview URL points at the primary service; every other service is reachable by service name from inside the stack and from nowhere else. To let the agent reach another one directly — an admin panel, a docs site, a second front end — tick it under **Exposed services** and it gets its own URL, named to the agent by its service name. Nothing is ticked for you.
* **Only a port published under `ports:` can be exposed.** Declaring an exposed service whose port no compose file publishes is refused before anything is provisioned.
* **The preview URL doesn't move**, and the port it points at isn't offered again; the primary service can still be exposed on another port it publishes.
* **One port per service.** Ticking one of a service's ports holds its other rows until you untick it.
### A stack that spans several repositories
An environment can hold several **components** — each a repository with its own compose file — added with **Connect a component** and brought up as one Compose project.
* **Networking.** Services reach each other across components over the project's default network, by service name. A service joins it only when its `networks:` key is absent or empty; a non-empty `networks:` replaces the default. Same-named networks in two components' files don't merge, and `external: true` doesn't connect them either. So leave `networks:` off services that talk across repositories — and watch for a file where most services name a network and one doesn't, which silently splits the stack.
* **Names and published ports are project-wide.** Two components defining the same service name, or publishing the same host port, are refused. Publish only the ports something outside the stack needs; service-to-service traffic never needs one.
* **Paths are per repository.** Each component's file is read from its own checkout, so `build: { context: . }` means that repository. Keep one file per repository, with the services built from it and the backing services only it uses, and never hard-code another component's directory. `depends_on`, including `service_healthy`, works across components.
* **Every component is a trigger.** Where automatic runs are on, the check passes and the [watch paths](#step-5-turn-on-pull-request-testing) match, a pull request in **any** connected repository starts a run here. There is no "present but not tested" component: connect a repository only if you want its pull requests tested.
* **Branches with the same name are tested together.** A run also checks out the triggering pull request's branch name in every other component that has it, so a change split across repositories is tested as one — and each matched repository must [build from source](#build-from-source) too. This is off when the environment has one component, for pull requests from forks, and for `main`, `master`, `dev`, `develop`, `staging` and branches starting `renovate/` or `dependabot/`. Every other component is cloned at its configured **Default branch**.
* **The triggering repository reports the default branch name.** Its tree holds the pull request's commit, but `git rev-parse --abbrev-ref HEAD` returns the default branch, so don't derive a version, tag or cache key from it there.
* **Clones are shallow**, so builds that run `git describe` or count commits see a truncated history. Pass such values in as variables instead.
## Build time, caching and memory
The default budget is roughly **24 minutes** to build images and start the stack — about **22 minutes** if you declare a postdeploy hook. **Size your build for the budget with nothing reused.** [Build caching](#build-caching) makes repeat builds of an environment faster, but plenty of builds still start from nothing: an environment's first build, one build per environment each week, any build from a fork, and every build where caching is off or unavailable. A build that takes five minutes on your laptop, reusing your own machine's layers, can take far longer here.
To fit inside it:
* **Install dependencies before copying the source.** Copy `package.json` and the lockfile, install, *then* `COPY . .`. The reverse order re-installs everything on every commit, and it is also what stops the cache reusing the install between pull requests.
```dockerfile theme={null}
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile # unchanged by most pull requests
COPY . .
RUN yarn build
```
* **Keep the build context small.** A `.dockerignore` excluding `node_modules`, `.git`, build output and test fixtures pays off on every run.
* **Pick a bigger Compose size** if it's still too slow. New environments already start at the 30-minute maximum provision timeout, so for them the remaining lever is more CPU, not more time. An environment created before that default may still be on a shorter timeout: raise it to 30 minutes on the environment page before paying for a bigger size. Prefer the first two either way: sandboxes are billed for the time they're up.
| Compose size | Resources | Good for |
| - | - | - |
| Small | 2 vCPU · 2 GiB | A single small service |
| Medium | 4 vCPU · 4 GiB | Mid-weight apps with a few services |
| Large | 8 vCPU · 8 GiB | Heavy builds — large front-end monorepos |
### Build caching
Each environment keeps a cache of its **build layers**, stored outside the sandbox in a registry in your organisation's data region. A later build of the same environment reuses every step whose inputs haven't changed, and the build log says how many steps were reused. It's active once the **Build caching** setting on the environment's page stops saying that caching isn't active yet.
* **On by default.** Turn it off on the environment's page or in the **Infra** step; turning it off deletes the environment's cached layers.
* **Kept for up to 15 days** after the environment's last build.
* **A fresh start once a week**, on a day that differs between environments, so an unpinned download (`apt-get install`, a `curl` of a "latest" URL) is at most seven days stale. That build reuses nothing.
* **Pull requests from forks never use it**: they reuse nothing and save nothing. A pull request from a branch of the repository itself does, so anyone who can push a branch can influence what later builds reuse — the same trust a sandbox already gives them.
* **A layer holds whatever its step writes**: copied source, downloaded dependencies, and any file a `RUN` writes, including a credential in a config file. Carry credentials as [build secrets](#private-registries).
* **A build argument whose value changes per sandbox stops reuse** at the first `RUN` after its `ARG` — see [build-time values](#public-address-of-your-app).
* **Your `.dockerignore` rules still apply.** To keep the [written `.env` files](#env-files) out, qlane writes a `Dockerfile.dockerignore` (named after your Dockerfile) for the length of each cacheable build, containing your own rules copied exactly plus one line per `.env` it wrote, then restores or removes it.
* **Some services read the cache but never save to it.** The build log names each one and why. That happens when a service uses `dockerfile_inline`; has a remote URL or git repository as its context, or `additional_contexts` pointing at a local directory or at another service that can't save; shares its Dockerfile with a service using a different context; has its context or Dockerfile outside the checked-out repositories, or reached through a symbolic link; has an ignore file that isn't UTF-8 text or is a symbolic link; has a `.env` in its context that qlane didn't write (one your repository commits, at any depth); or when qlane can't inspect the context or write the ignore file. A service with both `image:` and `build:` whose pull policy pulls isn't built at all, so it saves nothing either.
* **A cache problem falls back to a build without it.** If the cache can't be reached, the build runs without it; if a cached build fails, qlane retries once without the cache and the log says so. If that fails too, the error is your build's own. A cache that is reachable but slow can still use up most of the budget before the retry.
### Build memory
**"JavaScript heap out of memory" is usually not a machine-size problem.** Node sizes its heap from the memory it detects and stops there, so a build can die at around 2 GB with plenty free, and a larger size doesn't fix it. Raise the ceiling in the Dockerfile, as an `ARG` so compose can override it through `build.args`:
```dockerfile theme={null}
ARG BUILD_MAX_OLD_SPACE_MB=4096
ENV NODE_OPTIONS=--max-old-space-size=${BUILD_MAX_OLD_SPACE_MB}
RUN yarn build
```
Check your build scripts first: a `package.json` script that sets its own `--max-old-space-size` (for example `NODE_OPTIONS=--max-old-space-size=… vite build`) overrides the Dockerfile's value, so change it there instead. If the build genuinely needs more than the machine has, reduce concurrency: many monorepo build tools run packages in parallel, and each worker carries its own heap.
The opposite also happens: a build script that hard-codes a heap ceiling close to or above the sandbox's memory can outgrow the sandbox and be killed outright — exit code `137` during the build. Pick the **Large** size for those, and if it's still killed, lower the ceiling.
## Worked example: an app whose shipped compose file can't be reused
A common shape, especially for open-source and self-hosted apps: the repository ships a compose file for *users*, which pulls the **published** image, bind-mounts a local `.env`, config files and data directories, runs containers as `${UID}:${GID}`, and brings up optional services (a search index, a vector store) that most changes never touch. The app itself is a Node.js server with a heavy front-end build and a database behind it. None of that works as-is for pull-request testing.
A standalone `compose.qlane.yaml` beside the shipped file fixes it without touching it:
```yaml compose.qlane.yaml theme={null}
# Testing-only stack for pull-request sandboxes. The shipped compose file
# still pulls the published image; this one builds the pull request.
services:
web:
image: myapp-pr:test # unregistrable: a pull can never pass for a build
pull_policy: build
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
environment:
HOST: 0.0.0.0
NODE_ENV: production
DATABASE_URL: postgres://app:app@db:5432/app
# The address the app builds links and redirects from.
PUBLIC_URL: ${QLANE_SANDBOX_PUBLIC_URL:-http://localhost:3000}
# Throwaway values for an ephemeral sandbox — not your production secrets.
SESSION_SECRET: test-only-session-secret-0123456789abcdef
# Let an automated tester sign in repeatedly without locking itself out.
LOGIN_RATE_LIMIT: "1000"
# A third-party key the main features need, configured on the environment.
PAYMENTS_API_KEY: ${PAYMENTS_API_KEY:?set PAYMENTS_API_KEY on the environment}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 5s
timeout: 5s
retries: 10
start_period: 120s
labels:
# A known login for the agent, created once the stack is healthy.
ai.qlane.postdeploy: "npm run users:create -- --email qa@example.com --password 'Qa-test-2026!'"
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 3s
timeout: 3s
retries: 20
```
The variable names and commands are illustrative — use your app's own. What carries over is what each choice is for:
| Choice | Why |
| - | - |
| `build:` + `pull_policy: build` + an unregistrable `image:` | Tests the pull request, never the published release |
| Only the services the change under test needs | Optional services add build and pull time to every run. Add them back when a pull request touches them |
| No bind mounts of `.env`, config files or data directories | They don't exist in a fresh clone. Pass settings as environment variables, or commit a test config file and mount it by relative path |
| No `user: "${UID}:${GID}"` | Those variables aren't set in the sandbox |
| `${QLANE_SANDBOX_PUBLIC_URL}` for the app's own URL | Links and redirects point at the preview, not `localhost` |
| Healthcheck on `127.0.0.1` with a long `start_period` | The first boot of a large app is slow, and `localhost` can resolve to IPv6 in Alpine images |
| A postdeploy hook calling the app's own user-creation command | A deterministic login to give qlane when you create the environment |
| Rate limits and lockouts relaxed | An agent signing in and retrying can look like abuse to the app's own protections |
| Required keys refused up front with `:?` | A missing key stops the run with a clear message instead of a half-working app |
Environment settings for this example: primary service `web`, port `3000`, Compose size **Large** because the front-end build is heavy, `PAYMENTS_API_KEY` in the **Env** step, and the `qa@example.com` login added on the **Login** step.
## What's checked before a run
| Condition | Result | Why |
| - | - | - |
| No services in the file, or a service with neither `image:` nor `build:` | Blocks | Nothing to run |
| The previewed service is `image:`-only, or nothing in a repository under test builds from source | Blocks | The pull request's code would never run — see [the one requirement](#build-from-source). Repositories under test are the one the pull request was opened in plus any [matched by branch name](#several-repositories) |
| The previewed service isn't defined by any compose file in play | Blocks | Nothing to point the preview URL at |
| The primary service publishes no port (only `expose:`, a bare container port, or none) | Blocks | Nothing outside the stack can reach it — see [troubleshooting](#primary-service-publishes-no-port) |
| The primary service sits behind a profile no active profile opens | Blocks | `docker compose up` would start nothing — see [profiles](#compose-profiles) |
| A required `env_file:` is neither `.env` nor committed | Blocks | Compose exits with `env file … not found` — see [env files](#env-files) |
| `privileged: true`, `network_mode: host`, an absolute bind mount, or `secrets:` with `external: true` | Blocks | Not available in the sandbox |
| A cycle in `depends_on` | Blocks | Compose won't start it either |
| Two components define the same service name | Blocks | Merging by name keeps one and drops the other, whatever profiles you pick |
| Two services that both start publish the same host port | Blocks | The second fails to bind. Only services your profile selection starts count, so mutually exclusive profiles can share a port |
| An exposed service whose port no compose file publishes | Blocks | The URL would have nothing behind it — see [exposed services](#exposed-services) |
| `env_file:` other than `.env`, when the file exists or is optional | Warns | Works only as committed; the environment's variables never reach it |
| `${VAR}` references | Warns | Check each resolves; empty values fail late |
| `extends:` or `include:` leaving the repository (absolute, `https://`, `oci://`, or climbing above the file's directory) | Warns | The sandbox has only your clone |
| `cap_add` / `cap_drop` | Warns | Specific capabilities may not be honoured |
| `network_mode:` other than the default | Warns | May behave differently than on your machine |
**Where the checks run.** Creating an environment blocks you at the compose step. Adding or editing a component refuses the save. The environment's **Components** view only reports — it can see clashes between components that a single save can't, and it is the only place that notices a compose file changed in the repository. **Start-up decides**, on every run: it reads the actual clone at the commit under test, so it can refuse what a configuration screen only warned about, such as an uncommitted `env_file`. Configuration screens read the component's configured branch, so a compose file that gains its `build:`, port or profile only on a pull request's branch is refused when you save the component — land that change on the default branch first.
**One thing isn't validated and costs a full build to discover: a network, volume or config declared `external: true`.** Nothing creates it in a fresh sandbox, so `up` fails with `network declared as external, but could not be found` after the image has built. Drop `external: true` and let Compose create it (keep `name:` if the name matters), or set `external: false` on it in an overlay. An external **secret**, by contrast, is refused up front.
## Troubleshooting a failed configuration check
When you create an environment, qlane runs a configuration check: it brings the stack up and waits for your primary service to answer on its published port. If nothing answers in time, the check fails. Work through these causes in order.
### The primary service publishes no port
A port listed only under `expose:`, or a bare container port such as `- 3000`, is reachable from inside the stack and from nowhere else. Publish it as a `"host:container"` mapping, or choose the service that does publish one — if a proxy sits in front of your app, the proxy is usually the right choice.
Write the mapping as literal numbers. A host port set by a variable (`"${WEB_PORT:-3000}:3000"`) or a port range can't be read when you pick the primary service. If you use an overlay, re-declare the port in the overlay.
### The app listens on `127.0.0.1`
An app bound to loopback accepts connections only from inside its own container. Bind to `0.0.0.0` — most frameworks take `--host 0.0.0.0` or `HOST=0.0.0.0`. A [Simple sandbox](/connect/single-repo#bind-to-all-interfaces) has the same requirement.
### The app listens on a different port than the mapping targets
In `"3000:8080"`, `8080` is the port inside the container, and the app must listen on it.
### The healthcheck never passes
* The app may still be starting — migrations, cache warm-up, a large bundle. Add a `start_period` long enough to cover it.
* The probe may target `localhost`, which can resolve to IPv6 `::1` while the app listens on IPv4 only. Probe `127.0.0.1`.
* The probe tool may not exist in the image. Use `wget` on Alpine, or your runtime itself.
### The build runs out of time or memory
See [Build time, caching and memory](#build-time-and-memory). A build step that exits with code `137` was killed for memory: choose a larger Compose size, or lower the Node heap ceiling your build script sets.
You can still save an environment whose check failed. You can't turn on pull-request testing for it until its configuration check passes.
## Other common problems
These come up most often with apps whose repository compose file was written for people running the published release, not for testing a pull request.
### Your compose file isn't in the list
qlane lists a file only if its name is `compose.yaml` or `docker-compose.yaml`, optionally with a dotted variant before the extension, such as `compose.qlane.yaml` or `docker-compose.test.yml`. The variants are qlane's picker rule: Docker Compose itself only picks up the plain names automatically. Both `.yaml` and `.yml` work, letter case doesn't matter, and the file can sit at any depth in the repository.
A name that uses a hyphen or underscore instead of the dot isn't recognised. `compose-qlane.yaml`, `docker-compose-qlane.yml` and `compose_test.yaml` never appear. Rename the file, for example to `compose.qlane.yaml`.
Also check that the file is committed on the branch you picked for the environment. If you commit it after opening the compose file step, go back a step and return to scan the repository again.
### The run passes, but your change isn't there
The service you preview is running a pulled image, not a build of the pull request. With an overlay, the usual cause is a missing `pull_policy: build`, which lets the included file's `image:` win. See [Option A](#option-a-add-a-testing-overlay).
### Compose stops before anything builds
Look for references to files or values that exist on a developer's machine but not in a fresh clone:
* A bind mount of an uncommitted file or directory — `./.env`, a local config file, a data directory. Pass the settings as environment variables, or commit a test version of the file and mount that.
* `user: "${UID}:${GID}"` — those variables aren't set in the sandbox, so the value is empty. Drop the line in your test compose file.
* An `env_file:` naming an uncommitted file — see [Step 2](#env-files).
If the shipped file has several of these, a standalone `compose.qlane.yaml` is simpler than overriding each one.
### A backing service exits right after it starts
Read that service's log in the run. Some database releases check the host kernel or CPU at start-up and refuse to run — for example, recent MongoDB releases exit on some newer Linux kernels with a message naming the incompatibility. Pin a specific version that starts, instead of a floating tag like `mongo:latest` or `mongo:8.0`: a floating tag can move to a release with different requirements between two runs.
### The app is up, but pages are blank or redirect to `localhost`
The app is building links, redirects or API calls from a `localhost` address. Set its own-URL settings from `${QLANE_SANDBOX_PUBLIC_URL}`, and check your committed `.env.example` for `localhost` values the compose file reads through `env_file:` — override those under `environment:`. See [Public address of your app](#public-address-of-your-app).
### The agent can't sign in, or is locked out partway through
* The test account doesn't exist. Check the **Postdeploy hook** step of the run for the hook's output.
* The user-creation command asked a question. A hook has no one to answer it, so it fails or hangs until the deadline. Pass every value as an argument or flag, so the command never prompts.
* The app's own brute-force or abuse protection kicked in. An agent signs in, retries and navigates fast, which can trip login limits and automatic bans. Relax them in your test compose file.
### Features that need a third-party key ask for one
Without the key, the feature shows a setup prompt, or asks each user for their own key, and the agent can't exercise it. Add the key in the **Env** step, and give the variable a `:?` default in the compose file so a missing key stops the run with a clear message.
### The build is killed even on the Large size
A build script that sets its own Node heap limit overrides any `NODE_OPTIONS` you set in the Dockerfile. Find the `--max-old-space-size` in your `package.json` scripts and lower it there. See [Build memory](#build-memory).
## Before you push: self-check and checklist
Run these from a clean clone, against the file you'll select (here `compose.qlane.yaml`). With an overlay, they read the **merged** result, which is what you need: the configuration screens don't follow `include:`.
```bash theme={null}
F=compose.qlane.yaml
# 1. Does it parse, and what starts? Empty output = every service is profile-gated.
docker compose -f $F config --services
# 2. Does the app service build from source?
docker compose -f $F config | grep -A3 'build:'
# 3. Which env files does it expect? Anything other than .env must be committed.
grep -n 'env_file' $F
# 4. Which variables must be supplied?
docker compose -f $F config 2>&1 | grep 'variable is not set'
# 5. Is every one-shot gated? A migrate or seed service with neither fails silently.
# `required: false` on a service_completed_successfully edge means it is NOT a gate.
grep -n 'service_completed_successfully\|pre_start\|required: false' $F
# 6. Anything that must already exist? Nothing creates these in the sandbox.
grep -n 'external: true' $F
# 7. Does the app need its own address? Check the compose file and the COMMITTED env
# files beside it (run from its directory). A hit that is the app's own URL needs
# ${QLANE_SANDBOX_PUBLIC_URL}; a hit inside a committed env file must move out of it.
grep -nE 'https?://(localhost|127\.0\.0\.1)' $F $(git ls-files .env .env.example)
# 8. Does it fit the budget with nothing reused, the way a first build runs?
docker compose -f $F down -v # discard volumes so init hooks re-run
time docker compose -f $F build --no-cache
time docker compose -f $F up -d # no --build: see below
# Together over ~24 minutes (~22 with a postdeploy hook) => see the build budget.
# 9. Does the app answer on its published port?
curl -fsS -o /dev/null -w '%{http_code}\n' http://localhost:3000
```
Don't add `--build` to `up` when you test locally: the sandbox runs a plain `docker compose up -d`, and `--build` hides a missing `pull_policy: build`.
### Verify an overlay builds rather than pulls
After step 8, check which image the app's container runs:
```bash theme={null}
docker inspect --format '{{.Config.Image}}' # must be myapp-pr:test
```
That check only proves something if the overlay renamed the image to a name no registry has: with the base file's published name, it reads the same whether your code was built or the release was pulled. If you can't rename the image, compare identities instead: `docker build -q -t myapp-pr:expected .` prints an image ID, and `docker inspect --format '{{.Image}}'` must show that same ID.
The final state to aim for:
* [ ] The app service builds from source, directly or through an overlay with `pull_policy: build` ([why](#build-from-source))
* [ ] Every repository under test builds its own code, not only the one the pull request was opened in ([several repositories](#several-repositories))
* [ ] It serves the production build, publishes a literal port, binds `0.0.0.0`, and has a healthcheck that probes `127.0.0.1`
* [ ] It starts without a profile, or the environment's profile selection activates one ([profiles](#compose-profiles))
* [ ] `env_file:` is `.env`, or the referenced file is committed ([env files](#env-files))
* [ ] Every required `${VAR}` has a default or is configured on the environment, and no real secret is committed ([interpolation](#variable-interpolation))
* [ ] The app's own URL comes from `${QLANE_SANDBOX_PUBLIC_URL}` in the compose file or an environment variable, never a hard-coded `localhost` or a committed `.env` ([public address](#public-address-of-your-app))
* [ ] Build-time credentials are build secrets, not build arguments or files left in a layer ([private packages](#private-registries))
* [ ] Migrations run before the app serves, a failure stops the start-up, and a test account is seeded ([Step 3](#step-3-get-the-app-into-a-testable-state))
* [ ] A build that reuses nothing fits the budget on the Compose size you picked, and doesn't exhaust the Node heap ([budget](#build-time-and-memory))
* [ ] Nothing is declared `external: true`, and nothing bind-mounts an absolute or uncommitted path ([what's checked](#whats-checked))
* [ ] With several components: service names unique, no host-port collisions ([several repositories](#several-repositories))
## What happens behind the scenes
For each run, qlane creates a fresh sandbox, clones your repository at the pull request's head commit, writes `.env` beside your compose file, and runs `docker compose up -d`. It waits for the primary service to report healthy and answer on its published port, runs any postdeploy hooks, and then connects the agent to the preview URL. When the run ends — pass, fail, or cancelled — the sandbox is destroyed. Nothing survives between runs except the environment's [build cache](#build-caching).
# Existing previews
Source: https://docs.qlane.ai/connect/existing-previews
Test the per-PR preview deployments you already build with Vercel, Netlify, or Cloudflare Pages — no extra sandbox compute, faster turnaround.
If your team already builds a preview deployment for every pull request, you don't need QLane to build it again. QLane waits for your preview to be ready and runs the agent against it.
## When to use this mode
* You already have **per-PR preview deployments** on Vercel, Netlify, Cloudflare Pages, or similar.
* You want **faster turnaround** — no QLane build means tests start sooner.
* You want to **save sandbox credits** — preview-mode sessions don't consume sandbox compute.
* You want QLane to test **the exact artifact your team reviews** in the preview.
If you don't have previews already, or your stack needs services that aren't in the preview (a real database, a worker), use [Single repo](/connect/single-repo) or [Docker Compose](/connect/docker-compose) instead.
## Set it up
From the project page, open **Environments → New environment** and choose **Existing previews**.
1. **Provider** — Vercel, Netlify, Cloudflare Pages, or "Other (URL template)" for everything else.
2. **Repository** — the GitHub repo whose previews you're testing.
3. **URL template** *(only when "Other")* — for example, `https://{branch}.preview.example.com`. QLane fills `{branch}`, `{commit}`, or `{pr}` from the PR.
4. **Test credentials** — same as [Test a URL](/connect/test-a-url). Encrypted at rest.
5. **Linked repo** — read-only code access for the agent. Defaults to the same repo.
Save.
### How QLane discovers your preview
For first-party integrations, QLane reads the provider's `deployment_status` webhook — the same signal your team uses to find the preview in your PR. When the deployment goes "ready", QLane starts the session.
For URL-template setups, QLane interpolates the template using PR metadata and polls the URL until it responds.
## Triggering runs
Like other PR-bound modes, sessions fire automatically when a PR is opened or updated and PR testing is enabled for the project. You can also trigger a session manually for any commit on the dashboard.
## What you don't get
Preview mode is browser-only. The agent has no shell, no logs, no database — just the running app at the URL.
* **No shell access.** If you need the agent to inspect files or run commands, link a repo (read-only code access) or move to a sandbox mode.
* **No log inspection.** Server logs on Vercel/Netlify/etc. aren't reachable from the sandbox.
* **No deep-testing mode.** The agent can't add temporary logging and re-run; it can only drive the UI.
These limits aren't blocking for most teams — most PR regressions are visible in the browser. But if your bugs hide in server-only paths, pair this with a Compose environment for the harder ones.
## Common gotchas
* **Preview takes ages to build.** QLane waits patiently, but the user-visible turnaround is dominated by your provider's build, not QLane. If preview builds are slow, consider a sandbox mode for the parts you need fast feedback on.
* **Preview auth.** If your preview requires a password (Vercel password-protected previews, Netlify password lock), the agent can't get in. Either disable preview auth, or move to a sandbox mode where the URL is generated by QLane and not protected.
* **Deployment URL changes.** Some providers rotate URLs between commits. The webhook-driven flow handles this; URL templates only work if the pattern is stable.
# How QLane fits your app
Source: https://docs.qlane.ai/connect/overview
Compare the four ways to connect QLane to your app: testing a URL, a single repo, a Docker Compose stack, or an existing PR preview deployment.
Whatever your codebase looks like, QLane gives the agent a real, running version of your app to drive. You pick which of the four modes matches your setup; you can mix them across environments in the same project.
| Mode | When to use it | What QLane does |
| - | - | - |
| [**Test a URL**](/connect/test-a-url) | You already have a staging or production URL the agent can reach. | Hits the URL directly. No build, no sandbox. |
| [**Single repo**](/connect/single-repo) | One repo, one Node.js app. | Clones, builds, and runs it in an isolated sandbox on every PR. |
| [**Docker Compose**](/connect/docker-compose) | Multi-service stack (web + API + worker + database). | Brings the whole stack up from a Compose file. |
| [**Existing previews**](/connect/existing-previews) | You already deploy per-PR with Vercel, Netlify, or Cloudflare. | Waits for your preview, then tests on top — no QLane compute. |
## What stays the same across modes
The agent's job doesn't change:
* It opens a **real browser** against the running app.
* It runs your **active test cases** plus any drafts triggered by the PR diff.
* It captures **per-bug screenshots, DOM snapshots, and reproduction steps**.
* It posts a **GitHub review** — silent on pass, structured on fail.
What changes between modes is only *how* the app comes alive in front of the agent.
## Choosing a mode
A simple rule of thumb:
* You **don't** want QLane to build your code: use **Test a URL** or **Existing previews**.
* You **do** want QLane to build your code, and your app is one repo: use **Single repo**.
* Your app needs a database or other services to even start: use **Docker Compose**.
If you outgrow one mode you can switch — environments are configuration, not infrastructure, and the rest of your project (test cases, history, coverage) carries over.
## Multiple environments per project
A project can hold more than one environment. Common patterns:
* **Staging URL + PR sandbox.** Smoke the staging environment on a schedule, and spin up a fresh sandbox on every PR.
* **Preview + Compose.** Use the Vercel preview for frontend PRs, fall back to a Compose stack for backend changes.
You wire which environment a PR triggers from the project's **PR testing** settings.
# Single repo
Source: https://docs.qlane.ai/connect/single-repo
Connect a single-repository app so qlane clones the pull request, builds it, serves it in an isolated sandbox, and tests it with full shell access. The four fields, what you can type in them, and why the start command must serve the production build.
The default mode for web apps that live in one repository and run on their own. For every pull request, qlane clones the pull request's code, installs and builds it, starts it in a fresh sandbox, and connects the agent.
**Name in the product:** this mode is a **Simple sandbox**. The environment page's header calls it that, and when you create the environment it is the **Single service** option. This page says Simple sandbox from here on.
## When to use this mode
* Your app is **one repository** that runs on its own: a static site, a front end, or an API that only talks to hosted services.
* You can install, build and serve it with your package manager's scripts.
* You want the agent to test the **exact code in the pull request**, not a separate preview deployment.
If your app needs a database, cache, queue or object store running beside it, use [Docker Compose](/connect/docker-compose) (a **Composed sandbox**) instead. If you already build per-PR previews on Vercel, Netlify or Cloudflare, [Existing previews](/connect/existing-previews) is usually a better fit: the same coverage with no extra build time.
## Set it up
From your project, click **Add environment** and choose **Test every pull request**.
Pick the **Repository** and **Branch** (the default to test against, typically `main`; on a pull request qlane uses its head commit automatically). If the app is not at the repository root, set the **Root directory**. See [When the app is not at the repository root](#root-directory).
Under **How does this app run?**, choose **Single service**.
If your app needs a login, add a test account the agent can sign in with. Skip this step if it has none.
Fill in the [four fields](#the-four-fields). qlane reads your `package.json` and suggests values; check them against the rules below, especially [serve the production build](#serve-the-production-build).
Check the summary and create the environment.
Then add any variables your app needs to boot (an auth secret, an API base URL) under **Runtime Variables** on the environment's page. Mark sensitive values as **secret** to encrypt them and mask them in the UI. Without the values it needs, the app won't start and the run fails before the agent connects.
## The four fields
| Field | What it does |
| - | - |
| **Install command** | Runs first. Defaults to `npm install` when you leave it empty. |
| **Build command** | Runs next, and **only if you fill it in**. Left empty, the step is skipped entirely. |
| **Start command** | Runs last, in the background. **Required**: nothing is guessed for you if you leave it blank. |
| **Port** | The port your started server listens on. The preview URL points at it. |
**Install and build must run to completion and exit `0`.** A non-zero exit fails the environment and reports that step's own output, so a failure names the step it came from.
**The start command must keep running.** It is launched in the background as a long-running server. If it exits with an error, the step fails at once and shows its output. If it exits cleanly, nothing is left listening, and the step fails when the 30-second window ends.
**There is no ready-check setting. Your app has 30 seconds to answer.** Once the start command is launched, a request to the preview URL must reach your app within a fixed 30-second window. Build time does not count against it, because the build is a separate step that finishes first. The window measures how long your server takes to begin serving, not how long your project takes to compile.
Any response your app returns counts, including a 4xx or a 5xx: an app that is up and failing is a finding for the agent to report, not a reason to keep waiting. A reply from the sandbox's own edge saying nothing is listening yet does not count.
`PORT` is set in the start command's environment to the value of the **Port** field. Some frameworks read it and others ignore it, so treat it as a convenience rather than a guarantee. If your framework takes a port flag or reads a port from its config file, make sure that value and the **Port** field agree.
## Serve the production build, not the dev server
This is the most consequential choice on the screen, and the default answer is the production build:
* **Build command**: your production build.
* **Start command**: the command that serves what the build produced.
A dev server skips the production transforms: minification, bundling, tree-shaking, dead-code and unused-CSS elimination, asset hashing, and build-time inlining of environment variables. A whole class of regressions lives there. A class name works in dev and is stripped from the build. A dynamic import breaks only once bundled. A variable is read at runtime in dev but baked in at build time in production. Point the agent at a dev server and it can't see any of them, so it passes a pull request that breaks the moment it ships.
Dev servers also add machinery production doesn't have: hot-reload sockets, and error overlays that can mask, restyle or swallow a real failure.
Fall back to a dev server only when the repository has no way to serve built output.
## Listen on `0.0.0.0`, not `127.0.0.1`
Most dev and preview servers listen on `localhost` only. That default is invisible on a laptop and fatal in a sandbox: a server bound to loopback inside the sandbox can't be reached from outside it. There is no error message. The 30-second wait expires while the process looks perfectly healthy.
Pass whatever your framework uses to listen on every interface: commonly a `--host 0.0.0.0` flag, sometimes a `HOST=0.0.0.0` variable. A [Composed sandbox](/connect/docker-compose#the-app-listens-on-127-0-0-1) has the same requirement.
## What you can type in the command fields
Each command **is** run through `sh -c`. What limits you is the check that runs before it. Because the shell is real, that check is what stands between a command and everything a shell would otherwise let it do. Two restrictions apply before anything runs:
* **The first word must be one of:** `npm`, `npx`, `yarn`, `pnpm`, `bun`, `node`, `next`, `turbo`, `tsc`.
* **These characters are rejected anywhere in the field**, as are line breaks and carriage returns:
``; | & ` $ ( ) { } \ < >``
So **you can't chain, redirect or substitute in a field.** `npm run build && npm run postbuild` is refused, so is `cd app && npm start`, and so is anything using `$(...)`.
The remedy is better than the thing it replaces: **put the chain in a `package.json` script and call the script.** The restriction applies to what you type in the field, never to what the script does, so an `&&` inside a package script runs normally.
```json package.json theme={null}
"scripts": {
"build": "vite build && node scripts/postbuild.mjs",
"serve": "vite preview --host 0.0.0.0"
}
```
The fields are then simply `npm run build` and `npm run serve`, and both pass.
## When the app is not at the repository root
Set the environment's **Root directory** rather than trying to `cd` into the subdirectory, which the [character rules](#allowed-commands) forbid anyway. Commands then run inside that directory, which also lets the package manager find the workspace's own manifest and use the package manager it declares.
## A worked example
A static site built by a bundler, whose `package.json` has a `build` script and a `preview` server:
| Field | Value |
| - | - |
| Install command | `npm ci` |
| Build command | `npm run build` |
| Start command | `npm run preview -- --host 0.0.0.0` |
| Port | `4321` |
Three things in it matter:
* **`npm ci` rather than `npm install`.** It installs exactly what the lockfile pins, which is reproducible and usually faster. It fails outright if the lockfile has drifted from `package.json`; if the first run errors there, switch to `npm install`.
* **The bare `--`.** With npm, it passes `--host` through to the underlying command instead of npm consuming it.
* **The port is the preview server's, not the dev server's.** They are often different numbers in the same project, and **Port** must name the one the start command binds.
To find your own values, read the `scripts` block in `package.json`. The script that serves built output is usually called `preview`, `serve` or `start`, but check what it does rather than trusting the name: a `start` script that runs the dev server is common, and it is the [wrong choice](#serve-the-production-build).
## Variables
* **Your app's own public address isn't provided.** `QLANE_SANDBOX_PUBLIC_URL` is set only in a [Composed sandbox](/connect/docker-compose#public-address-of-your-app). A variable on a Simple sandbox that references `${QLANE_SANDBOX_PUBLIC_URL}` stops the run rather than expanding to nothing.
* **Names starting with `QLANE_` are reserved.** Saving a variable of your own with that prefix is refused.
## Turn on pull-request testing
A new environment starts with only **Mention triggers** on: comment `@qlane-ai test` on a pull request to start a run. To test every pull request automatically, open **Project settings → PR testing**, find the environment under **Per environment**, and turn on **Automatic runs**. See [Triggers](/triggers) for the details, and [Docker Compose](/connect/docker-compose#step-5-turn-on-pull-request-testing) for **Watch paths**, which work the same way here.
## What qlane does on each pull request
1. Provisions a fresh, isolated sandbox.
2. Clones your repository at the pull request's head commit.
3. Runs your install command, then your build command if you set one, then starts your app.
4. Waits up to 30 seconds for the app to answer on the configured port.
5. Connects the agent and runs your active test cases plus any drafts generated from the pull request's diff.
6. Posts a check and, if anything broke, a structured review.
## What the agent can do in this mode
In addition to a real browser, the agent gets:
* **Shell access** inside the sandbox (`bash`, `readFile`). It can inspect logs, check files it wrote, and re-run commands.
* **Code awareness.** It reads the repository to understand failures and write better reproductions.
* **Deep testing.** When stuck, the agent can add temporary instrumentation, re-run, and observe. The sandbox is torn down after the session, so changes don't escape.
## Common gotchas
* **Native dependencies.** Anything that needs a system library should be in your package's install hooks. The sandbox is Linux-based, so macOS-only build steps fail.
* **A slow server start.** The 30-second window covers start-up only, not the build. If your server needs longer to begin serving, move the work into the build step, or serve the prebuilt output with a lighter server.
* **Required variables.** Set the auth secret, API URLs and anything else the app needs under **Runtime Variables**. Without them the app won't boot, and the run fails before the agent connects.
## Checklist
* [ ] The start command serves the production build, not a dev server ([why](#serve-the-production-build))
* [ ] The server listens on `0.0.0.0`, not `127.0.0.1` ([why](#bind-to-all-interfaces))
* [ ] **Port** is the port the start command actually binds ([fields](#the-four-fields))
* [ ] Every field starts with an allowed command and contains no rejected characters ([rules](#allowed-commands))
* [ ] Anything that needs chaining lives in a package script, not in a field ([rules](#allowed-commands))
* [ ] The app is at the repository root, or **Root directory** is set ([root directory](#root-directory))
* [ ] The server answers within 30 seconds of the start command launching ([fields](#the-four-fields))
* [ ] The app needs no backing services. If it does, use a [Composed sandbox](/connect/docker-compose)
# Test a URL
Source: https://docs.qlane.ai/connect/test-a-url
Point QLane at any reachable staging or production URL to start testing in minutes — no build, no provisioning, optional credentials and linked repo.
The fastest way to try QLane. If you already have an environment running somewhere the agent can reach — staging, a public preview, even production — you can hand QLane the URL and start testing immediately.
## When to use this mode
* You want to **try QLane in two minutes** before wiring up CI.
* You have a **shared staging environment** the team uses for QA.
* You want to **smoke-test production** on a schedule.
* Your app is **deployed somewhere QLane can't build** (a customer environment, an internal preview system).
If you want testing tied to a specific pull request and commit, prefer [Single repo](/connect/single-repo), [Docker Compose](/connect/docker-compose), or [Existing previews](/connect/existing-previews) — those check out and run your branch.
## Set it up
From the project page, open **Environments → New environment** and choose **Test a URL**.
1. **Target URL** — the URL the agent should open. Anything reachable from the public internet works.
2. **Test credentials** *(optional but recommended)* — a username and password the agent can use to sign in. Stored encrypted; never appear in transcripts or logs.
3. **Linked repo** *(optional)* — a GitHub repo QLane reads from to make tests code-aware. The agent uses it to write better test cases, reproduce bugs, and reason about diffs. Read-only.
Save. The environment is ready.
## Triggering runs
URL environments don't auto-fire on PR webhooks — there's no notion of "this PR's URL." Instead, you trigger sessions:
* **Manually** from the dashboard ("Run now").
* **From Slack, Linear, Jira, or Claude Code** — see [Triggers](/triggers).
* **On a schedule** — pick a cadence in the environment settings.
## What you don't get in this mode
URL mode gives the agent a browser, not a shell. That means:
* **No log inspection.** The agent can't read your server logs.
* **No code execution.** No `bash` tool inside your app.
* **No database queries.** State changes must happen through the UI.
If those matter, link a repo (read-only code access) or move to a sandbox-based mode.
## Common gotchas
* **The URL needs to be reachable.** Behind-VPN URLs need an allowlist for QLane's egress IPs. Contact support if you need them.
* **Don't point at production without test credentials.** The agent will navigate freely. Give it a dedicated test account, not your CEO's login.
* **Rate limits and CAPTCHAs.** Whitelist QLane's agent user-agent in any bot-protection layer, or scope it to a staging environment.
# QA for the AI era
Source: https://docs.qlane.ai/index
QLane is the AI QA engineer that tests every pull request in a real browser and posts structured GitHub reviews with screenshots and repro steps.
Teams using AI coding tools ship 98% more pull requests. Review time grew 91%. The code volume scaled — QA didn't. **QLane is the QA half.**
Point QLane at your app, open a pull request, and an AI agent navigates it in a real browser, finds the bugs, and posts a structured review with screenshots and repro steps. Silent on pass. Evidence on fail.
## What you get on every PR
Reproducible, not opinionated. Every bug ships with a screenshot, the exact click path, and a severity grounded in user impact.
Generated as you ship. QLane reads each PR diff and drafts the cases that should exist — they become active when you merge.
What's tested, what's not. Coverage areas mapped to test cases with pass/fail history.
Silent on pass. Bugs come back as structured per-comment reviews with severity tags.
## Pick your starting point
Five minutes to your first PR test.
Test a URL, a single repo, a Compose stack, or your existing previews.
Run your whole multi-service app on every PR.
What a finding looks like, and how to triage one.
## Who it's for
* **Engineers** — bugs caught before review, with reproducible evidence. `/qlane:fix` loads the bug straight into your editor.
* **QA teams** — AI as a force multiplier. Review proposals, run sessions on demand, keep the smoke suite sharp.
* **Product managers** — release with evidence, not vibes. Coverage gaps surface the moment a feature ships without tests.
# Billing
Source: https://docs.qlane.ai/manage/billing
Understand QLane plans, credits, concurrency, and retention — plus how to upgrade, downgrade, view invoices, and manage your subscription.
QLane bills per organization. Manage everything from **Settings → Billing**.
## Plans
Each plan defines:
* **Monthly credits** — your budget of test work.
* **Concurrency** — how many sessions can run at the same time.
* **Retention** — how long session events, screenshots, and reports are kept.
* **Team features** — SSO, audit logs, dedicated capacity.
See the [pricing page](https://qlane.ai/pricing) for the current plans and limits.
## Credits
A credit is the unit of test work. The exact cost of a session depends on:
* **Mode.** URL and preview-based sessions are cheaper than sandbox sessions because there's no build. Compose sessions cost more than single-repo sessions because the stack is bigger.
* **Duration.** Sessions consume credits proportional to wall-clock time.
* **Browser minutes** the agent uses.
Usage updates in near real-time on the **Usage** page, with a breakdown by project and a forecast for the rest of the cycle.
When you exceed your monthly credits, additional sessions are billed at the per-credit overage rate. On plans without overage, new sessions are gated until the next cycle.
## Customer portal
Open the **Customer portal** from the billing page to:
* Update card details
* Download invoices
* See upcoming charges
* Add a billing email
## Upgrading and downgrading
From **Settings → Billing → Change plan**:
* **Upgrade** — new entitlements take effect immediately. You're charged the prorated difference.
* **Downgrade** — new entitlements take effect at the end of the current billing cycle.
Annual billing is available on Pro and Enterprise at a discount.
## Cancelling
Cancel from **Settings → Billing → Cancel subscription**. Your plan continues until the end of the current cycle, after which the organization drops to the starter plan. Past sessions and bug reports remain accessible subject to the starter plan's retention.
## Need a custom plan?
Contact [sales](mailto:sales@qlane.ai) or book a [demo](https://cal.com/qlane/demo) for dedicated capacity, custom retention, or volume pricing.
# Team and access
Source: https://docs.qlane.ai/manage/team-and-access
Invite teammates, assign admin, member, and viewer roles, configure SAML SSO and SCIM directory sync, and review audit logs in your QLane organization.
QLane is built for engineering teams from day one. SSO, role-based access, and audit logs are available on every paid plan.
## Roles
| Role | Can do |
| - | - |
| **Admin** | Manage members, billing, projects, environments, integrations. Delete the organization. |
| **Member** | Create and edit projects and test cases. Run sessions. Cannot manage billing or members. |
| **Viewer** | Read-only. View projects, sessions, and reports. |
There must be at least one admin at all times.
## Inviting teammates
From **Settings → Members**:
1. Click **Invite member**.
2. Enter the email and pick a role.
3. Send.
The recipient gets a sign-in link. If your org uses SSO, they sign in through your identity provider; otherwise, they sign in with their email.
## Single sign-on (SSO)
On paid plans, you can require SSO for everyone in your organization. From **Settings → Authentication**:
1. Pick your identity provider — Google Workspace, Okta, Microsoft Entra, and any SAML 2.0 IdP are supported.
2. Follow the configuration wizard.
3. Verify your domains. Once verified, users at those domains are routed through your IdP automatically.
Just-in-time provisioning creates users on first sign-in. Directory Sync (SCIM) keeps your member list in lockstep with your IdP — when someone leaves the company, their QLane access is revoked automatically.
## Audit logs
Every meaningful action — sign-ins, role changes, test case edits, environment updates, integration changes — is recorded with the actor, timestamp, and the change made. Available on Pro plans and above under **Settings → Audit log**, with optional streaming to your SIEM.
## Secrets
Secrets you store on environments (database URLs, API keys, test credentials) are encrypted at rest. They're decrypted into the sandbox at session time and never appear in agent transcripts or stored logs. Redaction is applied to every captured screenshot before storage.
## SOC 2
QLane is on a SOC 2 path. Security review documents are available on request from the **Security** page or by emailing [support@qlane.ai](mailto:support@qlane.ai).
# Quickstart
Source: https://docs.qlane.ai/quickstart
Get QLane testing your pull requests in five minutes — sign up, connect a GitHub repo, create a project, pick a run mode, and open your first PR.
This is the shortest path to QLane testing your pull requests. By the end you'll have a structured review posted on a real PR.
## 1. Sign up
Head to [qlane.ai/sign-in](https://qlane.ai/sign-in) and sign in with your work email. QLane creates an organization for you on first sign-in.
## 2. Connect your repository
QLane installs as a GitHub App on the org or account that owns the repo you want to test.
1. From the dashboard, click **Connect GitHub**.
2. Pick the repositories you want QLane to test. You can change this later.
## 3. Create a project
A **project** is a QLane workspace bound to one repository.
1. Click **New project** and select the repo.
2. QLane scans for a `package.json` and a Compose file to suggest sensible defaults.
3. Confirm and create.
## 4. Pick how QLane runs your app
QLane needs a way to reach your app. Pick the mode that matches your codebase — you can change it later, and projects can mix modes across environments.
Already have staging or a public preview? Point QLane at the URL — no build, no provisioning.
One repo, one app. QLane clones, builds, and runs it on every PR.
Run your whole stack — every service, with realistic data.
Already deploying per PR with Vercel, Netlify, or Cloudflare? QLane tests on top.
## 5. Open a pull request
Open any PR in your repo (or push to an existing one). Within a couple of minutes you'll see:
* A **QLane** check appear on the PR.
* A new run in your dashboard under the project.
When the run finishes, you get either silence (nothing broke) or a GitHub review with per-bug comments, screenshots, and repro steps.
## Next steps
Pin down the behaviour you care about most.
Slack, Linear, Jira, Claude Code, the dashboard, the CLI.
See what a finding actually looks like.
Find the gaps before they ship.
# Bug reports
Source: https://docs.qlane.ai/results/bug-reports
What's in a QLane bug report — severity, repro steps, screenshots, DOM and console logs — and how to triage, dismiss, or push it to Linear or Jira.
When the agent finds a defect, it files a **bug report**. Bug reports are evidence-grade: they include the exact reproduction steps, a screenshot at the moment of failure, expected vs. actual behaviour, and a severity tag grounded in user impact.
## What's in a bug report
Every bug report has:
* **Title** — a one-liner describing the symptom (e.g. *"Checkout submit reports success without making the API call"*).
* **Severity** — `critical`, `major`, or `minor`. Critical = revenue or data loss path. Major = a key user flow is broken. Minor = visual or non-blocking.
* **Expected vs. actual** — what the agent thought should happen and what it observed.
* **Reproduction steps** — the click path the agent followed, in plain English, with the URLs it landed on.
* **Screenshot** at the moment of failure.
* **DOM snapshot** of the page, downloadable from the bug detail.
* **Console and network logs** captured around the failure.
* **Source link** *(when available)* — the file and line the agent thinks is most likely involved. When the agent is confident, this anchors a comment in the GitHub review.
## Triaging a bug
There are three ways to act on a bug:
1. **It's a real regression.** Fix the code and push. The QLane check re-runs automatically on the new commit. When the fix lands, the bug is closed.
2. **It's a flake.** Mark the bug as **dismissed** with a reason. The agent learns from dismissals — the same flake won't be filed again in this project without a higher confidence threshold.
3. **It's expected behaviour** (the test case is wrong). Edit the test case to reflect the new expectation. Future runs use the updated case.
## Pushing bugs to your tracker
Connect Linear, Jira, or GitHub Issues from **Settings → Integrations**. Once connected, you can:
* **Auto-file** critical bugs into the configured project/repo.
* **Push selected bugs** manually from the bug detail page.
* **Round-trip status** — closing the ticket in your tracker marks the QLane bug as fixed; the next QLane run confirms.
The agent can also be invoked **from your tracker** — see [Triggers](/triggers) for `@qlane verify ENG-247` and similar.
## What QLane won't file
To keep the signal-to-noise ratio high, QLane never files:
* **Style nits or layout opinions** — that's what code-review tools are for.
* **"Looks fine to me" comments** — silent on pass.
* **Console warnings without user impact** — unless they correlate with a failed test case.
Bug reports are reserved for things a human would call a bug.
## The "evidence-grade" bar
The agent doesn't file a bug until it has a reproducible repro:
1. **First observation** — something looked wrong (API call missing, UI didn't update, an error was visible).
2. **Re-run check** — the agent re-attempts the same path. If the failure reproduces, it captures evidence.
3. **Evidence capture** — screenshot, DOM snapshot, console, network, click path.
4. **Severity assessment** — graded against the user impact, not the technical surface.
This is the difference between a bug report you trust and one you ignore. Every QLane bug is one a human could reproduce on their own from the report alone.
# Coverage map
Source: https://docs.qlane.ai/results/coverage
See what's tested and what's not with QLane's coverage map — a matrix of feature areas and test cases that surfaces gaps, trends, and untested epics.
The coverage map answers two questions every engineering team eventually asks:
1. *Is this feature actually covered by tests?*
2. *Where are the gaps we'll get burned by?*
It's a matrix of **coverage areas** (the features and surfaces of your app) crossed with **test cases**, with the most recent result for each pair.
## Coverage areas
QLane derives coverage areas automatically:
* From your **navigation structure** (the agent walks the app and identifies the major sections).
* From your **PR history** (recurring file paths and route changes cluster into areas).
* From **linked requirements** if you've connected an issue tracker or docs source.
You can edit, merge, or split areas from the **Coverage** tab. Areas are not test cases — they're the buckets test cases fall into.
## The matrix view
Each row is a coverage area; each column is a test case. Cells show:
* **Green** — the most recent run passed.
* **Red** — the most recent run failed.
* **Grey** — never run against this area (e.g. a test case was added after the last session).
* **Empty** — no test case maps to this area. **This is the gap signal.**
Filter by level (`smoke` / `sanity` / `regression`) to see what *protects the critical path* vs. what's catching subtle regressions.
## Gap signals
Two flags worth scanning for:
* **Areas with no smoke coverage.** A surface that no smoke test touches is one network blip away from being broken without you knowing.
* **Areas with only failing tests.** Often the test is wrong; sometimes the feature has been quietly broken for weeks.
The dashboard flags both on the project home page, alongside their owners (if you've mapped areas to team members).
## Trends over releases
The **Trends** view plots pass rate per area over time. Useful for:
* **Release readiness** — is the suite stable enough to ship?
* **Hot spots** — which area is regressing fastest? Often correlates with a refactor in that part of the code.
* **Reviewing your suite** — if an area has been 100% passing for six months, that's either great or the tests aren't actually exercising it.
## Connecting to your tracker
If you've connected Linear, Jira, or Notion, areas can be linked to **requirements** or **epics**. That lets you flip the question around:
* "Does this epic have tests?" — see every test case that traces to the epic's tickets.
* "Are we shipping un-tested work?" — every closed ticket without a test case linked surfaces as a gap.
## The point
Coverage maps aren't about a percentage on a dashboard. They're a feedback loop: every shipped feature gets a test, every gap is visible to the team that closed it, and you can show stakeholders coverage in their own language — features, not files.
# GitHub reviews
Source: https://docs.qlane.ai/results/github-reviews
How QLane posts results to GitHub — a required check run, silent passes, and structured pull request reviews with one comment per bug on failure.
The primary surface for QLane's PR-bound results is GitHub itself — a check run on every PR, plus a structured review when something broke.
## The check run
Every PR with QLane enabled gets a single check run named **QLane**. It moves through three states:
| State | Meaning |
| - | - |
| **In progress** | The agent is testing right now. Click for live progress in the dashboard. |
| **Success** | All test cases passed, no bugs filed. |
| **Failure** | At least one test case failed or a bug was filed. The check shows the count. |
You can require the QLane check in your branch protection rules to block merges on regressions.
## Silent on pass
When QLane finds nothing, you hear nothing — no PR comment, no review, no notification. The check just turns green. This is deliberate: every successful run that posts a comment is noise, and noise teaches your team to ignore the tool.
## Reviews on failure
When QLane finds bugs, it posts a single GitHub review with:
* A **summary** at the top: total passed, total failed, bug count, run duration, link to the dashboard.
* **One review comment per bug** — anchored to the line of code most likely to be the culprit when QLane can pinpoint one, or to the PR description otherwise.
* For each bug: severity, a one-line title, the exact reproduction steps, and a screenshot.
You can reply to a bug comment with `@qlane fix` to load the bug straight into your editor through the [Claude Code integration](/triggers).
## Re-running
If the failure looks like a flake — or you've pushed a fix — click **Re-run** on the GitHub check. QLane spins up a new session against the latest PR head SHA and reposts the review.
## Cancelling
Closing the PR cancels any in-flight session for it. You can also cancel from the session page in the dashboard.
## Where the screenshots live
Screenshots, DOM snapshots, and the full agent trace are stored on the session in the QLane dashboard, linked from each bug comment. We don't paste images into the GitHub comments themselves — review threads stay scannable, and the heavy assets stay one click away.
# Test cases
Source: https://docs.qlane.ai/results/test-cases
Write plain-English test cases for the QLane agent — draft, active, and archived states, AI-drafted proposals from PR diffs, levels, tags, and versioning.
A **test case** describes a behaviour you want QLane to verify. It's written in plain English, not code. You don't write selectors or assertions — the agent figures out how to perform the steps against the running app.
## Lifecycle: draft → active → archived
Test cases move through three states:
* **Draft.** Either AI-drafted from a PR diff or hand-written. Drafts are visible but don't run as part of normal sessions.
* **Active.** Runs on every applicable session.
* **Archived.** Hidden from new sessions. Past sessions still show their result.
The transition from `draft` to `active` is always a human action. **Drafts only become active when you accept them** — usually by clicking "Accept" on the proposal, or by merging the PR that introduced them.
## How drafts get generated
QLane drafts test cases in two situations:
* **From every PR diff.** When a PR is opened, QLane reads the diff and proposes test cases for the new behaviour. Drafts show up in the project's **Test cases** tab marked with the PR they came from.
* **From a baseline exploration.** On first project setup (and on demand), the agent explores your app and proposes a smoke suite — *"sign in works"*, *"create a project works"*, *"invite a teammate works"*. These show up the same way.
Drafts that don't get accepted simply expire — they never enter your active suite.
## Writing a test case yourself
Open the **Test cases** tab and click **New test case**.
1. **Title** — a one-liner.
2. **Goal** — what should happen, from the user's point of view. Example: *"A signed-in user can create a project and see it in the project list."*
3. **Preconditions** *(optional)* — state the app must be in before the test (a known fixture, a test account, etc.).
4. **Level** — `smoke` (must always pass), `sanity`, or `regression`.
5. **Tags** — for filtering and selective runs.
You're done. The next session picks it up.
## Test levels
Levels signal what *kind* of failure each test catches:
* **Smoke** — the app is alive. If smoke fails, nothing else matters. Smoke is what runs first and what gates merges.
* **Sanity** — primary user flows. Most of your active suite lives here.
* **Regression** — narrow tests pinned to specific past bugs. They guard against re-introductions.
QLane infers a level when drafting, but you can override.
## Editing and versioning
Editing a test case applies to all future runs. **Past session reports show the version of the test case that was in effect at the time**, so historical results remain reproducible.
## Writing tests the agent runs well
A few patterns work well:
* **One outcome per case.** "Sign in *and* create a project" should be two cases — failures are easier to diagnose.
* **Describe the user's intent, not the UI.** "Sign in" beats "Click the green button labelled 'Sign in'" — the latter breaks the moment you rename the button.
* **Reference real fixtures.** "Sign in as `qa@example.com`" is more reliable than "Sign in as a test user."
* **Lean on preconditions.** Don't make the test create its own scaffolding if a precondition can pin it.
## Promotions and demotions
Accepted test cases aren't immutable. The agent watches how each one performs across sessions and surfaces proposals:
* **Promote** a frequently-passing sanity test to smoke, if it covers a critical path.
* **Demote** a frequently-flaky case to regression so it doesn't gate merges.
* **Archive** a case that's been duplicated by a newer one.
You approve or reject each proposal — promotions and demotions are never silent.
# Triggers
Source: https://docs.qlane.ai/triggers
Trigger QLane sessions from GitHub, Linear, Jira, Slack, Claude Code, the browser extension, or on a schedule — wherever your team's work happens.
QLane runs **where work happens**. The agent doesn't care where the request came from — you get the same kind of session, with the same kind of report, no matter how you kicked it off.
## On every pull request
The default. With PR testing enabled, every PR opened or updated triggers a session against the configured environment. The result lands as a [GitHub review](/results/github-reviews).
No setup beyond connecting the GitHub App and enabling PR testing on the project.
## From GitHub
* **Mention `@qlane`** on any issue or PR comment. Example: `@qlane verify the signup flow on staging.` The agent runs and replies with the result in-thread.
* **Assign QLane as a reviewer** on a PR to trigger a fresh run.
* **Use the GitHub Action** — `uses: qlane/qa-action@v1` in your workflow to fire sessions from CI on any event.
## From Linear
* **Mention `@qlane`** on a Linear issue: `@qlane reproduce ENG-247 on staging`. The agent runs and threads its activity inline, like any other Linear agent session.
* **Assign the QLane bot** to delegate via Linear's Agent Sessions.
* Findings come back as **comments with severity** and a deep link to the full run.
Set up under **Settings → Integrations → Linear**.
## From Jira
* **Mention `@qlane`** on a Jira ticket to trigger a session.
* **Assignment triggers a session** — assign the QLane user to a ticket and the agent picks it up.
* Findings post back as comments on the ticket with severity tags.
Set up under **Settings → Integrations → Jira**.
## From Slack
* **Slash command:** `/qlane test staging` in any channel where the bot is invited. The agent picks up the project bound to that channel and reports back in-thread with bugs, screenshots, and a dashboard link.
* **Mention `@qlane`** in a thread to chat with the agent or kick off a one-off session.
Set up under **Settings → Integrations → Slack**.
## From Claude Code
* **`/qlane:test`** runs a QA pass against `localhost` from inside your editor — perfect for "I want to know if this works before I push."
* **`/qlane:fix PR 247`** loads the bug, repro steps, and screenshot for a failing PR straight into your Claude Code session.
Install from the **Claude Code** marketplace; sign in once with your QLane account.
## From the browser extension
See a bug while you're using the app? Click the extension. The agent reproduces it cold, captures evidence, and files a structured report — with the path you took already mapped.
Available for Chrome and Arc. Sign in once with your QLane account.
## On a schedule
For URL environments, you can run a session on a cron-like cadence. Useful for:
* **Smoke-testing production** every few hours.
* **Catching environment drift** between releases.
* **Validating long-running flows** that need real data accumulation.
Set the cadence on the environment under **Triggers → Schedule**.
## Manually from the dashboard
Every environment has a **Run now** button. Pick a commit/branch/PR and the test cases to run, and the agent starts. Use this for ad-hoc validation when none of the integrations fit.
## What all triggers have in common
* **One session per trigger.** No matter the surface, you get one auditable session with its own dashboard URL.
* **The same agent.** Browser, evidence, severity, repro steps — every trigger produces evidence-grade output.
* **The same credit cost.** Whichever surface fires the run, the session costs the same against your plan.