The Best Self-Hosted CI Runners in 2026
This post assumes you've already had the argument. Somebody made the case for hosted runners — no control plane, no pool to scale, no orphan reaper, no pager — and it lost, for one of the handful of reasons it legitimately loses: the build needs to reach a database that lives inside your VPC, or a machine shape nobody rents you, or a two-hundred-gigabyte cache that makes network-restore absurd, or an FPGA toolchain, or a compliance answer about which jurisdiction the code executed in, or a volume bill you actually modelled rather than felt. Those are real. What follows is the next decision, which is the one people underestimate: which runner software you install, and — the part almost nobody chooses deliberately — what separates one job from the next on the hardware you now own.
I'm Ajay; I build PandaStack, which appears near the bottom of this list. The framing I want to push is that these tools differ less in what they can build than in the boundary they draw around a build. Every option here will check out your repo and run your test suite. What varies enormously is whether job number two starts on a filesystem job number one could write to, whether they share a kernel, whether a job can query the host's cloud instance role, and who gets woken up when the pool wedges. That's the axis this roundup organises around, because it's the one that determines whether self-hosting was a good idea or an incident waiting for a slow week.
Why self-host at all, honestly — and when not to
The good reasons are specific and you can name them in one sentence each. Network locality: the integration suite talks to a database that will never have a public address. Cache and artifact gravity: your build's working set is large enough that shipping it in and out of a hosted runner costs more time than the compile. Hardware: GPUs, big-memory machines, ARM at a shape you can't rent, a physical device on a USB bus. Data residency: an auditor wants a defensible answer about where source code was decrypted and executed. Economics at volume: a fleet you keep genuinely busy can be much cheaper than metered minutes — the emphasis on genuinely, because bursty CI on always-on capacity means paying for the trough.
The bad reason, which is by far the most common, is that builds feel slow. Nine times out of ten that's a caching or parallelism problem you can fix inside the pipeline in an afternoon, and self-hosting to solve it buys you a permanent operations commitment in exchange for a temporary improvement. Runner fleets do not stay fixed. They accumulate bespoke images, a scaling policy that made sense once, a wedged node someone drains manually every few weeks, and eventually an unwritten rule that you don't touch it before a release. If your reason for leaving hosted runners doesn't survive being written down and read aloud at a planning meeting, stay where you are.
Your CI runner is a machine that runs whatever is in a YAML file someone can open a pull request against. Every question about isolation follows from that sentence, and so does every incident.
The field, runner by runner
GitHub Actions self-hosted runners + actions-runner-controller (ARC)
If your code lives on GitHub, this is the default answer and deserves to be. The runner itself is GitHub's own agent, which you can register against a repository, an organisation, or an enterprise; actions-runner-controller is the Kubernetes operator that turns that agent into a fleet. The modern shape is the runner scale set: you declare a scale set with a runner image and a set of labels, the controller watches queue depth for that label, and creates a pod containing a runner registered as ephemeral — meaning it accepts exactly one job and then deregisters. Pod created, one job, pod deleted. Autoscaling is driven by real demand rather than a guess, the runner image is a declarative artifact you can review and pin, and the whole thing lives in a cluster you already know how to observe.
Two honest caveats. The first is the kernel: a pod per job resets the filesystem and the process namespace, and shares a kernel with every other pod on that node, including jobs running concurrently. For a private repo built by employees that's a reasonable boundary and the reset is genuine. The second is the one GitHub itself puts in a warning box in its documentation, and it deserves repeating in full seriousness: self-hosted runners are not recommended for public repositories, because a fork pull request can execute arbitrary code on your runner, and forked-PR workflows are the exact case where the container boundary is doing the most work. A self-hosted runner attached to a public repo without a hard isolation story underneath is, functionally, a free compute grant to the internet with your VPC routes attached. If you're going to build fork PRs on your own hardware, either put a VM boundary under the pod — a VM-backed runtime class such as Kata over Firecracker or Cloud Hypervisor — or put those jobs somewhere else entirely.
GitLab Runner — the executor is the whole story
GitLab Runner is a single well-built Go binary, and the interesting thing about it is that it doesn't have an isolation model; it has a menu of them, and you pick one during `gitlab-runner register` in response to a prompt that looks like a formality. `shell` runs jobs directly on the runner host as the runner user, in a build directory reused between jobs — which is to say no isolation at all, and I'll come back to that. `docker` runs each job in a fresh container from a declared image: a genuine reset boundary with a shared kernel. `docker-machine` (and the newer autoscaling instance executors that succeed it) provisions a cloud VM per job, which buys VM-level separation at the cost of instance provisioning latency in front of every build. `kubernetes` is the ARC-shaped answer, a pod per job.
That menu is the tool's greatest strength and its most reliable trap. Four postures, one config file, and the difference between the safest and the most dangerous is a single word on a line nobody revisits. In practice the choice is usually made by whichever tutorial someone found on the day they set it up, and then inherited by everyone after them. If you run GitLab Runner, go and read the `executor` line in every `config.toml` you own right now, before you finish this post. The surrounding controls are genuinely good and worth configuring deliberately — protected and masked variables, and `id_tokens` for OIDC against your cloud or Vault instead of long-lived secrets sitting in the environment — but none of them help if the executor underneath is `shell`.
# `gitlab-runner register` writes here: /etc/gitlab-runner/config.toml
# The `executor` line IS your isolation model. Nothing else in this file matters
# as much, and nothing else is as easy to choose by accident.
concurrent = 8
check_interval = 3
# ---------- executor = "shell": trust-based access control ----------
[[runners]]
name = "build-01-shell"
url = "https://gitlab.example.com/"
token = "glrt-REDACTED"
executor = "shell" # jobs run as the gitlab-runner user, on this host,
# alongside whatever the last job left behind in
# ~/.npmrc, ~/.docker/config.json and ~/.kube/config
builds_dir = "/home/gitlab-runner/builds"
# REUSED between jobs -- including any .git/hooks a
# previous job wrote, which run during the NEXT
# job's clone, before your pipeline logic exists
# ---------- executor = "docker": a real reset, a shared kernel ----------
[[runners]]
name = "build-02-docker"
url = "https://gitlab.example.com/"
token = "glrt-REDACTED"
executor = "docker"
[runners.docker]
image = "registry.example.com/ci/base:2026-09-01" # pinned, never :latest
pull_policy = ["always"]
privileged = false # `true` is how most teams make docker-in-docker
# work, and it is approximately root on the host
volumes = ["/cache"] # a cross-job WRITE surface with a stable name: the
# one piece of the shared runner you kept by choice
# Never, on anything that builds a fork:
# volumes = ["/var/run/docker.sock:/var/run/docker.sock"]
network_mode = "ci-egress-restricted" # default-deny, enforced by the host
[runners.cache]
Type = "s3"
Shared = false # scope caches by trust level, not by convenienceBuildkite Agent
Buildkite is unusual in this list because bring-your-own-compute isn't a deployment option, it's the product's premise: the coordination, pipeline definition, UI, and scheduling are hosted, and the agent — an open-source Go binary — runs entirely on infrastructure you own. The split is clean in a way that matters for exactly the constraints that pushed you here. Your source, secrets, and build artifacts stay on your side; what crosses the boundary is job metadata and logs. Teams with a hard rule about where code executes, or with a fleet spread across a data centre and two clouds, tend to find this the most natural fit in the category, and the elastic-stack tooling for running agents on autoscaling cloud capacity is well trodden.
The isolation caveat is the familiar one wearing different clothes. By default an agent runs jobs as a process on the host — the `shell` posture — and containerised or VM-backed execution is something you configure via plugins or by baking it into the agent's hooks. That's a design choice consistent with the tool's philosophy: the agent orchestrates, you decide what it orchestrates into. It does mean the isolation model is entirely yours to establish, and the fast path to a working pipeline does not establish one for you. Buildkite's own documentation is direct about the risk of running untrusted or fork-originated builds on agents you haven't isolated; take that at face value rather than as boilerplate.
Jenkins with dynamic agent clouds
Jenkins remains, by an enormous margin, the largest installed base of self-hosted CI in the world, and any roundup that treats it as a legacy footnote is writing for an audience that doesn't exist. Modern Jenkins is not the static-executors-on-the-controller setup most of its reputation comes from. Configured with a cloud provider plugin — Kubernetes, Docker, EC2 — it provisions agents dynamically: a pod, container, or instance is created for the build, the build runs, and the agent is torn down. With the Kubernetes plugin and pod templates you get something structurally close to ARC, plus thirty years of plugins for build systems and artifact formats nobody else supports.
The tradeoffs are freshness and blast radius. Dynamic agents are only as clean as the image behind them, and the natural pressure — provisioning takes time, so keep agents warm and reuse them — is precisely the pressure that turns 'ephemeral' into 'we run a cleanup script'. The other thing to internalise is that the Jenkins controller is a high-value target with credentials for everything it can build, and pipelines are code that executes on it unless you're careful about where each step runs. Keep builds off the controller entirely, treat the plugin surface as a dependency you patch rather than an app store, and make the agent image a reviewed artifact. Done deliberately, Jenkins-with-clouds is a perfectly respectable 2026 answer. Done by accretion, it's the thing everyone is afraid to restart.
Woodpecker CI and Drone
For a small, self-contained setup — a homelab, an internal tools team, a project on a self-hosted Gitea or Forgejo — this is the sweet spot the heavyweight options miss. Woodpecker (a community fork of Drone that carries the lineage forward under a permissive licence) is a small server plus agents, with pipelines defined as YAML where every step is a container. Container-native is the default rather than a plugin: each step declares an image, gets a fresh container, and shares a workspace volume with the other steps in the same pipeline. Setup is genuinely an evening, not a quarter, and the operational surface is small enough that one person can hold all of it in their head.
What you're accepting is scope. The ecosystem is smaller than Jenkins' or GitHub's, enterprise-shaped features (fine-grained RBAC, sophisticated queue policies, audit trails) are thinner, and the isolation posture is container-per-step on a shared kernel — with a `privileged` escape hatch that pipelines reach for whenever they need to build images, which quietly removes the boundary for those steps. Perfect for trusted code and small fleets. Not the thing to point at a public repository's fork PRs.
Tekton
Tekton is what you get when you decide CI should be Kubernetes objects rather than a system that happens to run on Kubernetes. Tasks, Steps, Pipelines, PipelineRuns — all CRDs, all reconciled by controllers, all subject to the same RBAC, admission control, GitOps, and observability as everything else in the cluster. A TaskRun becomes a pod; each Step is a container in it, executed in order. If your platform team's answer to every problem is a controller and a custom resource, Tekton fits that worldview exactly, and it's a strong foundation for building CI as an internal platform others consume rather than as a tool a team uses.
The cost is verbosity and altitude. Expressing a build that would be twelve lines of GitHub Actions YAML can take several resources and a real understanding of workspaces, results, and parameter plumbing, and most teams end up putting a generator or a higher-level layer (Tekton Chains for provenance, a Dashboard, a bespoke abstraction) in front of it. Isolation is Kubernetes isolation — pod per run, shared node kernel — with the same caveats and the same fix if fork PRs are in scope. Pick it when CI is a platform you are building; don't pick it because you needed to run some tests.
Concourse
Concourse is the most opinionated system here and the most interesting one philosophically. Its model is that every step runs in a container built from a declared image, taking declared inputs and producing declared outputs, with no ambient state between steps — pipelines are a graph of resources and jobs rather than a script with a machine underneath. That constraint is enforced rather than encouraged, which means hermetic-ish builds fall out of the design instead of being a discipline you have to maintain. Reproducibility problems that other systems make you hunt for tend not to arise, because there is nowhere for them to hide.
The same opinionated design is why adoption is harder. You cannot cheat: 'just leave that file on the box' is not available, so migrating an existing pipeline means genuinely modelling its inputs and outputs, which is good for you and unpleasant on a deadline. The resource-type abstraction is elegant and unfamiliar, and it is emphatically its own ecosystem rather than a place your GitHub Actions knowledge transfers. Isolation is still container-per-step on a shared kernel — strong hygiene, not a hardware boundary — but of the container-based options, Concourse's structure gives you the least room to accidentally build a stateful runner.
Firecracker and microVM-based runners (including PandaStack)
One rung further up: a virtual machine per job, with its own guest kernel, isolated by KVM behind a minimal virtio device model. This is the only option on the list where a kernel-level bug reached from a build script costs you a machine that was going to be deleted in four minutes rather than a node that other people's jobs are sitting on. It's the boundary the large public compute platforms chose for running strangers' code, and the reason to want it in CI is exactly the same: you are running strangers' code, and the fact that some of the strangers are your own dependencies' postinstall scripts doesn't make it less true.
The reason this hasn't been the obvious default is latency. Cold-booting a VM per job puts tens of seconds in front of every build; a team facing that will pool the VMs; pooled VMs outlive jobs; and now you have moving parts without the property you wanted. Snapshot-restore removes that objection — restore a pre-booted, pre-warmed memory image instead of booting. On PandaStack every create is a snapshot restore rather than a cold boot: about 179ms p50 and 203ms p99, with the restore step itself around 49ms. The roughly 3-second cold boot happens once, when the template snapshot is baked. The rootfs is copy-on-write, so a snapshot holding a warm toolchain and a populated dependency cache is cloned per job rather than reinstalled — warm on read, private on write, discarded with the guest. Same-host forks run 400–750ms (1.2–3.5s cross-host), which is the primitive for fanning one warmed checkout across a test matrix. Each sandbox gets its own network namespace and TAP device from 16,384 pre-allocated /30 subnets per agent, so a default-deny egress policy is enforced outside the guest where the job can't flush it.
Now the honest part, because this is a self-hosting roundup and the entries above are things you install. PandaStack is not a runner you drop into a workflow file; there is no `runs-on: pandastack`. It's an open-source Firecracker control plane plus a per-host agent, driven from a Python or TypeScript SDK, and making it your CI fleet means writing the orchestrator that mints a per-job registration token, creates a sandbox, streams logs, collects artifacts, and reaps. That's an integration, not a plugin. It also needs bare metal or nested-virtualisation-capable instances with `/dev/kvm`, which rules out a chunk of managed Kubernetes and every provider that doesn't expose KVM. If ARC on your existing cluster satisfies your threat model, use ARC — it is dramatically less work. This rung is for teams building CI as a product, verifying code submitted by users or agents, or building fork PRs on their own hardware where the container boundary genuinely isn't enough.
from pandastack import Sandbox
# A template baked by a TRUSTED job on a schedule: toolchain, resolved
# dependencies from the lockfile, warm page cache -- frozen into a snapshot and
# pinned by date, because "latest" is how a poisoned cache entry outlives the
# ephemerality you built a whole fleet to get.
TOOLCHAIN = "ci-node22-2026-09-01"
def run_job(job: dict) -> dict:
"""One job, one microVM, its own guest kernel, destroyed at the end.
This function is the thing you are signing up to own at this rung of the
ladder. It is not a config change: it is a service with a queue, a reaper,
a log pipeline, and a pager rotation.
"""
sbx = Sandbox.create(
template=TOOLCHAIN,
ttl_seconds=1800, # a wedged suite gets reaped, not billed to Friday
metadata={
"repo": job["repo"],
"sha": job["sha"], # pin the commit -- a fork can move a ref
# between queue time and start time
"trust": job["trust"], # "fork" | "internal" -- decides the token
},
)
try:
# Fences first, while the only code that has run is ours. If the egress
# policy does not apply cleanly we do NOT build. Fail closed.
fence = sbx.exec("bash /opt/ci/egress-fence.sh")
if fence.exit_code != 0:
raise RuntimeError(f"egress policy not applied: {fence.stderr[-500:]}")
# Only now does repo-controlled code get a CPU. A build script is
# untrusted input that happens to compile.
build = sbx.exec(f"bash /opt/ci/run-job.sh {job['sha']}")
artifacts = b""
if build.exit_code == 0:
artifacts = sbx.filesystem.read("/work/dist.tar")
return {
"exit_code": build.exit_code,
"logs": build.stdout + build.stderr,
"artifacts": artifacts,
}
finally:
# The checkout, whatever node_modules' postinstall hooks wrote, the
# short-lived token, the page cache and the guest kernel all stop
# existing together -- the only teardown whose correctness does not
# depend on the build's cooperation.
sbx.kill()A note on Nomad-scheduled runners
Worth a mention as a placement layer rather than a runner: if you already run HashiCorp Nomad, you can schedule any of the agents above as Nomad jobs and get bin-packing, autoscaling, and a scheduler without adopting Kubernetes. Nomad's task drivers also include a QEMU driver and community drivers for firecracker-microvm, so the isolation posture can range from `exec`/`raw_exec` (host processes, no boundary) through Docker to full VMs, depending on the driver you choose. Same rule as everywhere else on this page: the driver, not the scheduler, decides what separates your jobs.
The isolation ladder, which is the actual decision
Strip the branding away and every option above sits on one of four rungs. This ladder is the real content of this post; the product names are how you get onto a rung.
- Rung 0 — shell executor / raw process on the host. No boundary at all. Jobs run as a service user, in a reused build directory, next to cached registry credentials, a kubeconfig, and a cloud instance profile that hands a role to anything that can make an HTTP request from that box. This is trust-based access control: it works exactly as long as everyone whose code runs on it — including every transitive dependency's install script — is behaving. GitLab's `shell`, Buildkite's default, Jenkins' static executors, Nomad's `raw_exec`.
- Rung 1 — container per job or per step. Fresh filesystem, fresh process namespace, cgroup limits, seccomp. This is a genuine reset and it eliminates the entire class of leftover-state problems, which is most of what actually goes wrong. It does not eliminate the kernel: every container on that node shares one syscall surface, so a kernel-level bug is a node compromise rather than a lost container. And a mounted Docker socket or `privileged: true` collapses the rung back to zero, silently. ARC pods, GitLab's `docker` and `kubernetes` executors, Woodpecker, Tekton, Concourse.
- Rung 2 — hardened sandbox: gVisor, or Kata Containers. gVisor puts a user-space kernel between the job and the host's syscall interface; Kata runs the pod inside a lightweight VM behind a runtime class, so you keep Kubernetes semantics and gain a hypervisor boundary. Both are usually adopted as a runtime class under an existing pod-per-job setup, which makes this the cheapest real upgrade available if you're already on rung 1.
- Rung 3 — a full VM per job, with its own guest kernel. Firecracker, Cloud Hypervisor, QEMU, or a cloud instance per job. The job's kernel is not your kernel; an escape has to get through the hypervisor's much smaller, better-audited interface rather than the full Linux syscall surface. This is the boundary you want for code you did not write and cannot review, and snapshot-restore is what makes it cost a fraction of a second instead of a minute.
The rule that follows is short enough to put in a policy document. Forked pull requests and customer-supplied pipelines belong at rung 2 or 3, always. Everything else is a judgement call about your contributor set, and rung 1 is a reasonable place to land for a private repo built by employees. The thing to notice is that most teams end up on a rung by accident — by picking an executor from a prompt, by adding `privileged: true` to make image builds work, by mounting the Docker socket because a plugin wanted it. Choose the rung explicitly and write down which one you're on, because the version of this that hurts is discovering it during an incident.
The operational realities buyers forget
Isolation is the interesting question. These are the ones that decide whether you're happy in six months, and they're conspicuously absent from most comparison tables.
- Cache and artifact storage. This is the number one reason self-hosted fleets drift back toward shared mutable state. Ephemeral runners start cold, someone adds a shared cache volume or bucket every job can write to, and you've rebuilt the cross-job write surface you were trying to escape — centralised this time, so one poisoned entry reaches the whole fleet and looks like a cache hit in the logs. The workable answers: bake warm caches into the image or snapshot from a trusted scheduled job, use content-addressed remote caches (Bazel-style, sccache, Turborepo, Nx) with writes restricted to trusted post-merge jobs, and scope cache namespaces by trust level rather than by branch.
- Autoscaling to zero. Ask directly whether your chosen setup can go to zero capacity overnight, and how long the first job after that waits. ARC and Buildkite's elastic stacks scale on queue depth; Jenkins clouds scale but the provisioning latency pushes teams to keep a floor; a hand-rolled VM fleet usually never scales down at all, which is how a cost-motivated migration becomes more expensive than the hosted runners it replaced.
- Secrets scoping. Long-lived credentials in the environment undo everything the isolation bought, because you destroyed the machine and the valuable thing was never tied to the machine. Use OIDC to mint short-lived, claim-scoped credentials per run, and pin the subject claim as tightly as the workflow allows — a trust policy matching the repository but not the ref lets any branch in that repo assume the role. And keep a hard split between the stage that runs fork code and the stage that holds anything worth stealing.
- ARM versus x86. One of the better reasons to self-host, and one of the easiest ways to double your maintenance surface. Every runner image, every base image, every cached toolchain now exists twice, and the failure mode is a subtly different dependency resolution on one architecture that nobody notices for three weeks. Decide whether you're running a genuinely multi-arch fleet or an x86 fleet with an ARM annex, and label accordingly.
- GPUs. Device plugins, driver versions in the runner image that must match the host, and the fact that a GPU node is expensive enough that scale-to-zero stops being an optimisation and becomes the whole business case. Also worth knowing before you promise it: passing a GPU into a microVM is a materially harder problem than passing one into a container, so the isolation ladder and the accelerator requirement can pull against each other.
- Who gets paged at 2am. The question that should be asked first and is asked last. Hosted runners come with someone else's on-call. Every option on this page transfers that to you: the wedged pool, the node that stopped registering, the disk that filled with Docker layers, the queue that isn't draining during a release. Name the person before you migrate. If the answer is 'we'll figure it out', you have found the real cost of self-hosting, and it is not the instance bill.
Side by side
- ARC / GitHub Actions self-hosted runners — Isolation: pod per job, shared node kernel (rung 1); ephemeral registration is real and single-use at the pod level. Scale-to-zero: yes, driven by queue depth. Best for: GitHub shops already running Kubernetes, with a trusted contributor set. Watch out for: public repos and fork PRs, which GitHub's own docs warn against on self-hosted runners.
- GitLab Runner (docker executor) — Isolation: fresh container per job, shared kernel (rung 1); `shell` is rung 0 and `kubernetes` is the ARC shape. Scale-to-zero: yes with autoscaling instance executors; a static docker runner is always-on. Best for: GitLab shops that will choose the executor deliberately. Watch out for: `privileged = true` and a mounted Docker socket, both of which collapse the boundary.
- Buildkite Agent — Isolation: whatever you configure; the default is a host process (rung 0), containerised via plugins or hooks. Scale-to-zero: yes, elastic agent stacks are a first-class pattern. Best for: hybrid and multi-cloud fleets, and teams with a hard requirement that code executes only on their own compute. Watch out for: the isolation model being entirely yours to establish.
- Jenkins + dynamic agent clouds — Isolation: pod/container/instance per build depending on the cloud plugin (rung 1, or rung 3 with EC2-per-build). Scale-to-zero: yes with cloud plugins, though provisioning latency pushes teams to keep a warm floor. Best for: the enormous installed base, and builds that need a plugin nothing else has. Watch out for: agent-image freshness, plugin surface, and the controller being a credential-rich target.
- Woodpecker CI / Drone — Isolation: container per step, shared kernel (rung 1). Scale-to-zero: modest; small agent fleets, usually static. Best for: small self-hosted setups, homelabs, Gitea/Forgejo, teams who want one evening of setup and a small operational surface. Watch out for: `privileged` steps for image builds, and thinner enterprise-shaped features.
- Tekton — Isolation: pod per TaskRun, shared kernel (rung 1). Scale-to-zero: inherits the cluster's autoscaling. Best for: platform teams building CI as an internal product with GitOps and CRDs end to end. Watch out for: verbosity — you will build or adopt a higher-level layer on top.
- Concourse — Isolation: container per step, shared kernel (rung 1), with unusually strong structural discipline against ambient state. Scale-to-zero: worker-pool shaped; less elastic than queue-driven options. Best for: teams that want hermetic-ish, reproducible pipelines enforced by the tool. Watch out for: a steep, unfamiliar model and its own ecosystem.
- microVM runners (Firecracker/Cloud Hypervisor, incl. PandaStack) — Isolation: VM per job with its own guest kernel (rung 3). Scale-to-zero: yes; TTL reaping means nothing outlives its job. Best for: fork PRs on your own hardware, customer-supplied pipelines, agent-authored builds, CI you're building as a product. Watch out for: needing /dev/kvm, and being an orchestrator you write rather than a plugin you install.
How to choose
- Write down the constraint that took you off hosted runners, in one sentence, with a number in it. If you can't, stop here — the best self-hosted CI runner for you is the hosted one you already have.
- Answer the untrusted-code question next, because it dominates everything else. Do you build fork pull requests on a public repo, customer-submitted repositories, or model-generated branches? If yes, you need rung 2 or rung 3 and most of the field is disqualified for those jobs specifically. If no, rung 1 is a defensible landing spot and your decision is mostly about ergonomics.
- Then pick by where you already are. On GitHub and Kubernetes: ARC with runner scale sets. On GitLab: GitLab Runner with the `docker` or `kubernetes` executor, chosen deliberately. Already running Jenkins: keep it, and move it to dynamic agent clouds rather than migrating. Small and self-hosted: Woodpecker. Building CI as a platform: Tekton. Wanting hermetic pipelines by construction: Concourse. Hybrid compute with hosted coordination: Buildkite.
- If the untrusted-code answer was yes, add a boundary rather than switching tools: a Kata or gVisor runtime class under your existing pod-per-job setup is the cheapest real upgrade on this page, and it doesn't cost you your pipeline definitions.
- If the runner is a product rather than a rented box — you're building CI, a verification service, or an agent that has to run the branch to know whether it fixed the bug — drive microVMs per job from an SDK and own the orchestration. Budget it as a build, not a config change.
- Whichever you choose, fix caches and secrets on the same day you migrate: read-only baked or content-addressed caches with writes limited to trusted jobs, OIDC with tightly pinned subject claims, default-deny egress enforced outside the job, and a named human on the pager.
Where this leaves you
Self-hosting CI is a trade you should make with your eyes open: you gain locality, hardware, cache gravity, and a defensible answer about where code executes, and you take on a fleet, a scaling policy, an image pipeline, and a pager. The tools have converged more than the marketing suggests — most of the field is pod-or-container-per-job on a shared kernel, differing mainly in how pipelines are expressed and how much of your existing platform they reuse. That convergence is good news: it means the choice between ARC, GitLab Runner, Woodpecker, Tekton, and Concourse can be made on ergonomics and on what you already operate, without much risk of getting the security posture wrong by picking the wrong logo.
The choice that does carry risk is the rung, and it's the one made by default. Rung 0 — a shell executor, a raw host process, an agent with no container around it — is trust-based access control, and it holds right up until a dependency's postinstall script disagrees. Rung 1 solves leftover state, which is most of what goes wrong day to day, and shares a kernel, which is the part that matters when the code is genuinely hostile. Rungs 2 and 3 are where fork PRs and customer-supplied pipelines belong, and the honest reason more teams aren't there is that VMs used to be slow to start. Snapshot-restore is what changed that — on PandaStack, 179ms p50 per create with a copy-on-write clone of a warm, baked toolchain, per-sandbox networking for egress policy, and TTL reaping so nothing outlives its job. If that's the rung you need, it's now the cheap one. And if a single line in a workflow file would have solved your problem, that was always the better answer.
Frequently asked questions
What is the best self-hosted CI runner in 2026?
There isn't a single winner, because most of the field converges on the same isolation posture and differs mainly in ergonomics and in what you already run. The practical rule is to pick by where you already are: actions-runner-controller with runner scale sets if you're on GitHub and Kubernetes, GitLab Runner with the docker or kubernetes executor if you're on GitLab, Jenkins with dynamic agent clouds if you already have Jenkins and a plugin nothing else replaces, Woodpecker for small self-hosted setups, Tekton if you're building CI as an internal platform, Concourse if you want hermetic pipelines enforced by the tool, and Buildkite if you want hosted coordination with all compute on your own infrastructure. The one question that genuinely disqualifies options is whether you build untrusted code — fork pull requests, customer repositories, agent-generated branches — because that pushes you off shared-kernel containers and onto a VM or hardened-sandbox boundary. Decide that first, then choose on ergonomics.
Is it safe to run self-hosted GitHub Actions runners on a public repository?
GitHub's own documentation warns against it, and the warning is well founded rather than boilerplate. A pull request from a fork can cause arbitrary code to execute on your runner, so a self-hosted runner attached to a public repo is effectively an open invitation to run code on your infrastructure — with whatever network routes, cached credentials, and cloud instance identity that machine happens to have. Ephemeral runners help by guaranteeing one job per runner registration, but that flag scopes the registration, not the machine underneath: several ephemeral runner processes on one long-lived VM still share a kernel, a filesystem, and an instance profile. If you must build fork PRs on your own hardware, put a hardware or hardened-sandbox boundary under each job — a Kata or gVisor runtime class under your pods, or a microVM per job — block the cloud metadata service at the network layer, and run those jobs with no secrets and default-deny egress. Otherwise keep fork builds on hosted runners, where the blast radius isn't yours.
Which GitLab Runner executor should I use?
For almost everyone the answer is docker or kubernetes, and the important thing is to choose rather than inherit. The shell executor runs jobs directly on the runner host as the runner user, in a build directory reused between jobs, next to whatever credentials previous jobs left in the home directory — no isolation, and never appropriate for anything that builds forks. The docker executor gives each job a fresh container from a pinned image, which is a genuine reset with a shared kernel, and the kubernetes executor gives you a pod per job with the same properties plus cluster-native autoscaling. Autoscaling instance executors provision a fresh cloud VM per job, buying a VM boundary at the cost of provisioning latency in front of every build. Whichever you pick, avoid privileged = true and never mount the Docker socket into a job container: both collapse the container boundary completely, and both are the standard workaround people reach for when they need to build images.
How do you keep build caches warm on ephemeral self-hosted runners?
The obvious fix — a shared volume or bucket that every job restores from and writes to — reintroduces exactly the cross-job write surface that ephemerality removed, and centralises it, so one poisoned entry reaches the whole fleet and shows up in the logs as a cache hit. The approaches that work all keep caches readable but not writable by untrusted jobs. Bake the toolchain and resolved dependencies into the runner image or VM snapshot from a trusted scheduled job running off the lockfile, so updating the cache becomes a reviewable event rather than a side effect of whichever pull request ran last. Use content-addressed remote caches (Bazel-style, sccache, Turborepo, Nx) with write access limited to trusted post-merge jobs and pull-request jobs read-only, scope cache namespaces by trust level rather than branch, and verify digests on restore so poisoning fails loudly. Snapshot-restore with a copy-on-write rootfs makes the warm-but-immutable pattern structurally cheap: reads come from the shared baked image, writes land in a private layer that is discarded when the machine dies.
Do I need microVMs for CI, or is a container per job enough?
It depends entirely on who can cause code to run. If your contributors are employees on a private repository, a container or pod per job is a reasonable boundary: it eliminates leftover state between jobs, which is the failure mode that actually bites most teams, and the residual risk is a shared kernel among people who already have commit access. If you build fork pull requests on a public repo, run pipelines your customers supply, or execute build steps a model generated, then the container boundary is being asked to hold against deliberately hostile code and a kernel-level bug becomes a node compromise. That's when you want a per-job guest kernel — a VM-backed runtime class such as Kata inside your existing Kubernetes setup, or a microVM per job. The reason this used to be impractical was start-up time, which snapshot-restore removes: on PandaStack a create is about 179ms p50 rather than a multi-second cold boot, with a copy-on-write clone of a pre-warmed toolchain, so the strong boundary no longer costs you queue time.
Keep reading
- The Best Ephemeral CI Runner Platforms in 2026 — the hosted and managed side of this decision
- Fork-PR CI Without Getting Pwned: One microVM Per Job — the rung-3 case, in detail
- Self-Hosted GitHub Actions Runners in Firecracker MicroVMs — what the per-job orchestrator actually looks like
- Docker-in-Docker vs microVMs for CI builds — why privileged: true collapses the container rung
- Protecting CI Secrets from Malicious Dependencies — scoping credentials once you own the fleet
49ms p50 cold start. Fork, snapshot, and scale to zero.