solutions · ci

Ephemeral CI runners:
a fresh microVM for every job.

An ephemeral CI runner runs exactly one job on a fresh machine, and then the machine is destroyed. On PandaStack that machine is a Firecracker microVM with its own Linux kernel, created from a template snapshot in 49ms p50 and deleted when the job ends. There is no warm runner pool, no state carried over from the last job, and nothing left behind for the next one.

49ms
p50 create
0
idle runners billed
1 kernel
per job
sandbox.create() — snapshot restore

What is an ephemeral CI runner?

An ephemeral CI runner is a machine that accepts exactly one job, starts from a known image, and is destroyed when the job ends. The next job never sees what the last one left behind, because the machine it ran on no longer exists.

Single-use: ephemeral

One job, then the machine is deleted. Nothing depends on a cleanup script running correctly after untrusted code, because there is no “after” on that machine.

Reset between jobs: not ephemeral

A long-lived machine runs a teardown routine between jobs. The routine is only as good as the script, and the script runs on a box the previous job just controlled. git clean does not remove a crontab.

Container per job: part of it

A fresh filesystem and process tree for every job. With a standard container runtime, the kernel is still shared with every other job on the node, including the ones running right now.

PandaStack runners are single-use by construction. Each job is a new Firecracker microVM restored from the same template snapshot, with its own guest kernel behind hardware virtualization (KVM), its own network namespace, and a copy-on-write clone of the template's disk. When the job ends you delete the VM, and its writes go with it.

How do ephemeral and persistent CI runners differ?

CI executes whatever the pull request's author wrote. On a persistent runner, that code runs next to every credential and cache the machine has collected since it was set up. On an ephemeral microVM, a test that wrecks its environment wrecks a VM that was about to be deleted anyway.

Ephemeral microVM runners compared with container-per-job and persistent self-hosted runners
PandaStack ephemeral microVMContainer per jobPersistent self-hosted runner
Lifetimeone job, then deletedone job, then the container is removedmany jobs, until someone rebuilds it
Kernelits own guest kernel per jobthe node's kernel, shared with other podsthe host's kernel, shared with every job before and after
Isolation boundaryhardware virtualization (KVM)namespaces, cgroups and seccompnone between jobs; they run in turn as the same user
State between jobsnone: every job restores the same snapshotnone in the container; the node persistsleftover caches, daemons, dotfiles, git hooks
Start of a job49ms p50 create from a snapshot; no pool to pre-warmimage pull if the node lacks it, then container startnothing to start: the machine is always up, and always billing
Build cachebaked into the template; each job writes to its own copy-on-write clonevolumes or a remote cache you wire upwarm on local disk, and writable by every job
Cloud metadata endpoint169.254.0.0/16 dropped on the host, outside the guestreachable unless a network policy blocks itreachable unless you block it
After a failurecopy logs out, SSH in before delete, or snapshot the VMpod logs, until the pod is goneSSH into a box that has drifted since
Cost between jobsnothing: no runner existsthe cluster's nodesthe whole machine, around the clock
Cleanupdelete at job end; idle-TTL reaper as backstopthe orchestrator removes the podhygiene scripts and hope

How do you run CI jobs in ephemeral microVMs?

Every job restores the same baked template snapshot, so the runner a flaky test saw yesterday is bit-for-bit the runner it sees today. There is no drop-in runs-on: pandastack: you drive runners from the pandastack CLI or the Python and TypeScript SDKs, in one of two shapes depending on whether the code is trusted.

Shape 1: a run step, for trusted branches

Your pipeline's own step creates a microVM, copies the checkout in, starts the suite in the background, polls until it finishes, copies the log out, and deletes the VM on success, failure or cancel. The suite is started detached because each CLI call times out after 30 seconds; a long test run can't sit inside a single exec.

The API key sits in CI secrets like any other credential, which is why this shape is for trusted branches and agent-written code. GitHub passes no repository secrets to workflows triggered from forks, so a fork's run would have no key to use.

