---
title: "Run GitHub Actions jobs on exe.dev, a fresh VM per job"
description: "Move GitHub Actions off GitHub-hosted runners: each job gets its own exe.dev sandbox, copied from a warm base VM and deleted when the job ends."
url: https://exe.dev/deploy/github-actions
category: "Developer tools"
updated: 2026-11-04
---

# Run GitHub Actions jobs on exe.dev, a fresh VM per job

Every job on a fresh copy of a warm VM, billed by the second.

- **Software:** GitHub Actions (https://docs.github.com/en/actions/concepts/runners/self-hosted-runners)
- **Source:** https://github.com/actions/runner
- **License:** MIT (the runner)
- **Time:** 20 minutes
- **Machine-readable:** https://exe.dev/deploy/github-actions/manifest.json
- **File:** https://exe.dev/deploy/github-actions/base-setup.sh
- **File:** https://exe.dev/deploy/github-actions/ci-dispatcher.py

## What you get

- A base VM with the GitHub Actions runner, ready to be copied
- The ci-dispatcher service, which gives each queued job a fresh sandbox and deletes it afterwards
- The exe.dev API key and the GitHub token held by integrations, on no VM
- A workflow running `runs-on [self-hosted, exe]` jobs, with `started` and `deleted` lines in `journalctl -u ci-dispatcher`

Running on the VM:

- **Dispatcher**: ci-dispatcher.py (Python 3, standard library), the ci-dispatcher systemd service on my-ci, port 8000, data in `~/ci/env`
- **Base VM**: my-ci-base, a regular VM with the runner, your toolchains, and warm caches, data in `~/actions-runner, ~/.cache`
- **Job sandboxes**: one cp --sandbox copy of the base per job, 2 vCPU / 4 GiB, deleted when the job ends

How it is reached:

- https://my-ci.exe.xyz/ (public): GitHub's signed workflow_job webhooks, nothing else
- ssh my-ci-base.exe.xyz (private): the base VM, to install toolchains and warm caches

GitHub-hosted runners start every job on a blank machine: install the
toolchain, restore the cache, download the dependencies, then run your
tests. This page moves those jobs to exe.dev without giving up the blank
machine. You prepare a **base VM** once, with the runner, your toolchains,
and warm caches. For every queued job, a small **dispatcher** copies the
base into a new [sandbox](/deploy/sandbox), registers it with GitHub as a
[just-in-time runner](https://docs.github.com/en/rest/actions/self-hosted-runners#create-configuration-for-a-just-in-time-runner-for-a-repository)
that takes exactly one job, and deletes it when the job is done. Copying
the base took under half a second in our tests, and exe.dev customers
already run CI this way, hundreds of short-lived job VMs a day.

What you change in a workflow is one line, `runs-on: [self-hosted, exe]`.
What you get is a whole VM per job (its own kernel, `sudo`, Docker, real
vCPUs) that starts with everything installed, is billed by the second,
and holds no credentials beyond the job's own.

## How it works

- **my-ci-base** is an ordinary VM. It never runs jobs; it's what each
  job's VM is copied from.
- **my-ci** runs [ci-dispatcher.py](https://exe.dev/deploy/github-actions/ci-dispatcher.py),
  a short Python program. GitHub sends it a webhook whenever a job is
  queued, starts, or ends; it also checks every 30 seconds in case one is
  missed. Each time, it compares GitHub's queue of `exe` jobs with the
  runners it already has, and for each job without one:
  1. `cp my-ci-base my-ci-job-<random> --sandbox --cpu=2 --memory=4GB`;
  2. asks GitHub for a just-in-time runner config for that VM;
  3. starts `run.sh --jitconfig …` on it.

  When a job ends, the runner exits, GitHub forgets it, and the
  dispatcher deletes the VM. Spare runners (the job was cancelled, or
  another runner took it) are deleted after two minutes.
- The dispatcher holds no exe.dev or GitHub key. It reaches both through
  two [integrations](/docs/integrations) attached to it alone, so neither
  key is on any VM, the job VMs included.

GitHub's queue is the source of truth, so a lost webhook only delays a
job, and a forged one can't start a VM nobody asked for.

## Before you start

- An exe.dev account that can create sandboxes. Personal accounts can. On
  a Work team, an admin turns them on with
  `ssh exe.dev team settings standalone all-users`.
- Admin access to the GitHub repository whose CI you're moving. The
  steps use `you/app`; put yours in its place.
- About 20 minutes.

## Prepare the base VM

<details class="dp-details" id="create-the-vm" data-manual-setup>
<summary>Create the base and install the runner</summary>

On your computer, create the base. The `ci` tag matters: the dispatcher's
exe.dev key will only be able to touch VMs with it.

```bash
ssh exe.dev new --name=my-ci-base --tag=ci
```

VM names are global, so pick any free name, and use it for `BASE_VM`
later. Install the runner with
[base-setup.sh](https://exe.dev/deploy/github-actions/base-setup.sh),
which downloads the latest
[actions/runner](https://github.com/actions/runner/releases) release into
`~/actions-runner`, checks its SHA-256, installs its dependencies, and
enables Docker at boot. It doesn't register anything with GitHub; the
dispatcher does that for each copy.

```bash
ssh my-ci-base.exe.xyz 'curl -fsSL https://exe.dev/deploy/github-actions/base-setup.sh | bash'
```

It ends with `runner v2.… installed in ~/actions-runner`.

</details>

The base already has what the default exe.dev image ships: Docker, Go,
Python with `uv`, git, and build tools. Install whatever else your
workflows set up, the way you would on any Ubuntu 24.04 machine:

```bash
ssh my-ci-base.exe.xyz 'sudo apt-get install -y postgresql-client libvips-dev'
```

Then warm the caches by building your project there once. Jobs run as the
same `exedev` user, so they find `~/go/pkg/mod`, `~/.cache/go-build`,
`~/.npm`, `~/.cache/pip`, or `~/.cargo` already full. From a checkout on
your computer:

```bash
rsync -a --exclude=.git ./ my-ci-base.exe.xyz:warm/
ssh my-ci-base.exe.xyz 'cd warm && go test ./... ; cd && rm -rf warm && sync'
```

(`npm ci && npm test`, `uv sync && uv run pytest`, `cargo test`: whatever
your CI runs.) Docker images work the same way: `docker pull` them on the
base, and every job has them.

Don't put secrets on the base. Everything on it is on every job's VM.

## Create the dispatcher VM and its keys

On your computer:

```bash
ssh exe.dev new --name=my-ci
```

**exe.dev.** Make an API key that can copy, list, reach, and delete VMs
tagged `ci`, and nothing else, and hand it to an
[HTTP proxy integration](/docs/integrations-http-proxy) that only `my-ci`
can use:

```bash
ssh exe.dev ssh-key generate-api-key --label=ci-dispatcher --tag=ci \
    --cmds=cp,ls,rm,ssh --exp=1y --json | jq -r .token \
  | ssh exe.dev integrations add http-proxy --name ci-exe \
    --target https://exe.dev --bearer - --attach vm:my-ci
```

The copies the key makes are tagged `ci` too, so it can delete them.
`ssh exe.dev ssh-key remove ci-dispatcher` revokes it.

**GitHub.** The dispatcher needs to register runners and read the job
queue. Create a
[fine-grained personal access token](https://github.com/settings/personal-access-tokens/new)
with access to only the repositories you're moving, and these repository
permissions:

- **Administration: Read and write**, to register just-in-time runners;
- **Actions: Read-only**, to see queued jobs.

For an organization's repositories, choose the organization as the
resource owner; it may have to approve the token. Then put the token in a
second integration. This reads it without echoing it or saving it in your
shell history:

```bash
printf 'GitHub token: '; read -rs T; echo
printf %s "$T" | ssh exe.dev integrations add http-proxy --name ci-github \
  --target https://api.github.com --bearer - --attach vm:my-ci
unset T
```

Check both from the dispatcher VM:

```bash
ssh my-ci.exe.xyz
curl -s -X POST https://ci-exe.int.exe.xyz/exec -d ls | jq -r '.vms[].vm_name'
curl -s https://ci-github.int.exe.xyz/repos/you/app/actions/runners | jq .total_count
```

The first prints `my-ci-base`, and nothing that isn't tagged `ci`. The
second prints a number. `Resource not accessible by personal access
token` means the token lacks a permission; an `invalid token` error from
exe.dev means the first integration got something other than the key.

> [!WARNING]
> Attach these two integrations to `my-ci` only (`--attach vm:my-ci`),
> never by the `ci` tag: every job VM carries that tag, and any workflow
> could then create VMs or register runners.

## Start the dispatcher

On the VM (`ssh my-ci.exe.xyz`), fetch the dispatcher and write its
settings, with a fresh secret for GitHub's webhooks:

```bash
mkdir -p ~/ci
curl -fsSL -o ~/ci/ci-dispatcher.py https://exe.dev/deploy/github-actions/ci-dispatcher.py
(umask 077; cat > ~/ci/env <<EOF
GITHUB_API=https://ci-github.int.exe.xyz
EXE_API=https://ci-exe.int.exe.xyz
REPOS=you/app
BASE_VM=my-ci-base
LABEL=exe
MAX_RUNNERS=5
CP_FLAGS="--cpu=2 --memory=4GB"
WEBHOOK_SECRET=$(openssl rand -hex 32)
EOF
)
```

- `REPOS`: the repositories, `owner/name`, separated by spaces.
- `LABEL`: jobs with `runs-on: [self-hosted, exe]` come here.
- `MAX_RUNNERS`: at most this many job VMs at once; further jobs wait in
  GitHub's queue. Keep it under your plan's VM limit (50 on
  [Personal](/docs/billing/personal)) minus the VMs you already have.
- `CP_FLAGS`: each job VM's size. Leave `--cpu` and `--memory` in: a
  copy otherwise takes the base's memory, which may be more than the
  2 GiB per vCPU a sandbox is billed for.
- Optional: `IDLE_SECONDS` (120) before a spare runner is deleted,
  `MAX_HOURS` (6) before any job VM is, `POLL_SECONDS` (30).

Run it as a service, so it comes back after a crash or a reboot:

```bash
sudo tee /etc/systemd/system/ci-dispatcher.service >/dev/null <<'EOF'
[Unit]
Description=GitHub Actions jobs on exe.dev sandboxes
After=network-online.target
Wants=network-online.target

[Service]
User=exedev
EnvironmentFile=/home/exedev/ci/env
ExecStart=/usr/bin/python3 /home/exedev/ci/ci-dispatcher.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now ci-dispatcher
journalctl -u ci-dispatcher -n 5 --no-pager -o cat
```

The log says `dispatching exe jobs for you/app to copies of my-ci-base`,
with no `reconcile:` errors after it.

## Send it GitHub's webhooks

Without webhooks the dispatcher still finds jobs, up to 30 seconds late.
With them, it hears about a job as soon as it's queued. The
[HTTPS proxy](/docs/proxy) keeps `my-ci` private until you open port 8000
to the internet, which GitHub needs. On your computer:

```bash
ssh exe.dev share set-public my-ci
```

Only requests signed with the webhook secret do anything; everything else
gets `401`. Show the secret on the VM:

```bash
grep WEBHOOK_SECRET ~/ci/env
```

In the repository on GitHub, open **Settings → Webhooks → Add webhook**:

- **Payload URL:** `https://my-ci.exe.xyz/`
- **Content type:** `application/json`
- **Secret:** the value after `WEBHOOK_SECRET=`
- **Which events:** *Let me select individual events*, then untick
  **Pushes** and tick **Workflow jobs**.

After **Add webhook**, its **Recent Deliveries** tab shows a `ping`
answered with `204`.

## Move a workflow

In each job you want on exe.dev, change `runs-on`:

```yaml
jobs:
  test:
    runs-on: [self-hosted, exe]
    steps:
      - uses: actions/checkout@v4
      - run: go test ./...
```

Leave out the `setup-*` step for a toolchain the base already has: it
would download one into the job's VM every time. Keep it where you need
an exact version that the base doesn't have.

Push, and watch the dispatcher on the VM:

```bash
journalctl -u ci-dispatcher -f -o cat
```

```
04:38:37 webhook: test queued
04:38:41 started my-ci-job-c259d116 for you/app in 1.9s
04:38:46 webhook: test in_progress my-ci-job-c259d116
04:38:53 webhook: test completed my-ci-job-c259d116
04:38:54 deleted my-ci-job-c259d116
```

On GitHub, the job's log names `my-ci-job-…` as its runner. A matrix of
six jobs gets six VMs at once, or `MAX_RUNNERS` of them with the rest
started as the first ones finish. To try it all without pushing, add
`workflow_dispatch:` to the workflow's `on:` and click **Run workflow**.

Moving one job at a time is fine: jobs that keep `ubuntu-latest` stay on
GitHub, and `needs:` works across the two.

## Caches: the base or actions/cache

Two kinds of cache work here, and they combine.

- **The base** holds whatever was on its disk when a job's VM was
  copied: toolchains, dependency caches, build caches, Docker images.
  Nothing a job writes comes back to it. It costs no time per job, but
  it's only as fresh as your last warm-up. Re-run the warm-up when
  dependencies change a lot, or weekly.
- **`actions/cache`** works as on GitHub's runners: it restores from and
  saves to GitHub's cache service, which keeps up with every branch. It
  costs a download per job and counts toward the repository's 10 GB.

Start with the base alone, and add `actions/cache` for whatever changes
faster than you want to refresh it. To update the base, change it
(`ssh my-ci-base.exe.xyz`) and end with `sync`: new job VMs are copies of
its disk, and anything not yet written to disk isn't in them. Jobs
already running keep their copy.

## Secrets

GitHub's `secrets.*` and the job's `GITHUB_TOKEN` work unchanged, and
they reach the job's VM the way they reach a GitHub-hosted runner.

exe.dev can keep some keys off job VMs altogether. An integration
attached by the `ci` tag is usable from every job VM, without a key on
it: an [HTTP proxy](/docs/integrations-http-proxy) to a private package
registry or API, or the [LLM gateway](/docs/integrations-llm) for tests
that call a model. A workflow calls `https://<name>.int.exe.xyz/` and
exe.dev adds the credential. Remember that any job can use it, including
the code in any pull request your workflows run.

## Public repositories

GitHub's advice is not to use self-hosted runners for public
repositories, because a pull request from a fork can run anything on
them. Throwaway VMs with no keys answer much of that: nothing survives
the job, and there's no other job's data to read. What's left is your
bill and your integrations. If you use them for a public repository:

- In **Settings → Actions → General**, under approval for fork pull
  request workflows, choose **Require approval for all external
  contributors**.
- Keep fork pull requests on GitHub's runners, which are free for public
  repositories:

  ```yaml
  runs-on: ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || fromJSON('["self-hosted", "exe"]') }}
  ```

- Attach nothing by the `ci` tag, and keep `MAX_RUNNERS` and
  `MAX_HOURS` low enough to cap what a bad week can cost.

## What it costs

exe.dev bills the [sandboxes](/deploy/sandbox) by the second, from the
copy to the delete: $0.105 an hour per 2 vCPU / 4 GiB, so $0.00175 a
minute for the default size, $0.0035 for 4 vCPU, $0.007 for 8. Each job
VM lives about 15 seconds longer than its job. `my-ci` and `my-ci-base`
are ordinary VMs that share your plan's capacity with your other VMs and
cost nothing extra (on Personal, plans start at $15 a month).

GitHub's [hosted runners](https://docs.github.com/en/billing/reference/actions-runner-pricing)
cost $0.006 a minute for 2-core Linux, $0.012 for 4-core, and $0.022 for
8-core, and round each job up to a whole minute. Private repositories get
$12 (Free) or $18 (Pro, Team) of that a month. Self-hosted runners,
which these are, are free on GitHub today; GitHub announced a $0.002 a
minute charge for them in December 2025, then postponed it.

Some months, on 30 days:

- **A team's private repository.** 100 jobs a day, 5 minutes each, on 2
  cores: 15,000 minutes. GitHub: $90, or $72 after Team's $18. exe.dev:
  3,000 VMs × 5¼ minutes ≈ 263 hours × $0.105 ≈ $27.60. If the warm base
  brings the jobs to 3 minutes, ≈ $17.10.
- **Many short jobs.** 500 lint and unit-test jobs a day, 20 seconds
  each. GitHub bills 15,000 minutes: $90. exe.dev: 15,000 VMs × 35 seconds
  ≈ 146 hours ≈ $15.30.
- **Big builds.** 2,000 builds a month, 12 minutes each, on 8 cores.
  GitHub: 24,000 minutes × $0.022 = $528. exe.dev, 8 vCPU / 16 GiB (4
  units, $0.42 an hour): ≈ 408 hours ≈ $171.50.

GitHub's runners stay the better deal for public repositories (free),
for private ones that fit in the included minutes, and for macOS,
Windows, and arm64 jobs, which exe.dev doesn't run. Mind the memory, too:
GitHub's 2-core runner for private repositories has 8 GB, and a 2 vCPU
sandbox 4 GiB, so a job that needs more takes 4 vCPU here.

## Just one runner

For one small repository whose pull requests all come from you, one
always-on runner on one VM may be all you need: no base, no dispatcher,
no integrations, and nothing to pay beyond your plan. It runs job after
job on the same VM, so each job sees what earlier ones left behind, and
any job can read what the runner's user can. Keep it to your own code.

<details class="dp-details" id="one-runner-setup">
<summary>Register one runner and keep it running</summary>

On your computer, create a VM (pick any free name) and log in:

```bash
ssh exe.dev new --name=my-runner
ssh my-runner.exe.xyz
```

On GitHub, open the new self-hosted runner page for the repository or
the organization, with your names in place of `USER/REPO` or `ORG`:

- `https://github.com/USER/REPO/settings/actions/runners/new?arch=x64&os=linux`
- `https://github.com/organizations/ORG/settings/actions/runners/new?arch=x64&os=linux`

On the VM, paste its **Download** and **Configure** commands, up to but
not including `./run.sh`: a service runs it instead, so the runner comes
back after a crash or a reboot.

```bash
cat > /home/exedev/actions-runner/gh-actions-runner.service << 'EOF'
[Unit]
Description=GitHub Actions Runner
After=network.target

[Service]
Type=simple
User=exedev
WorkingDirectory=/home/exedev/actions-runner
ExecStart=/home/exedev/actions-runner/run.sh
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
EOF
sudo cp /home/exedev/actions-runner/gh-actions-runner.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now gh-actions-runner.service
sudo systemctl status gh-actions-runner.service
```

The status says `active (running)`, and its log ends with `Connected to
GitHub` and `Listening for Jobs`. The runner shows as **Idle** under
**Settings → Actions → Runners**, and jobs with `runs-on: self-hosted`
run on it.

</details>

Or [let Shelley do it](https://exe.dev/new?prompt=GITHUB_RUNNER_URL%3D%3Cfill-this-in%3E%0AGITHUB_RUNNER_TOKEN%3D%3Cfill-this-in%3E%0A%0ASet%20up%20one%20self-hosted%20GitHub%20Actions%20runner%20on%20this%20VM%20by%20following%20only%20the%20%22Just%20one%20runner%22%20section%20of%20the%20exe.dev%20page%20at%20https%3A%2F%2Fexe.dev%2Fdeploy%2Fgithub-actions.md.%20Skip%20the%20rest%20of%20that%20page%2C%20and%20the%20step%20that%20creates%20the%20VM%3A%20you%20are%20on%20it.%0A%0AThe%20values%20at%20the%20top%20of%20this%20message%20are%20mine%3A%20the%20--url%20and%20the%20--token%20in%20the%20.%2Fconfig.sh%20command%20on%20GitHub%27s%20new%20self-hosted%20runner%20page%20for%20my%20organization%20or%20repository.%20Ask%20me%20for%20any%20that%20still%20say%20%3Cfill-this-in%3E%3B%20the%20token%20expires%20after%20an%20hour%2C%20and%20never%20echo%20it%20back.%0A%0AGitHub%27s%20instructions%20are%20on%20the%20page%20I%20open%20in%20my%20browser%3B%20reproduce%20them%20here.%20Download%20the%20latest%20Linux%20x64%20runner%20from%20https%3A%2F%2Fgithub.com%2Factions%2Frunner%2Freleases%20into%20~%2Factions-runner%2C%20check%20its%20SHA-256%2C%20and%20run%20.%2Fconfig.sh%20--unattended%20with%20my%20URL%20and%20token%20and%20--name%20%22%24%28hostname%29%22.%20Don%27t%20start%20run.sh%20by%20hand%3A%20the%20gh-actions-runner%20systemd%20service%20runs%20it.%0A%0AYou%20are%20done%20when%20all%20of%20these%20are%20true.%20Check%20each%20one%20yourself%3A%0A-%20A%20self-hosted%20runner%2C%20registered%20with%20my%20GitHub%20organization%20or%20repository%0A-%20The%20gh-actions-runner%20systemd%20service%2C%20enabled%2C%20which%20restarts%20the%20runner%20after%20a%20reboot%0A-%20%22Listening%20for%20Jobs%22%20in%20sudo%20systemctl%20status%20gh-actions-runner.service%0A%0AFinish%20with%20the%20runner%27s%20name%20and%20where%20I%20can%20see%20it%20on%20GitHub.%0A) on a new VM: it asks for the `--url` and
`--token` from GitHub's page (the token expires after an hour) and sets
up the same service.

## Update

- **The runner.** GitHub releases one every few weeks, and a just-in-time
  runner that's out of date updates itself before its job, which adds
  time to every job. Re-run the install on the base when that starts
  showing in job logs (`Runner update in progress`):
  `ssh my-ci-base.exe.xyz 'curl -fsSL https://exe.dev/deploy/github-actions/base-setup.sh | bash'`.
- **The dispatcher.** Fetch `ci-dispatcher.py` again as in
  [Start the dispatcher](#start-the-dispatcher), then
  `sudo systemctl restart ci-dispatcher`. Running jobs are unaffected.
- **The keys.** The exe.dev key above expires in a year and the GitHub
  token when you chose. Make a new one, `ssh exe.dev integrations remove
  ci-exe` (or `ci-github`), and add it again as above.

## Troubleshooting

Start with the dispatcher's log on `my-ci`:
`journalctl -u ci-dispatcher -n 50 --no-pager -o cat`.

- **A job says "Waiting for a runner to pick up this job".** If the log
  shows nothing for it, its labels don't match: they must all be among
  `self-hosted`, `linux`, `x64`, and `exe`. If `MAX_RUNNERS` job VMs are
  running, it waits its turn.
- **`reconcile: HTTPError 403`.** The GitHub token lacks Administration
  (write) or Actions (read) on that repository, or has expired.
- **`reconcile: HTTPError 401`.** The exe.dev key in `ci-exe` is wrong,
  expired, or revoked.
- **`launch … failed: HTTP Error 422`.** exe.dev refused the copy:
  sandboxes aren't enabled for your team, the VM limit is reached, or
  `BASE_VM` isn't tagged `ci`. `ssh exe.dev cp my-ci-base --sandbox`
  on your computer shows the reason; delete that copy afterwards.
- **No `webhook:` lines.** The webhook's Recent Deliveries on GitHub show
  why: `401` is a secret that doesn't match `~/ci/env`; a redirect to a
  login page means `my-ci` isn't public yet. Jobs still start, within
  `POLL_SECONDS`.
- **A job can't find a tool or a cached package.** It wasn't on the base's
  disk when the job's VM was copied. Install it, `sync`, and re-run.
- **Leftover job VMs.** The dispatcher deletes them while it runs. To see
  them yourself:
  `ssh exe.dev ls --json | jq -r '.vms[].vm_name | select(startswith("my-ci-job-"))'`.

## Frequently asked questions

### How much does it cost compared with GitHub-hosted runners?

A 2 vCPU / 4 GiB sandbox is $0.105 an hour, billed by the second: about $0.00175 a minute, against $0.006 a minute for GitHub's 2-core Linux runner, which also rounds every job up to a whole minute. Bigger runners differ by about the same factor: 8 vCPU is $0.007 a minute here and $0.022 on GitHub. The dispatcher and the base are ordinary VMs on your plan. See [What it costs](#what-it-costs) for worked examples, and when GitHub's own runners stay cheaper.

### Is it faster?

Usually, because the base already has your toolchain and caches. In our tests the same Go job, tests plus a Docker container, ran in 7 to 10 seconds on a sandbox and 17 to 22 on `ubuntu-latest`, where `actions/setup-go` alone took 10. Starting is slower: a sandbox job began 8 or 9 seconds after it was queued, a GitHub-hosted one 1 or 2.

### Can I use it for a public repository?

Carefully. Anyone can open a pull request, and a workflow that runs on `pull_request` runs their code on your sandboxes, at your cost. Each job is a throwaway VM with no keys on it, which limits the damage, but require approval for outside contributors and keep fork pull requests on GitHub's runners, as in [Public repositories](#public-repositories). Standard GitHub-hosted runners are free for public repositories anyway.

### Do actions/cache, artifacts, and secrets still work?

Yes. The runner talks to GitHub as any self-hosted runner does, so `actions/cache`, `actions/upload-artifact`, `secrets.*`, and `GITHUB_TOKEN` behave as they do on GitHub's runners. A warm base often makes `actions/cache` unnecessary; see [Caches](#caches-the-base-or-actions-cache).

### Can I have bigger runners, or several sizes?

Set `CP_FLAGS="--cpu=8 --memory=16GB"` in `~/ci/env` and restart the service: every job gets that size. Sandboxes have up to 2 GiB of memory per vCPU. For two sizes, copy the service to a second one with its own env file, another `LABEL` (say `exe-large`, for `runs-on: [self-hosted, exe-large]`), another `JOB_PREFIX` (`my-ci-large-`), `PORT=8001`, and `POLL_SECONDS=10`: the webhook goes to the first, and the second polls.

### Can one dispatcher serve several repositories?

Yes: list them in `REPOS`, separated by spaces, give the GitHub token access to each, and add the webhook to each. Runners are registered per repository. Organization-level runners need the `/orgs` API and an organization webhook, which the script doesn't do.

### What happens when the dispatcher is down?

Jobs wait in GitHub's queue, as they would for any busy runner, and start when it's back: it checks GitHub's queue every `POLL_SECONDS` (30) whether or not a webhook arrived. GitHub cancels a job that waits more than 24 hours.

### Do I need all this for one small repository?

No. One always-on runner on one VM is a five-minute job; see [Just one runner](#just-one-runner). Its jobs share the VM and see what earlier jobs left behind, which is fine for your own pushes and not for anyone else's pull requests.


## Related

- [Sandboxes on exe.dev](https://exe.dev/deploy/sandbox.md): A VM with its own CPU and memory, billed by the second while it runs.
- [Forgejo](https://exe.dev/deploy/forgejo.md): Self-hosted Git forge with exe.dev SSO
- [Gitea](https://exe.dev/deploy/gitea.md): Self-hosted Git forge with exe.dev SSO
- [Use exe.dev as a remote Docker host](https://exe.dev/deploy/docker-host.md): Keep the docker CLI. Send the builds, the containers, and the fan noise to a VM.
- [VS Code in your browser, with code-server](https://exe.dev/deploy/code-server.md): Your editor in a tab. Your projects right where you left them.
- [A Linux desktop in your browser](https://exe.dev/deploy/linux-desktop.md): Xfce, Chrome, and coding agents on a VM you open in a browser tab.
