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.yamlordocker-compose.yml.
The one requirement that matters most
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:- Does it contain a service that builds your app from source? If not, keep looking.
- Does that service publish a port (
ports: - "3000:3000")? Something must be reachable from outside the stack. - Does it start on a plain
docker compose up, or is the app behind aprofiles:gate? Either works — you select profiles on the environment — but you need to know which.
Option A: add a testing overlay
An overlay is a second compose file, used only for testing, thatinclude:s the file you already ship and overrides the one service that must build. Nothing you ship changes.
compose.qlane.yaml
depends_on, environment, healthchecks — merges in from the included file. Six things to know, in the order they bite:
pull_policy: buildis not optional.include:merges rather than replaces, so the base file’simage:survives alongside yourbuild:, and a plaindocker compose up -dpulls the published image and never builds. The overlay passes every check and still tests the release. The unregistrableimage: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.yamlanddocker-compose.qlane.ymlwork too, at any depth in the repository.qlanemust be the whole variant:compose.qlane-staging.yamlis 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://oroci://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’stest,intervalandretriesand 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.commandandentrypointreplace the base value.portsandenv_fileare 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.${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 itcompose.qlane.yaml so it’s listed first.
compose.qlane.yaml
-
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.dbehave inconsistently. -
A healthcheck on every service.
condition: service_healthymeans 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, notlocalhost. In many images — Alpine-based ones especially —localhostcan resolve to the IPv6 address::1. A server listening on0.0.0.0accepts IPv4 only, so alocalhostprobe 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 neitherwgetnorcurl. Then use your runtime, for examplenode -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.
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.
.env, committing a safe default under the referenced name, or marking the reference optional:
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:
:? 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 notlocalhost — 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 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_URLisn’t expanded, and a modifier such as:-fallbackis 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
.envor.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 yourlocalhostfallback. 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, beforeenvironment:applies. Pass it throughbuild.argsand declare the matchingARGin the stage that runs the build (noENVneeded — anARGis already in the environment of every laterRUNin its stage). The address differs in every sandbox, so every step from the firstRUNafter theARGis 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.exampleis 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:.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
.npmrcholding the token, or a~/.m2/settings.xmlwith 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 sameRUN. - 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 clonewrites it into.git/config, and so into the layer. Pass it through a credential helper or anAuthorizationheader instead.
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 Prismamigrate 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: withentrypoint: ["./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 ownentrypoint: ["sh", "-c"]withcommand: ["npm run db:migrate"]. - The service runs as a non-root
useror withread_only: true. The step does too, so a migration that writes inside the container fails. Give the step its ownuser:orread_only: false, or write to a volume.
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.
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 (notdistrolessorscratch), and the service must still be running — a hook on a one-shot that has exited fails withservice "<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, becausepre_startruns 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.
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.
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.
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 aprofiles: 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_PROFILESalone. 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.
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-emptynetworks:replaces the default. Same-named networks in two components’ files don’t merge, andexternal: truedoesn’t connect them either. So leavenetworks: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, includingservice_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,stagingand branches startingrenovate/ordependabot/. 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 HEADreturns the default branch, so don’t derive a version, tag or cache key from it there. - Clones are shallow, so builds that run
git describeor 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.jsonand the lockfile, install, thenCOPY . .. 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
.dockerignoreexcludingnode_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, acurlof 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
RUNwrites, 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
RUNafter itsARG— see build-time values. - Your
.dockerignorerules still apply. To keep the written.envfiles out, qlane writes aDockerfile.dockerignore(named after your Dockerfile) for the length of each cacheable build, containing your own rules copied exactly plus one line per.envit 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, oradditional_contextspointing 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.envin 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 bothimage:andbuild: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 anARG so compose can override it through build.args:
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
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 underexpose:, 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_periodlong enough to cover it. - The probe may target
localhost, which can resolve to IPv6::1while the app listens on IPv4 only. Probe127.0.0.1. - The probe tool may not exist in the image. Use
wgeton 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 code137 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 iscompose.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 missingpull_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.
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 likemongo: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 anyNODE_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 (herecompose.qlane.yaml). With an overlay, they read the merged result, which is what you need: the configuration screens don’t follow include:.
--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: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 probes127.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-codedlocalhostor 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.