create --ttl 1800exec detached + polldelete
bash: your CI provider's run step
# Trusted branches only: PANDASTACK_API_KEY comes from CI secrets,
# and GitHub passes no secrets to workflows triggered from forks.
# CLI: npm install -g @pandastack/sdk
set -euo pipefail

sid=$(pandastack sandbox create --template base --ttl 1800 | jq -r '.id')
trap 'pandastack sandbox delete "$sid"' EXIT    # success, failure or cancel

git archive --format=tar.gz -o repo.tar.gz HEAD  # file writes cap at 32 MiB
pandastack fs upload "$sid" --local repo.tar.gz --remote /tmp/repo.tar.gz

# Start the suite detached: each CLI call times out after 30 s.
pandastack sandbox exec "$sid" -- 'set -e; mkdir -p /workspace/repo
  tar -xzf /tmp/repo.tar.gz -C /workspace/repo; cd /workspace/repo
  setsid sh -c "timeout 20m ./ci.sh; echo \$? > /workspace/ci.exit" </dev/null >/workspace/ci.log 2>&1 &'

# Short polls; each one also resets the sandbox's idle TTL.
until pandastack sandbox exec "$sid" -- 'test -f /workspace/ci.exit'; do sleep 5; done

pandastack fs download "$sid" --remote /workspace/ci.log --local ci.log
cat ci.log
exit "$(pandastack sandbox exec "$sid" -- 'cat /workspace/ci.exit')"

Shape 2: a just-in-time runner per job, for fork PRs

Move the key out of the workflow. A small service subscribed to GitHub's workflow_jobwebhook asks GitHub for a just-in-time runner config with labels matching the job's runs-on, creates a microVM from a template with actions/runner in it, and starts ./run.sh --jitconfig. The runner takes at most one job and GitHub removes it; your service deletes the VM.

The fork's code runs inside the microVM with only what GitHub gives fork PRs: a read-only GITHUB_TOKEN and no repository secrets. Your PandaStack key and GitHub credentials stay in the orchestrator. The runner template is a custom template, which is private to your workspace; private templates come with Pro and Team. For larger volumes GitHub suggests ARC or its Runner Scale Set Client rather than raw webhooks, since webhook delivery can lag. The Scale Set Client leaves provisioning to you, so the create and delete shown here still apply.

Full walkthrough: ephemeral GitHub Actions runners on Firecracker.

python: your workflow_job webhook handler
# Runs in YOUR orchestrator (a workflow_job webhook handler), never in a
# fork's workflow. It holds PANDASTACK_API_KEY; the fork's code never does.
import time
from pandastack import Sandbox

def run_one_job(encoded_jit_config: str, job_id: int) -> None:
    # encoded_jit_config comes from
    # POST /repos/{owner}/{repo}/actions/runners/generate-jitconfig
    with Sandbox.create(
        template="gha-runner",   # yours: actions/runner in /opt/runner, user "runner"
        ttl_seconds=900,         # idle backstop if this process dies mid-job
        metadata={"kind": "gha-jit-runner", "job": str(job_id)},
    ) as sb:                     # leaving the block deletes the VM
        sb.filesystem.write("/opt/runner/.jit", encoded_jit_config)
        sb.exec(
            "chown runner /opt/runner/.jit && "
            "{ setsid runuser -l runner -c "
            "'cd /opt/runner && ./run.sh --jitconfig \"$(cat .jit)\"; echo $? > /tmp/runner.exit' "
            "</dev/null >/tmp/runner.log 2>&1 & }"
        )
        # A JIT runner takes at most one job, then GitHub removes it.
        deadline = time.monotonic() + 3600
        while sb.exec("test -f /tmp/runner.exit").exit_code != 0:
            if time.monotonic() > deadline:
                break
            time.sleep(10)
        # Keep the runner's log: the VM is gone once this block exits.
        sb.filesystem.download("/tmp/runner.log", f"runner-{job_id}.log")

Can fork pull requests run safely on ephemeral runners?

Safer, not safe by default. GitHub's own guidance says self-hosted runners should almost never serve public repositories, because a persistent runner can be compromised by one job and carry that into every job after it. A single-use microVM removes the persistence. It does not remove the need to keep secrets and network reach away from code you didn't write. Each control below is labelled with who provides it.

