> ## Documentation Index
> Fetch the complete documentation index at: https://docs.qlane.ai/llms.txt
> Use this file to discover all available pages before exploring further.

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

<Info>
  **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.
</Info>

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

<h2 id="build-from-source">
  The one requirement that matters most
</h2>

<Warning>
  **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.
</Warning>

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

<h3 id="option-a-add-a-testing-overlay">
  Option A: add a testing overlay
</h3>

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.

<Note>
  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`.
</Note>

**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).

<h3 id="option-b-write-a-standalone-test-compose-file">
  Option B: write a standalone test compose file
</h3>

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

<h2 id="step-2-environment-variables">
  Step 2: Environment variables
</h2>

<h3 id="env-files">
  `.env` is written for you — other env files must be committed
</h3>

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

<h3 id="variable-interpolation">
  `${VAR}` interpolation
</h3>

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.

<h3 id="public-address-of-your-app">
  Public address of your app
</h3>

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://<port>-<sandbox-id>.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.

<h3 id="escape-dollar-signs">
  A literal `$` inside `configs:`
</h3>

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.

<h3 id="private-registries">
  Private packages during the build
</h3>

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.

<h2 id="step-3-get-the-app-into-a-testable-state">
  Step 3: Get the app into a testable state
</h2>

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.

<h3 id="migrations">
  Migrations — `pre_start`
</h3>

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.

<h3 id="postdeploy-hook">
  A test account and seed data — the postdeploy hook
</h3>

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 "<name>" 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**.

<Steps>
  <Step title="Repo">
    Pick the **Repository** and **Branch** (the branch the environment reads its compose file from — usually your default branch), and name the environment.
  </Step>

  <Step title="Runtime">
    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.
  </Step>

  <Step title="Login">
    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.
  </Step>

  <Step title="Compose file">
    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.
  </Step>

  <Step title="Env">
    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.
  </Step>

  <Step title="Infra">
    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).
  </Step>

  <Step title="Review">
    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).
  </Step>
</Steps>

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

<h2 id="step-5-turn-on-pull-request-testing">
  Step 5: Turn on pull-request testing
</h2>

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 <project>/<environment>` 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

<h3 id="compose-profiles">
  Compose profiles
</h3>

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 <file> config --services`.

<h3 id="exposed-services">
  Exposing more than one service
</h3>

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.

<h3 id="several-repositories">
  A stack that spans several repositories
</h3>

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.

<h2 id="build-time-and-memory">
  Build time, caching and memory
</h2>

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 |

<h3 id="build-caching">
  Build caching
</h3>

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.

<h3 id="build-memory">
  Build memory
</h3>

**"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.

<h2 id="worked-example">
  Worked example: an app whose shipped compose file can't be reused
</h2>

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.

<h2 id="whats-checked">
  What's checked before a run
</h2>

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

<h2 id="troubleshooting-a-failed-configuration-check">
  Troubleshooting a failed configuration check
</h2>

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.

<h3 id="primary-service-publishes-no-port">
  The primary service publishes no port
</h3>

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.

<h3 id="the-app-listens-on-127-0-0-1">
  The app listens on `127.0.0.1`
</h3>

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.

<h3 id="compose-file-not-listed">
  Your compose file isn't in the list
</h3>

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

<h2 id="checklist">
  Before you push: self-check and checklist
</h2>

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

<h3 id="verify-an-overlay">
  Verify an overlay builds rather than pulls
</h3>

After step 8, check which image the app's container runs:

```bash theme={null}
docker inspect <container> --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 <container> --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).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.