Give every CI job a disk that already holds your toolchain, your dependencies and your service images. Build one sandbox, fork it for each job, read the exit code.
Managed runners are next: install the app, write runs-on: munito, and we keep the warm disk for you. This page gives
you the same disk today, through the API, from the CI you already run. To use the runners first, talk to an
engineer.
A CI job spends its first minute installing what the last job already installed. A hosted runner starts on an empty disk, so each job downloads a cache, unpacks it, and uploads it again at the end.
A sandbox keeps its disk. Build one sandbox with your toolchain and your dependencies, pause it, and fork it for each job. The fork starts in about a second with the whole dependency tree in place, and no cache crosses the network.
The examples use GitHub Actions. The same four calls work from GitLab CI, CircleCI, Buildkite, Jenkins or a shell script.
The loop
| Step | How often | What it costs |
|---|---|---|
| Build the warm base | when a lock file changes | one sandbox for the length of the install |
| Pause the base | after each build | saved state, $0.15 per GiB-month |
| Fork the base | each job | nothing: a fork and its parent share their data |
| Run the job | each job | the size of the sandbox, per second |
| Delete the fork | each job | nothing |
1. Build the warm base
Create a persistent sandbox from the ubuntu template, which has
Docker. Give it the disk your dependencies need.
export MUNITO_API_KEY="munitoai_..."
BASE=$(curl -sS --fail-with-body https://api.munito.ai/sandboxes/v1/sandboxes \
-H "Authorization: Bearer $MUNITO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"template": "ubuntu", "size": "large", "disk_gib": 40,
"internet": true, "persistent": true, "timeout": 3600,
"metadata": {"role": "ci-base"}}' | jq -r .id)Install everything a job needs, and nothing a job changes:
curl -sS --fail-with-body "https://api.munito.ai/sandboxes/v1/sandboxes/$BASE/exec" \
-H "Authorization: Bearer $MUNITO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"timeout": 1800, "command": "git clone --depth 50 https://x-access-token:$TOKEN@github.com/acme/app repo && cd repo && npm ci && npx playwright install --with-deps chromium"}'Three things belong in the base:
- The toolchain and the dependencies.
node_modules, the virtual environment, the Go module cache, the browsers a test driver downloads. - The service images.
docker pulleach image your tests start. A pull in the base is a pull that no job makes again. - A checkout. Each job then fetches only its own commits.
Pause the base when the install ends:
curl -sS --fail-with-body -X POST \
"https://api.munito.ai/sandboxes/v1/sandboxes/$BASE/pause" \
-H "Authorization: Bearer $MUNITO_API_KEY"A paused sandbox pays no machine time. Keep its id in a CI variable, next to your key.
2. Fork the base for each job
SANDBOX=$(curl -sS --fail-with-body \
"https://api.munito.ai/sandboxes/v1/sandboxes/$BASE/fork" \
-H "Authorization: Bearer $MUNITO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"count": 1, "persistent": false, "timeout": 1800}' | jq -r '.data[0].id')- The fork starts running, with the files and the processes of the base.
"persistent": falsemakes it a throwaway: it keeps no saved state and it stops at itstimeout.- The parent stays paused. One base serves every job of every branch at the same time.
- A fork starts 16 sandboxes or less. Call
forkagain for more.
Measured on 6 November 2026, from a base of 4 vCPU and 8 GiB that held 4.1 GiB of files, over the public API from a laptop: the pause took 4.1 s, one fork 0.65 s, and eight forks in one call 0.77 s. Each fork read the same files, and its page cache was the page cache of the base.
3. Check out the commit and run the job
The base holds a checkout, so each job moves it to its own commit. cwd picks the folder, and
env carries the credential, so no secret reaches a command line:
curl -sS --fail-with-body "https://api.munito.ai/sandboxes/v1/sandboxes/$SANDBOX/exec" \
-H "Authorization: Bearer $MUNITO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"cwd": "/workspace/repo", "timeout": 300,
"env": {"JOB_TOKEN": "'"$JOB_TOKEN"'"},
"command": "git fetch --depth 50 origin '"$SHA"' && git checkout --force '"$SHA"' && npm ci --prefer-offline"}'Then run the job and read its exit code:
curl -sS --fail-with-body "https://api.munito.ai/sandboxes/v1/sandboxes/$SANDBOX/exec" \
-H "Authorization: Bearer $MUNITO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"cwd": "/workspace/repo", "timeout": 1800, "stream": true,
"command": "npm test"}'- A command runs for 1,800 seconds or less. For longer work, start a background command and poll it.
"stream": truesends NDJSON events while the command writes, so the CI log fills as the job runs.- A command keeps 1 MiB of each stream. Write a longer log to a file and read the file.
4. Delete the fork
curl -sS --fail-with-body -X DELETE \
"https://api.munito.ai/sandboxes/v1/sandboxes/$SANDBOX" \
-H "Authorization: Bearer $MUNITO_API_KEY"Delete it even when the job fails. The timeout of the fork is the safety net: a runner that dies
leaves a sandbox that stops by itself.
The whole workflow
name: tests
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
env:
MUNITO_API_KEY: ${{ secrets.MUNITO_API_KEY }}
BASE: ${{ vars.MUNITO_CI_BASE }}
steps:
- name: Fork the warm base
id: fork
run: |
id=$(curl -sS --fail-with-body \
"https://api.munito.ai/sandboxes/v1/sandboxes/$BASE/fork" \
-H "Authorization: Bearer $MUNITO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"count": 1, "persistent": false, "timeout": 1800}' | jq -r '.data[0].id')
echo "id=$id" >> "$GITHUB_OUTPUT"
- name: Take the commit
env:
JOB_TOKEN: ${{ github.token }}
run: |
helper='!f() { echo username=x-access-token; echo "password=$JOB_TOKEN"; }; f'
curl -sS --fail-with-body \
"https://api.munito.ai/sandboxes/v1/sandboxes/${{ steps.fork.outputs.id }}/exec" \
-H "Authorization: Bearer $MUNITO_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -nc --arg sha "$GITHUB_SHA" --arg token "$JOB_TOKEN" --arg helper "$helper" '{
cwd: "/workspace/repo",
timeout: 300,
env: {JOB_TOKEN: $token},
command: ("git -c credential.helper=" + ($helper | @sh)
+ " fetch --depth 50 origin " + $sha
+ " && git checkout --force " + $sha)}')"
- name: Run the tests
run: |
code=$(curl -sS --fail-with-body \
"https://api.munito.ai/sandboxes/v1/sandboxes/${{ steps.fork.outputs.id }}/exec" \
-H "Authorization: Bearer $MUNITO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"cwd": "/workspace/repo", "timeout": 1800, "command": "npm ci --prefer-offline && npm test"}' \
| tee /dev/stderr | jq -r .exit_code)
exit "$code"
- name: Delete the fork
if: always() && steps.fork.outputs.id != ''
run: |
curl -sS -X DELETE \
"https://api.munito.ai/sandboxes/v1/sandboxes/${{ steps.fork.outputs.id }}" \
-H "Authorization: Bearer $MUNITO_API_KEY"The GITHUB_TOKEN of the job reads the one repository and expires with the job, so it is the right
credential to give a fork. Never give a fork a personal access token.
As a self-hosted runner
The workflow above holds a hosted runner while the sandbox works, so you pay both. To pay for the sandbox alone, make the fork the runner itself. This is the shape our managed runners take, and you can build it today.
Install the runner agent in the base, and start it in the fork with a registration token:
curl -sS --fail-with-body "https://api.munito.ai/sandboxes/v1/sandboxes/$SANDBOX/commands" \
-H "Authorization: Bearer $MUNITO_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -nc --arg token "$RUNNER_TOKEN" '{timeout: 3600, command:
("cd /opt/runner && RUNNER_ALLOW_RUNASROOT=1 ./config.sh --unattended --ephemeral --url https://github.com/acme/app --token \($token) --labels munito-sandbox && ./run.sh")}')"Then send the job to it:
jobs:
test:
runs-on: [self-hosted, munito-sandbox]
steps:
- uses: actions/checkout@v5
with:
clean: false # keep the warm node_modules and the virtual environment
- run: npm ci --prefer-offline && npm test--ephemeralmakes the runner take one job and exit. GitHub then removes it.clean: falsematters: the defaultactions/checkoutrunsgit clean -ffdx, which deletes the dependency tree the warm disk exists for. Keep the work folder of the runner inside the checkout the base holds, or point the tool caches at a folder outside it.- A scheduler starts one runner for each queued job. Supply it with a small service that reads the GitHub queue and forks the base, or with actions-runner-controller, which solves the GitHub half already. Our managed runners remove this step.
- GitHub bills no minutes for a self-hosted runner.
Docker builds keep their layers
A fork carries the Docker state of the base, so a docker build in a job reuses the layers of the
last build in the base. There is no cache to import, and no cache to export.
Build in the base, then pause it, and each job starts from those layers:
curl -sS --fail-with-body "https://api.munito.ai/sandboxes/v1/sandboxes/$BASE/exec" \
-H "Authorization: Bearer $MUNITO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"cwd": "/workspace/repo", "timeout": 1800, "command":
"docker buildx create --name warm --use --bootstrap && docker buildx build --builder warm --load -t app:base ."}'Keep the builder in the base. A docker buildx builder holds its state in a container, which the
pause saves and the fork copies. A plain docker pull of an image that nothing runs is also kept,
because the fork copies the whole disk.
Keep the base current
Rebuild the base when a lock file changes, not on every commit. One scheduled job is enough:
- Resume the base, or create a new one from the template.
- Fetch
main, install, and pull the images again. - Pause it.
- Write its id into the CI variable, and delete the base it replaces.
A job whose commit needs a dependency the base does not hold still works: its npm ci or uv sync
installs the difference. A stale base costs seconds, not a failure.
What it costs
A sandbox pays for its size for each second that it runs, and a paused base pays only for its saved state.
A job of 2 minutes on large (4 vCPU, 8 GiB) | $0.0012 |
| 1,000 such jobs a month | $1.20 |
| A paused base of 20 GiB | $3.00 a month |
A hosted runner charges by the minute and rounds each job up to the next whole minute. A sandbox charges by the second, so a job of 65 seconds pays for 65 seconds.
Traps
- A hosted runner image carries hundreds of packages, and a template carries what it names. Your
build uses some of those packages without saying so. Run the job once in the base, read what it
reports missing, and install that. A suite that reads file types, for example, needs
libmagic1. - Pull every image, not the ones you remember. An image that no
docker pullnamed costs one registry round trip in every job. Run the suite once in the base, then readdocker images. - One base, one shape. A fork takes the vCPUs and the memory of its parent. Keep one base for each size your jobs need.
- What the base holds, every fork holds. Put job secrets in the CI system and pass them in the command, never in the base.
- A sandbox has no outbound internet unless you set
"internet": true. A fork keeps the setting of its parent. - Delete the fork. A fork that nothing deletes runs until its
timeout. Set atimeoutthat fits the job, and delete in a step that always runs. - Count your sandboxes. Your plan sets how many run at once. A wide test matrix needs a plan that holds it.
Pause and fork
Keep a sandbox longer, pause it with its files and processes, resume it, and fork it into copies.
Pricing and limits
The sandbox plans, the price of each sandbox size, of the desktop and of saved state, the trial, the credits, the limits, and the price of a weekend of agents at Munito AI and at other providers.