GitHub feature

No secrets in fork jobs

A workflow triggered by a pull request from a fork gets no repository secrets and a read-only GITHUB_TOKEN. Don't switch to pull_request_target or workflow_run to get them back: GitHub describes those triggers as privileged and warns against checking out untrusted PR code under them.
GitHub feature

Approve runs from outside contributors

Repositories, organizations and enterprises can require a maintainer with write access to approve workflow runs from fork pull requests before they start.
GitHub feature

One job per registration

A just-in-time runner performs at most one job and is then removed from the repository or organization. GitHub notes that re-using the hardware underneath can still leak information, which is the gap a deleted-after-use VM closes.
PandaStack

One microVM per job

Each job gets its own guest kernel behind KVM, its own network namespace and TAP device, and a copy-on-write disk that is discarded at delete. Nothing inside the VM holds your PandaStack API key.
PandaStack

Network rules the guest can't flush

On the host, outside the guest, traffic between sandboxes is dropped, the link-local range that serves cloud metadata (169.254.0.0/16) is dropped, and well-known crypto-mining pool ports are blocked.
PandaStack

Know the gap: open egress

Apart from those rules, outbound internet is open, and the hosted service has no per-sandbox egress allowlist today. A fork job can reach any public host, so give it nothing worth sending there.
your orchestrator

Put a clock on every job

Wrap the suite in timeout so a hung test fails the job, and create every sandbox with a TTL so a VM whose orchestrator died is reaped once it goes idle.
GitHub feature

Short-lived cloud credentials

When a trusted job needs cloud access, use OpenID Connect so the workflow authenticates to the cloud provider directly instead of storing long-lived keys as secrets.

Deeper dive: fork pull request CI isolation with microVMs.

How do ephemeral CI runners keep builds fast?

The usual objection to ephemeral runners is the cold cache. Don't share one. Bake it.

Build a custom template from a Dockerfile with your toolchain and resolved dependencies installed. Every job restores that snapshot and gets a copy-on-write clone of its disk: warm for reads, private for writes, discarded at delete. A job can dirty its own copy, but it can't write into the image the next job starts from. Re-bake from a trusted job when your lockfile changes.

If you also run a remote build cache, give fork jobs read-only access and let only trusted post-merge jobs write to it. The stock base template already ships Ubuntu 24.04 with git, build tools and mise-managed Node, Python, Go and Bun. Custom templates must be Debian-based; they are private to your workspace, and private templates come with Pro and Team.

The first create of a newly baked template on a host can be a cold boot that captures its snapshot; every create after that is a restore. Why a shared writable cache is the thing to avoid: per-job CI build cache isolation.

bash: bake a CI template once
# Dockerfile: a copy of templates/base/Dockerfile (Debian or Ubuntu
# images only) plus your toolchain and a lockfile-driven dependency install.
# --context uploads EVERY file in that directory (max 50 files / 50 MiB total,
# no .dockerignore), so point it at a small dir holding only what you COPY,
# e.g. ci-template/ with package.json + the lockfile. COPY paths are relative to it.
# --size-mb sets the rootfs size (default 1024, too small for a base-style image).
pandastack template build --name ci-node -f Dockerfile --context ci-template/ --size-mb 12288 --memory-mb 4096

# Every job after that starts warm:
pandastack sandbox create --template ci-node --ttl 1800

How do you fan out a test matrix without a runner pool?

Create one microVM per matrix entry when the job starts, run every shard in parallel on its own kernel, and delete the lot when the pipeline finishes. With a 49ms p50 create there is nothing to pre-provision.

No pool to keep warm

Runners are created on demand and deleted at job end. Between pushes your CI fleet is zero machines, and bills accordingly.

One microVM per shard

Fan a test matrix out as parallel sandboxes. Shards can't see each other's processes or files, and one shard's crash can't poison another's environment.

Burst CPU, billed by use

Every first-party template bakes 8 vCPUs of burst capacity. Pro and Team burst to all of them; Free sustains 2 cores per sandbox. CPU is billed by active CPU-seconds, so headroom costs nothing while a job waits on I/O.

