---
title: "Run GitLab CI jobs on exe.dev, a fresh VM per job"
description: "A GitLab Runner that gives each CI/CD job its own exe.dev sandbox, copied from a warm base VM and deleted after. For GitLab.com and self-managed GitLab."
url: https://exe.dev/deploy/gitlab-runner
category: "Developer tools"
updated: 2026-11-04
---

# Run GitLab CI jobs on exe.dev, a fresh VM per job

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

- **Software:** GitLab Runner (https://docs.gitlab.com/runner/)
- **Source:** https://gitlab.com/gitlab-org/gitlab-runner
- **License:** MIT (GitLab Runner)
- **Time:** 20 minutes
- **Machine-readable:** https://exe.dev/deploy/gitlab-runner/manifest.json
- **File:** https://exe.dev/deploy/gitlab-runner/base-setup.sh
- **File:** https://exe.dev/deploy/gitlab-runner/exe-executor
- **File:** https://exe.dev/deploy/gitlab-runner/runner-setup.sh

## What you get

- A base VM with gitlab-runner and git-lfs, ready to be copied
- A GitLab Runner registered with a glrt- token, whose custom executor copies the base for each job and deletes the copy afterwards
- A tag-scoped exe.dev SSH key on the runner VM only; job VMs hold no key
- Jobs with `tags: [exe]` that print `Running on my-runner-job-…, a copy of my-runner-base`

Running on the VM:

- **GitLab Runner**: the gitlab-runner service from GitLab's package repository, with the custom executor exe-executor, data in `/etc/gitlab-runner/config.toml, /etc/gitlab-runner/exe.env`
- **Base VM**: my-runner-base, a regular VM with gitlab-runner, your toolchains, and warm caches, data in `~/.cache, ~/go, ~/.npm`
- **Job sandboxes**: one cp --sandbox copy of the base per job, 2 vCPU / 4 GiB unless the job asks for more, deleted when the job ends

How it is reached:

- ssh my-runner.exe.xyz (private): the runner; it only makes outgoing connections, to GitLab and exe.dev
- ssh my-runner-base.exe.xyz (private): the base VM, to install toolchains and warm caches

GitLab-hosted runners start every job on a blank machine and bill it in
compute minutes. This page moves those jobs to exe.dev without giving up
the blank machine. You prepare a **base VM** once, with your toolchains
and warm caches. A GitLab Runner on a second VM takes jobs from GitLab,
and for each one its
[custom executor](https://docs.gitlab.com/runner/executors/custom/)
copies the base into a new [sandbox](/deploy/sandbox), runs the job's
scripts on it over SSH, and deletes it when the job ends. In our tests a
job's VM was copied and answering in 3 to 4 seconds.

What you change in `.gitlab-ci.yml` is one line, `tags: [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. It works the same with GitLab.com
and with self-managed GitLab. For GitHub, see
[GitHub Actions](/deploy/github-actions), which does the same with a
small dispatcher.

## How it works

- **my-runner-base** is an ordinary VM. It never runs jobs; it's what each
  job's VM is copied from. It has the `gitlab-runner` binary, which jobs
  use for artifacts and caches, but it isn't registered with GitLab.
- **my-runner** runs GitLab Runner as a service, registered with your
  project or group. It asks GitLab for jobs (outgoing HTTPS only: no
  webhook, nothing public), and runs each one through
  [exe-executor](https://exe.dev/deploy/gitlab-runner/exe-executor), a
  short shell script that GitLab Runner calls at each stage of a job:
  1. **config** names the job's VM, `my-runner-job-<job id>-<random>`;
  2. **prepare** runs `ssh exe.dev cp my-runner-base my-runner-job-… --sandbox --cpu=2 --memory=4GB`
     and waits for the copy to answer over SSH;
  3. **run** sends each script GitLab Runner generates (fetch the
     sources, restore caches, your `script:`, upload artifacts) to the
     VM over SSH and runs it there, as `exedev`;
  4. **cleanup** deletes the VM, whether the job passed, failed, or was
     cancelled. A timer on `my-runner` deletes any job VM still around
     after six hours.
- The runner reaches exe.dev and the job VMs with one SSH key, scoped
  to VMs tagged `gitlab-ci`. The base carries that tag, and so does every
  copy of it. The key stays on `my-runner`: the job VMs have no exe.dev
  key, and no GitLab token beyond the job's own `CI_JOB_TOKEN`.

## 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`.
- A GitLab project or group, on GitLab.com or your own GitLab, where you
  can create runners: the Maintainer role for a project runner, Owner for
  a group runner. The steps use a project, `you/app`.
- About 20 minutes.

## Prepare the base VM

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

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

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

VM names are global, so pick any free name, and use it for `BASE_VM`
later. Prepare it with
[base-setup.sh](https://exe.dev/deploy/gitlab-runner/base-setup.sh),
which downloads the latest `gitlab-runner` binary from GitLab, checks its
SHA-256, installs Git LFS, and enables Docker at boot. GitLab Runner's
custom executor needs `git`, `git-lfs`, and `gitlab-runner` on the
machine where jobs run; nothing here registers a runner.

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

It ends with `Version: 19.…`.

</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 jobs
need, the way you would on any Ubuntu 24.04 machine:

```bash
ssh my-runner-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-runner-base.exe.xyz:warm/
ssh my-runner-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 runner VM and its key

On your computer, create the runner's VM, without the tag, and set it up
with [runner-setup.sh](https://exe.dev/deploy/gitlab-runner/runner-setup.sh),
giving it the base's name:

```bash
ssh exe.dev new --name=my-runner
ssh my-runner.exe.xyz 'curl -fsSL https://exe.dev/deploy/gitlab-runner/runner-setup.sh | bash -s my-runner-base'
```

It installs GitLab Runner from
[GitLab's package repository](https://docs.gitlab.com/runner/install/linux-repository/)
as the `gitlab-runner` service, puts `exe-executor` in
`/usr/local/bin`, makes an SSH key in `/etc/gitlab-runner/exe/`, pins
exe.dev's [host key](/docs/faq/host-key), writes the settings to
`/etc/gitlab-runner/exe.env`, and starts the timer that sweeps up
leftover job VMs. It ends by printing the public key.

Add that key to your exe.dev account, scoped to the `gitlab-ci` tag and to
the commands the executor uses:

```bash
ssh my-runner.exe.xyz sudo cat /etc/gitlab-runner/exe/id_ed25519.pub \
  | ssh exe.dev ssh-key add --tag=gitlab-ci --cmds=cp,ls,rm,ssh --exp=1y
```

The key can copy, list, reach, and delete VMs tagged `gitlab-ci`, and
nothing else. Check it from the runner VM:

```bash
ssh my-runner.exe.xyz 'sudo ssh -i /etc/gitlab-runner/exe/id_ed25519 -o UserKnownHostsFile=/etc/gitlab-runner/exe/known_hosts exe.dev ls'
```

It lists `my-runner-base`, and nothing that isn't tagged `gitlab-ci`. To
revoke the key, find it in `ssh exe.dev ssh-key list` and pass its name
or fingerprint to `ssh exe.dev ssh-key remove`.

> [!WARNING]
> Don't tag `my-runner` itself `gitlab-ci`. The key would then reach the
> VM that holds it and the GitLab runner token, and so would
> anything else with that tag.

## Register the runner with GitLab

GitLab now creates runners in its UI and hands you a runner
authentication token, which starts with `glrt-`; the old registration
tokens are deprecated and go away in GitLab 20.0. Tags and the other
settings belong to the runner in GitLab, not to the command that
registers it.

In your project on GitLab, open **Settings → CI/CD**, expand **Runners**,
and select **Create project runner** (for a group: **Build → Runners →
Create group runner**):

- **Tags:** `exe`. Leave **Run untagged jobs** off, so only jobs that
  ask for `exe` come here.
- **Runner description:** `my-runner`, or anything you'll recognise.
- Optional: **Protected**, to run only pipelines for protected branches
  and tags, and **Lock to current projects**.

Select **Create runner**, then **Linux**. GitLab shows the token once,
in a `gitlab-runner register` command. Copy the token, then on the runner
VM (`ssh my-runner.exe.xyz`) register it with the custom executor. This
reads the token without echoing it or saving it in your shell history:

```bash
printf 'Runner token (glrt-…): '; read -rs T; echo
sudo gitlab-runner register --non-interactive \
  --url https://gitlab.com --token "$T" \
  --executor custom --description "$(hostname)" \
  --builds-dir /home/exedev/builds --cache-dir /home/exedev/cache \
  --custom-config-exec /usr/local/bin/exe-executor --custom-config-args config \
  --custom-prepare-exec /usr/local/bin/exe-executor --custom-prepare-args prepare \
  --custom-run-exec /usr/local/bin/exe-executor --custom-run-args run \
  --custom-cleanup-exec /usr/local/bin/exe-executor --custom-cleanup-args cleanup
unset T
```

For self-managed GitLab, put its address in `--url`, such as
`https://gitlab.example.com`. It says `Runner registered successfully`,
and the runner shows as **Online** under **Settings → CI/CD → Runners**
within a few seconds.

Last, let it run several jobs at once. `concurrent` is the most job VMs
at any time; further jobs wait in GitLab's queue. Keep it under your
plan's VM limit (50 on [Personal](/docs/billing/personal)) minus the VMs
you already have:

```bash
sudo sed -i 's/^concurrent = .*/concurrent = 5/' /etc/gitlab-runner/config.toml
```

GitLab Runner reloads its config on its own.

## Move a job

In each job you want on exe.dev, add the tag, or set it for every job
with `default:`:

```yaml
default:
  tags: [exe]

test:
  script:
    - go test ./...
```

Drop `image:` for jobs that move: the base is the image. Push, and the
job's log starts:

```
Using Custom executor with driver exe.dev sandbox 1...
Running on my-runner-job-1234-a3f0, a copy of my-runner-base with --cpu=2 --memory=4GB (3s)
```

A `parallel: 4` job gets four VMs at once, or `concurrent` of them with
the rest started as the first ones finish. Moving one job at a time is
fine: jobs without the tag stay on GitLab's runners, and `needs:` and
artifacts work across the two.

A job that needs more room asks for it with a variable; it gets 2 GiB of
memory per vCPU:

```yaml
build:
  variables:
    EXE_CPU: "8"
  script:
    - make -j"$(nproc)"
```

`EXE_CPU` takes 2, 4, 8, and so on up to `MAX_CPU` in
`/etc/gitlab-runner/exe.env` (8); anything else fails the job before a
VM is made. To change every job's size, set `CP_FLAGS` there instead.
Leave `--cpu` and `--memory` in it: a copy otherwise takes the base's
memory, which may be more than the 2 GiB per vCPU a sandbox is billed
for.

## Caches: the base or an S3 cache

GitLab Runner keeps `cache:` on the machine that ran the job unless you
give it an S3 bucket, and here that machine is deleted after every job.
Two kinds of cache work, 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. To update the base, change it
  and end with `sync`: new job VMs are copies of its disk, and anything
  not yet written to disk isn't in them.
- **`cache:` with an S3 bucket** works as on GitLab's runners: the runner
  hands each job short-lived signed URLs, and the job's VM downloads and
  uploads `cache.zip` directly. Any S3-compatible bucket works; set it in
  the [`[runners.cache]`](https://docs.gitlab.com/runner/configuration/advanced-configuration/#the-runnerscache-section)
  section of `/etc/gitlab-runner/config.toml`.

<details class="dp-details" id="s3-cache-on-the-runner">
<summary>Or keep the cache on the runner VM</summary>

[rclone serve s3](https://rclone.org/commands/rclone_serve_s3/) turns a
directory into a small S3 server. Run it on `my-runner`, in Docker, with
a fresh secret:

```bash
sudo mkdir -p /srv/s3/runner-cache
echo "RCLONE_AUTH_KEY=\"cache,$(openssl rand -hex 24)\"" | sudo tee /etc/gitlab-runner/s3.env >/dev/null
sudo chmod 600 /etc/gitlab-runner/s3.env
sudo docker run -d --name runner-cache --restart unless-stopped \
  --env-file /etc/gitlab-runner/s3.env -p 8000:8080 -v /srv/s3:/data \
  rclone/rclone serve s3 --addr :8080 /data
```

The quotes in `s3.env` are on purpose: they keep rclone from splitting
the key pair at its comma.

Job VMs reach it at `https://my-runner.exe.xyz/`, so make port 8000
public. It answers only requests signed with the secret. On your
computer:

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

Then add the cache to the runner's section of
`/etc/gitlab-runner/config.toml`, replacing the empty
`[runners.cache]` lines that `register` wrote. The secret is the part
after `cache,` in `sudo cat /etc/gitlab-runner/s3.env`:

```toml
  [runners.cache]
    Type = "s3"
    Shared = true
    [runners.cache.s3]
      ServerAddress = "my-runner.exe.xyz"
      AccessKey = "cache"
      SecretKey = "the hex secret from s3.env"
      BucketName = "runner-cache"
      BucketLocation = "us-east-1"
```

The next job with a `cache:` logs `Uploading cache.zip to
https://my-runner.exe.xyz/runner-cache/…` and `Created cache`, and the
one after it `Successfully extracted cache`. The files live in
`/srv/s3/runner-cache`; delete old ones whenever you like.

</details>

## Variables and secrets

CI/CD variables, masked and protected ones included, and the job's
`CI_JOB_TOKEN` reach the job's VM the way they reach any runner, and are
gone with it.

exe.dev can keep some keys off job VMs altogether. An
[integration](/docs/integrations) attached by the `gitlab-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 job calls `https://<name>.int.exe.xyz/` and exe.dev
adds the credential. Remember that any job can use it, including one
from a merge request.

## Merge requests from forks

A merge request from a fork runs its pipeline in the fork, with the
fork's runners: GitLab doesn't enable your project runners there. A
pipeline in your project for a fork's merge request runs only when a
member of your project starts it from the merge request, after reading
the change, as GitLab's
[merge request pipelines](https://docs.gitlab.com/ci/pipelines/merge_request_pipelines/#use-with-forked-projects)
docs describe. Throwaway VMs with no keys limit what such code can do;
what's left is your bill and anything attached by the `gitlab-ci` tag.
Tick **Protected** on the runner if only protected branches and tags
should run here.

## 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 10 seconds longer than its scripts. `my-runner` and
`my-runner-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).

On GitLab.com, [compute minutes](https://docs.gitlab.com/ci/pipelines/compute_minutes/)
are a job's duration times the runner's cost factor, not rounded: 1 for
the default Linux runner (2 vCPU, 8 GB), 2 for medium (4 vCPU, 16 GB),
3 for large (8 vCPU, 32 GB, Premium and Ultimate only). A namespace gets
400 a month on Free, 10,000 on Premium, 50,000 on Ultimate, and
[extra minutes](https://about.gitlab.com/pricing/) are $10 per 1,000,
paid once and valid for 12 months. Your own runners, which this is, use
no compute minutes on any plan, and need no card on file for Free
accounts. Self-managed GitLab has no hosted runners: there, every runner
is one you run.

Some months on GitLab.com, on 30 days:

- **A small team on Free.** 40 jobs a day, 4 minutes each: 4,800
  minutes, 4,400 over the quota. GitLab: $50 in minute packs (the
  leftover 600 carry over). exe.dev, 2 vCPU: 1,200 VMs × 4⅙ minutes ≈
  83 hours ≈ $8.75, or $17.50 on 4 vCPU / 8 GiB.
- **A team on Premium.** 100 jobs a day, 5 minutes each: 15,000
  minutes, 5,000 over the quota. GitLab: $50. exe.dev, 2 vCPU: 3,000
  VMs × 5⅙ minutes ≈ 258 hours ≈ $27.10, or $54.25 on 4 vCPU. Here the
  included minutes win unless the warm base makes the jobs shorter.
- **Big builds on Premium.** 2,000 builds, 12 minutes each, on large
  runners: 72,000 compute minutes, 62,000 over. GitLab: $620. exe.dev,
  8 vCPU / 16 GiB ($0.42 an hour): ≈ 406 hours ≈ $170.

GitLab's runners stay the better deal for jobs that fit in the included
minutes, and for macOS, Windows, arm64, and GPU jobs, which exe.dev
doesn't run. Mind the memory, too: GitLab's default runner has 8 GB, and
a 2 vCPU sandbox 4 GiB, so a job that needs more takes `EXE_CPU: "4"`
here.

## Just one runner

For one small project whose merge requests all come from you, one
persistent runner on one VM may be all you need: no base, no executor
script, no exe.dev key, and nothing to pay beyond your plan. With
GitLab Runner's [Docker executor](https://docs.gitlab.com/runner/executors/docker/),
each job runs in a fresh container from its `image:`, with `services:`
such as a database next to it, and `cache:` kept on the VM between jobs.
The containers share the VM, though, and jobs see what earlier ones left
in the cache and in Docker. Keep it to your own code.

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

In GitLab, create a project runner as in
[Register the runner](#register-the-runner-with-gitlab), with its own
tag (say `exe-docker`) or **Run untagged jobs**. On your computer, create
a VM (pick any free name) and log in:

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

On the VM, install GitLab Runner and register it with the Docker
executor; `alpine:3` is the image for jobs that don't name one:

```bash
sudo systemctl enable --now docker
curl -fsSL https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash
sudo apt-get install -y gitlab-runner
printf 'Runner token (glrt-…): '; read -rs T; echo
sudo gitlab-runner register --non-interactive \
  --url https://gitlab.com --token "$T" \
  --executor docker --docker-image alpine:3 --description "$(hostname)"
unset T
sudo sed -i 's/^concurrent = .*/concurrent = 2/' /etc/gitlab-runner/config.toml
```

The runner shows as **Online** in GitLab, and a job like this one runs
with Node and a Postgres service:

```yaml
test:
  tags: [exe-docker]
  image: node:22-alpine
  services:
    - name: postgres:17-alpine
      alias: db
      variables:
        POSTGRES_PASSWORD: test
  script:
    - npm ci && npm test   # Postgres is at db:5432
```

For jobs that run straight on the VM instead, register with
`--executor shell`: they run as the `gitlab-runner` user, with what's
installed on the VM. Both runners can serve the same project, each with
its own tag, next to the sandbox runner above.

</details>

## Update

- **GitLab Runner on my-runner.** It comes from GitLab's apt repository:
  `sudo apt-get update && sudo apt-get install -y gitlab-runner`. GitLab
  releases a new minor version every month.
- **gitlab-runner on the base.** Jobs use it for artifacts and caches;
  keep it at the runner's version by re-running
  `ssh my-runner-base.exe.xyz 'curl -fsSL https://exe.dev/deploy/gitlab-runner/base-setup.sh | bash'`.
- **The executor.** Re-run `runner-setup.sh` as in
  [Create the runner VM](#create-the-runner-vm-and-its-key). It keeps the
  key and `/etc/gitlab-runner/exe.env`; running jobs are unaffected.
- **The exe.dev key** expires in a year. Make a new one with
  `sudo rm /etc/gitlab-runner/exe/id_ed25519*`, re-run `runner-setup.sh`,
  remove the old key, and add the new one as above.

## Troubleshooting

Start with the job's log in GitLab, then the runner's on `my-runner`:
`journalctl -u gitlab-runner -n 50 --no-pager`.

- **A job is stuck, "waiting for a runner".** Its tags must all be among
  the runner's (`exe`), or the runner must allow untagged jobs. A runner
  marked **Protected** takes only protected branches and tags. If
  `concurrent` job VMs are running, it waits its turn.
- **`cp my-runner-base my-runner-job-… failed`** with exe.dev's reason:
  sandboxes aren't enabled for your team, the VM limit is reached, or the
  base isn't tagged `gitlab-ci`. GitLab retries `prepare` three times,
  then fails the job as a system failure.
- **`Permission denied (publickey)`** in the runner's log. The key isn't
  on your exe.dev account, has expired, or isn't scoped to `gitlab-ci`;
  `ssh exe.dev ssh-key list` shows it.
- **`gitlab-runner: command not found` or `git-lfs` errors** while a job
  fetches its sources or handles artifacts. The base lacks them: re-run
  `base-setup.sh` on it.
- **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.
- **`Failed to extract cache` every time.** Without an S3 cache, that's
  expected: see [Caches](#caches-the-base-or-an-s3-cache). With one, the
  runner's log shows the S3 error; check `ServerAddress` and that
  `my-runner` is public.
- **Leftover job VMs.** `exe-sweep.timer` deletes them after
  `MAX_HOURS` (6). To see them:
  `ssh exe.dev ls --json | jq -r '.vms[].vm_name | select(startswith("my-runner-job-"))'`.

## Frequently asked questions

### How much does it cost compared with GitLab's compute minutes?

On GitLab.com, a job on GitLab's default Linux runner (2 vCPU, 8 GB) uses one compute minute per minute, and extra minutes cost $10 per 1,000: a cent a minute. A 2 vCPU / 4 GiB sandbox here is $0.00175 a minute, and 4 vCPU / 8 GiB, which matches GitLab's memory, $0.0035. Bigger machines differ more: 8 vCPU is $0.007 a minute here and 3 compute minutes (3 cents) on GitLab. The runner and the base are ordinary VMs on your plan. See [What it costs](#what-it-costs), including when GitLab's included minutes are the better deal.

### Does it work with self-managed GitLab?

Yes. The runner only needs to reach your GitLab's URL over HTTPS, and register with a runner authentication token (`glrt-…`) from it, as on GitLab.com. We tested this page against GitLab CE on an exe.dev VM; see [Gitea](/deploy/gitea) or [Forgejo](/deploy/forgejo) for lighter forges with their own runners.

### Why the custom executor, and not the instance or docker-autoscaler executor?

Those two scale out through fleeting plugins, and GitLab maintains plugins only for AWS, Google Cloud, and Azure. The custom executor ships with GitLab Runner and needs four commands: name the VM, copy the base, run a script over SSH, delete the VM. It's in maintenance mode, so GitLab fixes bugs and security issues in it but adds no features; the instance executor would also ignore `image:`.

### Do `image:` and `services:` work?

Not on the sandboxes: a job runs on the VM itself, with whatever the base has, and the log notes that `image:` was ignored. The VM has Docker, so a job can `docker run` what it needs. For jobs that depend on `image:` and `services:`, add the Docker executor runner from [Just one runner](#just-one-runner) under another tag.

### Do caches, artifacts, and CI/CD variables still work?

Artifacts and variables work as on any runner. `cache:` needs a place outside the job's VM, which is deleted; see [Caches](#caches-the-base-or-an-s3-cache) for the warm base and for an S3 cache.

### Can a job get a bigger VM?

Yes. Set the CI/CD variable `EXE_CPU` to `4` or `8` in the job (up to `MAX_CPU`, 8 unless you change it), and its VM gets that many vCPU and 2 GiB of memory per vCPU.

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

No. One persistent runner with the Docker executor on one VM is a five-minute job; see [Just one runner](#just-one-runner). Its jobs run in containers on the same VM, which is fine for your own pushes and not for anyone else's merge requests.


## Related

- [Run GitHub Actions jobs on exe.dev, a fresh VM per job](https://exe.dev/deploy/github-actions.md): Every job on a fresh copy of a warm VM, billed by the second.
- [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.
- [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.
- [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
- [Host your Tangled repos on your own knot](https://exe.dev/deploy/tangled.md): Your git repos on your own VM, with tangled.org on top.
