# How Do I Run Each GitHub Actions Job in a Fresh MicroVM?

> Either keep GitHub's hosted runner and have the job call sbx to create a sandbox, copy the checkout in, run the tests there and delete it, or start a sandbox per job that registers an --ephemeral self-hosted runner, takes exactly one job and is then deleted.

Source: https://sandboxer.com/guides/run-each-github-actions-job-in-a-fresh-microvm. Updated 2026-10-27.

**Short answer:** Either keep GitHub's hosted runner and have the job call sbx to create a sandbox, copy the checkout in, run the tests there and delete it, or start a sandbox per job that registers an --ephemeral self-hosted runner, takes exactly one job and is then deleted.

## At a glance

| | A: hosted runner drives `sbx` | B: ephemeral runner inside a sandbox |
| - | - | - |
| Where the job's steps run | GitHub's runner; only the commands you pass to `sbx exec` run in the microVM | Every step, including `actions/checkout`, runs in the microVM |
| GitHub secrets you add | `SANDBOXER_API_KEY` | `SANDBOXER_API_KEY` plus a GitHub token with admin access to the repo |
| Moving parts | One job, five steps | Three jobs: start runner, run job, delete sandbox |
| Good for | Tests that need Docker, `/dev/kvm`, root, or a bigger machine | Whole workflows and marketplace actions you want off GitHub's machines |
| What we measured (single runs, 2026-10-27) | Install to tests passed: 26.6 s (the install reused a cached binary) | Runner 2.338.2 downloaded, verified and checked in 8.1 s (not registered) |

Both give each job a new Ubuntu 24.04 microVM with its own kernel, Docker and `/dev/kvm` ([machines](/docs/machines)), and both throw it away after the job.

## First: a key for CI

