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.
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.
| PandaStack ephemeral microVM | Container per job | Persistent self-hosted runner | |
|---|---|---|---|
| Lifetime | one job, then deleted | one job, then the container is removed | many jobs, until someone rebuilds it |
| Kernel | its own guest kernel per job | the node's kernel, shared with other pods | the host's kernel, shared with every job before and after |
| Isolation boundary | hardware virtualization (KVM) | namespaces, cgroups and seccomp | none between jobs; they run in turn as the same user |
| State between jobs | none: every job restores the same snapshot | none in the container; the node persists | leftover caches, daemons, dotfiles, git hooks |
| Start of a job | 49ms p50 create from a snapshot; no pool to pre-warm | image pull if the node lacks it, then container start | nothing to start: the machine is always up, and always billing |
| Build cache | baked into the template; each job writes to its own copy-on-write clone | volumes or a remote cache you wire up | warm on local disk, and writable by every job |
| Cloud metadata endpoint | 169.254.0.0/16 dropped on the host, outside the guest | reachable unless a network policy blocks it | reachable unless you block it |
| After a failure | copy logs out, SSH in before delete, or snapshot the VM | pod logs, until the pod is gone | SSH into a box that has drifted since |
| Cost between jobs | nothing: no runner exists | the cluster's nodes | the whole machine, around the clock |
| Cleanup | delete at job end; idle-TTL reaper as backstop | the orchestrator removes the pod | hygiene 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.
# 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.
# 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.
No secrets in fork jobs
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.Approve runs from outside contributors
One job per registration
One microVM per job
Network rules the guest can't flush
Know the gap: open egress
Put a clock on every job
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.Short-lived cloud credentials
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.
# 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 1800How 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.047A 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 sshin 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.
Guides by CI provider
Background reading: the best self-hosted CI runners in 2026.
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.