Parallelism follows your plan: 5 concurrent sandboxes on Free, 50 on Pro, 500 on Team. The 49ms p50 figure is for a single create; a burst of simultaneous creates takes longer per VM.

What do ephemeral CI runners cost?

Between jobs, nothing, because no runner exists. While a job runs, it is metered per second at the same rates as every other PandaStack workload: $0.054 per active vCPU-hour and $0.0162 per working-set GiB-hour.

Worked ceiling: a 10-minute job, 4 vCPUs busy, 4 GiB resident

CPU   4 vCPU × $0.054  × 10/60 h = $0.0360
RAM   4 GiB  × $0.0162 × 10/60 h = $0.0108
      ceiling per job           ≈ $0.047

A real job bills less, because idle CPU meters near zero while tests wait on I/O and memory the job stops touching drops out of the working set. On Free, sustained CPU is capped at 2 cores per sandbox, a sandbox lives at most one hour, and usage draws on $5.40 of monthly credit. Pro and Team burst to all 8 vCPUs with no lifetime cap. Plan credits and limits are on the pricing page.

When are persistent or hosted runners the better choice?

Ephemeral microVMs are not the right tool for every CI job.

  • Private, trusted repos with no fork PRs. Hosted runners already give you a clean VM per job, and there is nothing to operate.
  • macOS or Windows jobs. PandaStack runs Linux microVMs only. Send Apple and Windows jobs to a provider that has those machines.
  • You want a one-line runs-on change. PandaStack is a CLI and SDK integration: you own the step or the orchestrator that creates and deletes runners.
  • State that must outlive a job. A long-running build daemon or cache server belongs on a persistent machine. Keep untrusted code off it.
  • Debugging on the box afterwards. The box is gone. Copy logs and artifacts out before delete, skip the delete on failure and pandastack sandbox ssh in before the TTL reaps it, or snapshot the failed VM (a synchronous call that can take tens of seconds on a multi-GB guest) and restore it later. More in snapshot a flaky failure.
  • Provisioning still isn't zero.A new template's first create on a host can cold-boot once, burst creates take longer than single ones, and your step pays a network round trip to the API.

What are the options for ephemeral CI runners?

Four common ways to get a fresh machine per job. The full buyer's guide, with criteria and trade-offs, is the best ephemeral CI runner platforms in 2026.

Hosted runners

GitHub-hosted runners run each job in a clean, isolated VM, with Ubuntu, Windows and macOS images. Nothing to operate. It stays the default until machine shape, network placement or control pushes you off it.

Actions Runner Controller

GitHub's Kubernetes operator for self-hosted runners, with runners that can be ephemeral and container-based. A strong fit if you already run Kubernetes. The trade-off is the container one: runner pods share their node's kernel unless the cluster uses a sandboxed runtime.

DIY Firecracker

Maximum control. You also build and run the snapshot pipeline, per-VM networking and egress rules, scheduling across hosts, and orphan reaping.

PandaStack

Per-job Firecracker microVMs from a CLI or SDK, restored from a snapshot and deleted after the job. Linux only. You write the run step or orchestrator shown above; the platform runs the fleet.

One primitive, your whole CI fleet.

This solution is a usage pattern over the platform's sandbox primitive: the same microVMs that run agent sessions, driven from your CI provider.

Ephemeral CI runner FAQ

What is an ephemeral CI runner? +

A CI runner that takes exactly one job and is destroyed when the job ends. Every job starts from the same known image, and nothing carries over: no caches, no credentials, no process a previous job left running. A runner that is cleaned up between jobs is a different thing, because the cleanup runs on a machine the previous job controlled. On PandaStack each ephemeral runner is a Firecracker microVM with its own guest kernel, created from a template snapshot and deleted after the job.

How fast does an ephemeral runner start on PandaStack? +

Sandbox create measured 49ms p50 against the live API on June 17, 2026: 50 boots of the base template, covering scheduler claim, snapshot restore, network setup and the guest readiness probe. Your CI step also pays its own network round trip to the API. The first create of a newly baked template on a host can be a cold boot that captures the snapshot; creates after that are restores.