Create a key with the `ci` preset and store it as a repository secret. The preset can create sandboxes and delete only the ones it created ([API keys](/docs/api-keys#presets)).

```bash
sbx api-key create github-actions --preset ci --ttl 90d
```

Add the secret it prints as `SANDBOXER_API_KEY` under **Settings → Secrets and variables → Actions**. The CLI reads that variable, so the workflow never runs `sbx login`.

## Pattern A: the hosted runner calls sbx

```yaml
name: test-in-a-microvm
on: [push, pull_request]

defaults:
  run:
    shell: bash   # adds -o pipefail, so a failed download fails the step

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    env:
      SANDBOXER_API_KEY: ${{ secrets.SANDBOXER_API_KEY }}
    steps:
      - uses: actions/checkout@v4

      - name: Install sbx
        run: |
          curl -fsSL https://sandboxer.com/install | SANDBOXER_INSTALL_DIR="$HOME/.local/bin" sh
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"

      - name: Create a sandbox
        run: |
          id="$(sbx new --name "gha-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" --type default --ttl 1800 --no-env --json | jq -r 'select(.event=="ready") | .id')"
          echo "SBX_ID=$id" >> "$GITHUB_ENV"

      - name: Copy the checkout in
        run: tar -czf - . | sbx ssh "$SBX_ID" -- "mkdir -p repo && tar -xzf - -C repo"

      - name: Test
        run: sbx exec "$SBX_ID" --cwd repo --timeout 600 "npm ci && npm test"

      - name: Delete the sandbox
        if: always() && env.SBX_ID != ''
        run: sbx delete "$SBX_ID" --yes
```

What each piece is for:

- **`CI` is set on GitHub's runners**, so the installer skips the interactive `sbx onboard`. `SANDBOXER_INSTALL_DIR` plus `$GITHUB_PATH` puts `sbx` on the path of the later steps (not the step that installs it).
- **`shell: bash`.** Without it, GitHub runs steps with `bash -e {0}`, which has no `pipefail` ([workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#defaultsrunshell)). In one of our runs the install script download returned a 502, and the step still exited 0 because `sh` read an empty pipe.
- **`--no-env`** gives the sandbox none of your account's secrets, which matters when it runs pull-request code. Pass what the tests need with `-e KEY=value`.
- **`--ttl 1800`** stops the sandbox after 30 minutes if the job dies before the delete step. A stopped sandbox isn't billed ([billing](/docs/billing)), but it does use storage until you delete it.
- **`sbx exec` exits with the remote exit code** ([CLI](/docs/cli#sbx-exec)), so a failing test fails the step. A synchronous run can wait up to 600 seconds. For longer suites, start the command with `--detach` and follow it with `--attach`, which also exits with the command's code ([long-running jobs](/docs/long-running)).
- **The delete step runs on failure too** because of `always()`, and skips if the sandbox was never created.

### What we ran

We ran the five `run:` steps above in order, each in its own `bash -e` with `CI=true` and the step's environment files, from a checkout of [commander.js](https://github.com/tj/commander.js) at `ba6d13d`. A `default` sandbox on prod:

```text
### step "Install sbx" exit=0 time=0.1s
created sb_dnrpatdkee
### step "Create a sandbox" exit=0 time=7.3s
### step "Copy the checkout in" exit=0 time=2.2s

added 204 packages, and audited 205 packages in 3s
…
> commander@15.0.0 test
> node --test && npm run check:type:ts
…
# tests 1373
# suites 210
# pass 1372
# fail 0
# skipped 1
…
### step "Test" exit=0 time=16.9s
### job time through Test: 26.560575406s ; test rc=0
…
### step "Delete the sandbox" exit=0 time=2.1s
```

Then we added a test that asserts `1 + 1 === 3` and ran `npm test` again in the same sandbox. The step failed, as it should:

```text
# tests 1374
# pass 1372
# fail 1
### step "Test again (expect failure)" exit=1 time=4.8s
```

Every timing on this page is a single run on 2026-10-27. In this run the install step took 0.1 s because the download returned a 502 and the `sbx` from our previous run was still in the install directory; the run before it installed in 5.0 s. That 502 is why the workflow sets `shell: bash` (see below).

At $0.036 an hour for `default`, billed per second, a job like this costs a fraction of a cent.

### Why tar and not `sbx scp -r`

Our first attempt copied the checkout with `sbx scp -r . "$SBX_ID:/home/user/repo"`. Two problems showed up:

| | `sbx scp -r` | `tar` over `sbx ssh` |
| - | - | - |
| Time for this repo (246 files, 1.9 MB) | 134.5 s | 2.2 s |
| Symlinks | Followed: copied as regular files | Kept as links |
| commander.js tests | `fail 1` (a symlinked fixture broke) | `fail 0` |

`scp` copies the target of a symlink, so `tests/fixtures/another-dir/pm` arrived as a file and its relative import failed. With tar it stays a link:

```text
lrwxrwxrwx 1 user user 15 Oct 27 02:22 repo/tests/fixtures/another-dir/pm -> ../other-dir/pm
```

For a public repository, you can skip the copy and clone inside the sandbox. With commander.js at `ba6d13d` in place of the two variables, this took 15.6 s including `npm ci` and the full test run, with `fail 0` (we also passed `--no-audit --no-fund` to `npm ci`):

```bash
sbx exec "$SBX_ID" --timeout 600 "git clone -q https://github.com/$GITHUB_REPOSITORY repo && cd repo && git checkout -q $GITHUB_SHA && npm ci && npm test"
```

For private repositories, use tar: the checkout is already on the runner, so the sandbox needs no GitHub credentials. See [moving files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox) for the other options.

## Pattern B: an ephemeral self-hosted runner in a sandbox

GitHub's self-hosted runner takes an `--ephemeral` flag at registration. GitHub then de-registers the runner after it has processed one job, and recommends ephemeral runners for autoscaling ([GitHub docs](https://docs.github.com/en/actions/reference/runners/self-hosted-runners#ephemeral-runners-for-autoscaling)). One sandbox per job, deleted afterwards, fits that model.

Registering needs a registration token from `POST /repos/{owner}/{repo}/actions/runners/registration-token`. The caller needs admin access to the repository, a classic token needs the `repo` scope, and the token expires after one hour ([REST docs](https://docs.github.com/en/rest/actions/self-hosted-runners#create-a-registration-token-for-a-repository)). The job's built-in `GITHUB_TOKEN` can't do this, so store a separate token as `RUNNER_ADMIN_TOKEN`.

```yaml
name: test-on-a-sandbox-runner
on: [push]

defaults:
  run:
    shell: bash

jobs:
  start-runner:
    runs-on: ubuntu-latest
    outputs:
      sandbox: ${{ steps.start.outputs.sandbox }}
    env:
      SANDBOXER_API_KEY: ${{ secrets.SANDBOXER_API_KEY }}
      GH_TOKEN: ${{ secrets.RUNNER_ADMIN_TOKEN }}
    steps:
      - run: |
          curl -fsSL https://sandboxer.com/install | SANDBOXER_INSTALL_DIR="$HOME/.local/bin" sh
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"
      - id: start
        run: |
          token="$(gh api -X POST "repos/$GITHUB_REPOSITORY/actions/runners/registration-token" --jq .token)"
          id="$(sbx new --name "runner-$GITHUB_RUN_ID" --ttl 3600 --no-env --json | jq -r 'select(.event=="ready") | .id')"
          echo "sandbox=$id" >> "$GITHUB_OUTPUT"
          sbx ssh "$id" -- bash -s <<EOF
          set -e
          mkdir -p ~/actions-runner && cd ~/actions-runner
          curl -fsSL -o runner.tar.gz https://github.com/actions/runner/releases/download/v2.338.2/actions-runner-linux-x64-2.338.2.tar.gz
          echo "baebf204307bac6193480852732f0aba2ae97b6c5597c244f4d5558390147c40  runner.tar.gz" | sha256sum -c
          tar xzf runner.tar.gz
          ./config.sh --unattended --ephemeral --disableupdate \
            --url "https://github.com/$GITHUB_REPOSITORY" --token "$token" \
            --name "sbx-$GITHUB_RUN_ID" --labels "sbx-$GITHUB_RUN_ID"
          EOF
          sbx exec "$id" --detach "cd ~/actions-runner && ./run.sh"

  test:
    needs: start-runner
    runs-on: [self-hosted, "sbx-${{ github.run_id }}"]
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  delete-runner:
    needs: [start-runner, test]
    if: always() && needs.start-runner.outputs.sandbox != ''
    runs-on: ubuntu-latest
    env:
      SANDBOXER_API_KEY: ${{ secrets.SANDBOXER_API_KEY }}
    steps:
      - run: |
          curl -fsSL https://sandboxer.com/install | SANDBOXER_INSTALL_DIR="$HOME/.local/bin" sh
          "$HOME/.local/bin/sbx" delete "${{ needs.start-runner.outputs.sandbox }}" --yes
```

The label `sbx-<run id>` makes sure the `test` job lands on the runner this run started, not on another run's. The runner runs as the sandbox's normal `user` with passwordless `sudo`. `--detach` keeps `run.sh` running after the CLI returns. As an alternative to registration tokens, GitHub can create [just-in-time runners](https://docs.github.com/en/rest/actions/self-hosted-runners#create-configuration-for-a-just-in-time-runner-for-a-repository) through the REST API.

### What we ran

We can't run GitHub Actions from our test setup, so we checked the sandbox side: download the runner, verify its checksum, and confirm it starts on the sandbox image. We didn't register it with a repository.

```text
runner.tar.gz: OK
--- run.sh --version
2.338.2
Runner listener exit with 0 return code, stop the service, no retry needed.
Exiting runner...
--- config.sh --version
2.338.2
--- config.sh --help
…
Config Options:
 --unattended           Disable interactive prompts for missing arguments. Defaults will be used for missing options
 --url string           Repository to add the runner to. Required if unattended
 --token string         Registration token. Required if unattended
 --name string          Name of the runner to configure (default rugged-plucky-easel)
 --labels string        Custom labels that will be added to the runner. This option is mandatory if --no-default-labels is used.
 …
 --disableupdate        Disable self-hosted runner automatic update to the latest released version`
 --ephemeral            Configure the runner to only take one job and then let the service un-configure the runner after the job finishes (default false)
```

The runner's .NET listener started without installing any extra packages. Download, checksum and the three checks took 8.1 s. We did not run the registration-token call, `config.sh` against a repository, the detached `run.sh`, or a job on the runner.

### Public repositories

GitHub says self-hosted runners "should almost never be used for public repositories", because anyone can open a pull request against them ([GitHub security guide](https://docs.github.com/en/actions/reference/security/secure-use#hardening-for-self-hosted-runners)). A fresh microVM per job keeps one job from affecting the next. It doesn't protect what that job can reach: the runner's token, your network and any secrets you pass in. For untrusted pull requests, prefer pattern A: the steps run on GitHub's runner, only the commands you pass reach the microVM, and `--no-env` keeps your account's secrets out of it.

## Matrix builds and limits

Each matrix leg that creates a sandbox uses one start and one active slot. On the trial, two sandboxes can be active at once. The $20 plan allows 100 at once and 12 starts per minute ([limits](/docs/limits), [billing](/docs/billing#plans)). Use [`strategy.max-parallel`](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstrategymax-parallel) to stay under them. Over the limit, `sbx new` fails with `429 rate_limited` or `429 limit_reached`. Check your headroom with `sbx limits --json`.

To skip slow setup in every job, prepare one sandbox, save it as a named snapshot, and start jobs with `sbx new --from <name>` ([templates](/guides/turn-a-sandbox-into-a-reusable-template)).

## FAQ

### How do I add a runner to GitHub Actions?

Download the runner from GitHub, run `./config.sh --url <repo url> --token <registration token>`, then `./run.sh`. Add `--ephemeral` so it takes one job and is removed afterwards. GitHub's [add runners](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/add-runners) page shows where to get the token in the repository settings.

### Does a failing test fail the GitHub step?

Yes. `sbx exec` exits with the remote command's exit code. In our run, one failing test gave `exit=1`, which fails the step and the job.

### What happens to the sandbox if the job is cancelled?

The delete step has `if: always()`, so it runs on cancellation too. If the runner itself dies, `--ttl` stops the sandbox at the deadline. Stopped sandboxes aren't billed, but delete them so they don't use storage.

### Can the tests use Docker or KVM?

Yes. Every sandbox has Docker with Compose and `/dev/kvm`, so `docker compose up` and nested VMs work inside the job. See [Docker and KVM inside a sandbox](/guides/docker-and-kvm-inside-an-agent-sandbox).

### Which size should I pick?

We ran these tests on `default` (4 vCPU, 8 GB). `small` (2 vCPU, 4 GB) uses machine time at half the rate. `large` and `xlarge` suit heavy builds, and `xlarge` needs the $100 plan or higher ([machines](/docs/machines#sizes)).

## Related

- [API keys](/docs/api-keys)
- [CLI reference](/docs/cli)
- [Limits and errors](/docs/limits)
- [Get a preview URL for every pull request](/guides/get-a-preview-url-for-every-pull-request)
