Run the tests of a repository in a fresh sandbox. In this example, the Durable Object clones the repository, installs its dependencies, runs its test script, returns the result, and stops the instance.
- A Worker with a Durable Object that starts a container with the Durable Object scheduling policy. To create one, refer to Run a Linux command.
You must have Docker running locally when you run wrangler deploy. For most people, the best way to install Docker is to follow the docs for installing Docker Desktop ↗︎. Other tools like Colima ↗︎ may also work.
You can check that Docker is running properly by running the docker info command in your terminal. If Docker is running, the command will succeed. If Docker is not running,
the docker info command will hang or return an error including the message "Cannot connect to the Docker daemon".
-
Create a
Dockerfilein the project root with Git and the runtime your tests need. This one adds Git to Debian Trixie with Node.js 24:Dockerfiledockerfile FROM node:24-trixie-slim RUN apt-get update \ && apt-get install --yes --no-install-recommends ca-certificates git \ && rm -rf /var/lib/apt/lists/* CMD ["sleep", "infinity"]sleep infinitykeeps the container running so that it can accept moreexec()calls. -
In
wrangler.jsonc, build theDockerfileas a named image in yourcontainersentry:{ "containers": [ { "class_name": "MyContainer", "scheduling_policy": "durable_object", "images": { "tests": { "dockerfile": "./Dockerfile", }, }, }, ], }[[containers]] class_name = "MyContainer" scheduling_policy = "durable_object" [containers.images.tests] dockerfile = "./Dockerfile" -
Add a method to your Durable Object that clones a repository, installs its dependencies, and runs its tests:
src/index.tsts export class MyContainer extends DurableObject<Env> { // ... async runTests(repository: string, revision: string) { const container = this.ctx.container; if (!container) { throw new Error("The container binding is not configured"); } container.start({ image: container.images.tests, // `git` and `npm` need the Internet. Do not pass credentials into this sandbox enableInternet: true, }); const decoder = new TextDecoder(); const run = async (step: string, argv: string[], cwd?: string) => { // `timeout` stops the command and every process it started. const process = await container.exec( ["timeout", "--kill-after=5", "600", ...argv], { cwd }, ); const output = await process.output(); return { step, stdout: decoder.decode(output.stdout), stderr: decoder.decode(output.stderr), exitCode: output.exitCode, }; }; try { const clone = await run("clone", [ "git", "clone", "--depth=1", `--branch=${revision}`, "--", repository, "/workspace", ]); if (clone.exitCode !== 0) { return clone; } const install = await run( "install", ["npm", "ci", "--no-audit", "--no-fund"], "/workspace", ); if (install.exitCode !== 0) { return install; } return await run("test", ["npm", "test", "--", "--silent"], "/workspace"); } finally { if (container.running) { await container.destroy(); } } } }--stopsgitfrom reading a repository value that starts with-as an option.A step that runs longer than 10 minutes returns exit code
124.timeoutstops the step together with every process the step started. Thefinallyblock stops the instance after the run.output()holds the whole output of a step in the memory of the Durable Object. For test suites that print a lot, stream the output instead.enableInternet: trueletsgitandnpmdownload the repository and its packages. It also lets the install and test scripts of the repository reach the Internet, so do not pass credentials into this sandbox. For more information, refer to Sandbox security. -
Add a route to your Worker that runs the tests in a new sandbox:
src/index.jsjs export default { async fetch(request, env) { if (request.method !== "POST") { return new Response("Not found", { status: 404 }); } const sandbox = env.MY_CONTAINER.getByName(crypto.randomUUID()); const result = await sandbox.runTests( "https://github.com/cloudflare/workers-oauth-provider.git", "v0.0.6", ); return Response.json(result, { status: result.exitCode === 0 ? 200 : 500, }); }, };src/index.tsts export default { async fetch(request: Request, env: Env): Promise<Response> { if (request.method !== "POST") { return new Response("Not found", { status: 404 }); } const sandbox = env.MY_CONTAINER.getByName(crypto.randomUUID()); const result = await sandbox.runTests( "https://github.com/cloudflare/workers-oauth-provider.git", "v0.0.6", ); return Response.json(result, { status: result.exitCode === 0 ? 200 : 500, }); }, } satisfies ExportedHandler<Env>;A random sandbox name gives each run a new instance, so one run cannot see files from another.
In this example, the Worker runs the tests of version
v0.0.6of the publicworkers-oauth-provider↗︎ repository. Replace the repository, revision, andnpmcommands with your own.exec()does not start a shell, so keep each argument in its own array entry. -
Deploy your Worker:
npx wrangler deployyarn wrangler deploypnpm wrangler deploy -
Send a
POSTrequest to theworkers.devURL that Wrangler prints:curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev --request POSTThe response includes the test output and exit code:
{ "step": "test", "stdout": "...\n Test Files 1 passed (1)\n Tests 37 passed (37)\n...", "stderr": "", "exitCode": 0 }When a step fails, the Worker responds with
500, andstepnames the step that failed.
- Stream command output: show test output while the tests run.
- Save and restore a sandbox with snapshots: keep installed dependencies between runs.
- Handle outbound traffic: allow only the Git server and package registry instead of the whole Internet.
- Clone a private repository: clone with a token that stays in your Worker.