Is GitHub's --ephemeral flag enough to make a runner ephemeral? +

It covers the registration, not the machine. The --ephemeral flag on config.sh, or a just-in-time runner created through the REST API, makes the runner take one job and then removes it from GitHub. The machine underneath is unchanged: ten ephemeral registrations on one long-lived VM still share its kernel, disk and anything cached on it. GitHub's own docs warn that re-using hardware for just-in-time runners can expose information from earlier jobs. Pair the flag with a machine that is deleted after the job, such as a fresh microVM.

Can I run pull requests from forks on PandaStack runners? +

Yes, with the orchestrator outside the fork's workflow. GitHub passes no repository secrets to workflows triggered from forks, and their GITHUB_TOKEN is read-only, so a run step that needs your PandaStack API key won't have one there. Instead, run a small service on GitHub's workflow_job webhook: it creates a microVM, starts a just-in-time runner inside it, and deletes the VM when the job ends. Outbound internet from the sandbox is open apart from host-side blocks on cloud metadata, other sandboxes and mining-pool ports, so keep anything worth stealing out of fork jobs.

Does it work with GitHub Actions, GitLab CI, Jenkins, Buildkite or CircleCI? +

Yes, as an integration you write rather than a config switch: there is no runs-on: pandastack. Any CI system that can run a shell step can create a microVM, run the suite in it and delete it, using the pandastack CLI or the Python and TypeScript SDKs. For systems built around runner agents, start a single-use agent inside a fresh microVM per job. The per-provider guides linked on this page cover GitHub Actions, GitLab CI, Jenkins, Buildkite, CircleCI and Tekton.

How do ephemeral runners handle build caches? +

Bake the cache instead of sharing it. Build a custom template from a Debian- or Ubuntu-based Dockerfile with your toolchain and dependencies installed. Every job restores that snapshot and gets a copy-on-write clone of its disk, so it starts warm and its writes are thrown away at delete; one job cannot change the image the next job starts from. Re-bake from a trusted job when your lockfile changes. Custom templates are private to your workspace; private templates come with Pro and Team, and Free uses the public templates.

What happens if a job hangs or my pipeline dies mid-job? +

Put a time limit inside the job, for example timeout 20m ./ci.sh, so a hung suite exits with a failure code. For the case where your orchestrator itself dies, create every sandbox with a TTL. The TTL counts idle time since the sandbox was last used: an exec, a file read or write, a shell connect, a fork or a snapshot. Status, metrics, log, port and directory-listing reads don't reset it, and the clock starts when a request begins, not when a long command finishes. The reaper deletes any sandbox that has been idle for longer than its TTL, so poll with a short exec, as in the examples above, rather than a status GET. On the Free plan a sandbox is also deleted one hour after it is created, busy or not.

Can I run macOS or Windows CI jobs on PandaStack? +

No. PandaStack runs Linux microVMs only, and custom templates must be built from Debian-based images. Keep macOS and Windows jobs on a provider that offers those machines, such as GitHub-hosted runners, and send your Linux jobs to microVMs.

How many CI jobs can run in parallel? +

Up to your plan's concurrent-sandbox limit: 5 on Free, 50 on Pro, 500 on Team and unlimited on Enterprise. Free is also limited to 60 creates an hour. Each job is its own microVM, so shards never share a kernel or a filesystem. The 49ms p50 figure is for a single create; a burst of simultaneous creates takes longer per VM.

What does an ephemeral CI runner cost on PandaStack? +

Nothing between jobs, because no runner exists then. While a job runs it is metered per second at $0.054 per active vCPU-hour and $0.0162 per working-set GiB-hour, the same rates as every other PandaStack workload. A 10-minute job that keeps 4 vCPUs busy and 4 GiB resident the whole time costs at most about $0.047. A job that spends time waiting on I/O costs less, because idle CPU meters near zero. On Free, sustained CPU is capped at 2 cores per sandbox.

Ship on the millisecond cloud.

Free tier with $5.40/mo usage credit. No card. Apache-2.0.