Run your CI jobs on a warm sandbox

Preview

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

StepHow oftenWhat it costs
Build the warm basewhen a lock file changesone sandbox for the length of the install
Pause the baseafter each buildsaved state, $0.15 per GiB-month
Fork the baseeach jobnothing: a fork and its parent share their data
Run the jobeach jobthe size of the sandbox, per second
Delete the forkeach jobnothing

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 pull each 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": false makes it a throwaway: it keeps no saved state and it stops at its timeout.
  • The parent stays paused. One base serves every job of every branch at the same time.
  • A fork starts 16 sandboxes or less. Call fork again 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": true sends 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
  • --ephemeral makes the runner take one job and exit. GitHub then removes it.
  • clean: false matters: the default actions/checkout runs git 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:

  1. Resume the base, or create a new one from the template.
  2. Fetch main, install, and pull the images again.
  3. Pause it.
  4. 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 pull named costs one registry round trip in every job. Run the suite once in the base, then read docker 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 a timeout that 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.