Skip to main content
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 (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.
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 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. 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.
compose.qlane.yaml
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. Nothing is wasted by either: a pull-request sandbox starts with no images of its own, and where 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 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 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.

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.
compose.qlane.yaml
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:
  • 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.
  • 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 and the self-check.

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, 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.
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:
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 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 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.

${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:
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://<port>-<sandbox-id>.e2b.app, or your environment’s custom domain where one is set. A Simple sandbox doesn’t set it.
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. Declare it just before the build step, not at the top:
  • 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 $$:
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:
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 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. 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 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.
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.

Accounts and data

Most apps need neither a test account nor seed data. Every run gives the agent an email inbox of its own, so it can sign up to your app the way a new user does, open the confirmation email, and sign in. Before testing a pull request, Qlane also tries to set up what each planned case needs, such as an account, a record or a screen in the right state, mostly through your app’s own pages. That is Set up preconditions before testing under Project settings → PR testing. It is on by default; check that it is on for your project. Accounts and records created this way go with the sandbox when the run ends, unless your stack keeps them in a hosted service. Add a test account when signing up isn’t enough:
  • Your app has no self sign-up: accounts are invite-only, created by an admin, or signed in through single sign-on.
  • Signing up needs something an inbox can’t give, such as a phone number, a CAPTCHA or an address on your own company’s domain, or a confirmation email your app can’t send from the sandbox.
  • Cases need a role or a plan that a new sign-up doesn’t get.
Seed data only when cases need state your app’s pages can’t create, such as history, usage figures or records that background jobs produce. For either, use the postdeploy hook below.

When you need a test account or seed data — the postdeploy hook

When signing up isn’t enough, or your cases need data your pages can’t create, 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.
  • 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). 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.
1

Repo

Pick the Repository and Branch (the branch the environment reads its compose file from — usually your default branch), and name the environment.
2

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

Login

If signing up isn’t enough for your app (see Accounts and data), 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. Otherwise skip this step.
4

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 and Exposed services you need.
5

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

Infra

Choose the Compose size (Medium by default), the Provision timeout (30 minutes by default, the maximum), and whether Build caching is on (it is by default). Pick Large for heavy front-end builds — see Build time, caching and memory.
7

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

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 <project>/<environment> to pick where it runs. See 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 <file> 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 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 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 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.
  • 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.

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.
  • A build argument whose value changes per sandbox stops reuse at the first RUN after its ARG — see build-time values.
  • Your .dockerignore rules still apply. To keep the written .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:
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:
compose.qlane.yaml
The variable names and commands are illustrative — use your app’s own. What carries over is what each choice is for: 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, because this app’s accounts are created by an administrator and the agent can’t sign itself up.

What’s checked before a run

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.

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

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

The agent can’t sign in, or is locked out partway through

  • The agent signed itself up, but the confirmation email never arrived. If your app sends that email itself, it needs real outgoing mail settings in the sandbox: a mail catcher such as Mailpit in your compose file keeps the email inside the sandbox, where the agent’s inbox can’t receive it. Set your app’s mail settings in the Env step, or add a test account.
  • 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.

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:.
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:
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)
  • Every repository under test builds its own code, not only the one the pull request was opened in (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)
  • env_file: is .env, or the referenced file is committed (env files)
  • Every required ${VAR} has a default or is configured on the environment, and no real secret is committed (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)
  • Build-time credentials are build secrets, not build arguments or files left in a layer (private packages)
  • Migrations run before the app serves, and a failure stops the start-up (Step 3)
  • A new sign-up can reach what your cases test, or a postdeploy hook creates the test account you added (accounts and data)
  • A build that reuses nothing fits the budget on the Compose size you picked, and doesn’t exhaust the Node heap (budget)
  • Nothing is declared external: true, and nothing bind-mounts an absolute or uncommitted path (what’s checked)
  • With several components: service names unique, no host-port collisions (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, tries to set up what the planned cases need, and then tests against 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.