How Do I Run Each GitHub Actions Job in a Fresh MicroVM?
Updated October 27, 2026 .md
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), 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).
sbx api-key create github-actions --preset ci --ttl 90dAdd 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#
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" --yesWhat each piece is for:
CIis set on GitHub's runners, so the installer skips the interactivesbx onboard.SANDBOXER_INSTALL_DIRplus$GITHUB_PATHputssbxon the path of the later steps (not the step that installs it).shell: bash. Without it, GitHub runs steps withbash -e {0}, which has nopipefail(workflow syntax). In one of our runs the install script download returned a 502, and the step still exited 0 becauseshread an empty pipe.--no-envgives 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 1800stops the sandbox after 30 minutes if the job dies before the delete step. A stopped sandbox isn't billed (billing), but it does use storage until you delete it.sbx execexits with the remote exit code (CLI), so a failing test fails the step. A synchronous run can wait up to 600 seconds. For longer suites, start the command with--detachand follow it with--attach, which also exits with the command's code (long-running jobs).- 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 at ba6d13d. A default sandbox on prod:
### 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.1sThen we added a test that asserts 1 + 1 === 3 and ran npm test again in the same sandbox. The step failed, as it should:
# tests 1374
# pass 1372
# fail 1
### step "Test again (expect failure)" exit=1 time=4.8sEvery 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:
lrwxrwxrwx 1 user user 15 Oct 27 02:22 repo/tests/fixtures/another-dir/pm -> ../other-dir/pmFor 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):
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 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). 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). The job's built-in GITHUB_TOKEN can't do this, so store a separate token as RUNNER_ADMIN_TOKEN.
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 }}" --yesThe 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 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.
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). 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, billing). Use strategy.max-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).
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 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.
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).
Related#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.