v0.4 · Linux + Windows validated

Self-hosted runners that vanish after one job.

runnerize is a stateless, on-demand pool of self-hosted GitHub Actions runners for your private repos. One always-on dispatcher mints a just-in-time runner per queued job inside a throwaway rootless container, runs exactly one job, then auto-deregisters. No standing runners. No host credentials. Nothing persists.

no install needed
# run the dispatcher — pulls straight from GitHub
npx github:animeshkundu/runnerize run

or install globally npm i -g runnerize Node ≥ 18 · zero dependencies

  • 0 npm dependencies
  • 1 job per runner, then gone
  • MIT licensed
  • Stateless by design

    Every job runs in a fresh container from a fat image. No cross-job state, no persistent-workspace collisions, no long-lived runner credentials left on disk.

  • Private-only, fail-closed

    Only your own private, non-fork user repos are ever dispatched. Privacy is re-checked immediately before every mint and fails closed on any doubt.

  • Count-based scaling

    It scales the number of runners to match queued demand and lets GitHub assign the jobs — the correct model for ephemeral runners, never pinned to a job id.

How it works

One dispatcher. Runners that live for a single job.

The dispatcher watches your owned private repos. Every poll it counts queued jobs whose labels a flavor can serve, mints just enough just-in-time runners to meet demand, and hands each one a throwaway container. The runner takes exactly one job, deregisters itself, and the container is destroyed. The job never sees your host credentials or caches.

runnerize dispatch flow The always-on dispatcher polls GitHub every fifteen seconds and counts queued jobs in your private repos. For each unit of demand it re-checks that the repo is still private, then mints a just-in-time runner and launches it in a throwaway rootless container. The container runs exactly one job pulled from GitHub, the runner auto-deregisters, and the container is destroyed. The freed concurrency slot is released back to the dispatcher for the next poll. ephemeral · stateless · one job each ① poll ~15s · count queued jobs ② mint JIT · re-check private ③ launch container ④ one job, then exit runs the queued job slot released → next poll Your private repos queued Actions jobs status: queued Dispatcher one always-on process count-based · semaphore-gated reconciles stale registrations Mint JIT runner min(demand, free slots) · off-argv token Throwaway rootless container fat image · no host creds or caches runs exactly 1 job Auto-deregister & destroy nothing persists
Over-minting is safe: an unassigned runner is killed by the idle watchdog, releasing its slot.

Quickstart

Point a workflow at it and go.

You need Node ≥ 18, a container runtime for the default linux flavor (native podman/docker on Linux, WSL2 on Windows, Colima or podman machine on macOS), and a GitHub token resolved from $GH_TOKEN / $GITHUB_TOKEN, else gh auth token.

1 · Run the dispatcher

terminal
# start the dispatcher (foreground)
node bin/runnerize.js run

# see flavors, private repos, live runners
node bin/runnerize.js status

# enumerate demand, mint nothing
node bin/runnerize.js run --dry-run

# one reconcile / cleanup pass
node bin/runnerize.js remove

# install as a boot service
node bin/runnerize.js service install

2 · Target the runner

.github/workflows/ci.yml
jobs:
  build:
    runs-on: [self-hosted, linux, x64]

Always name the OS and arch labels. A bare [self-hosted] is ambiguous across flavors, so runnerize maps it to the default linux flavor only.

Flags & environment

--max <n>max concurrent runners (default 4)
--interval <ms>poll interval (default 15000; adapts with repo count)
--idle-timeout <ms>kill an unclaimed runner after this (default 120000)
RUNNERIZE_LINUX_IMAGEfat image (default catthehacker/ubuntu:full-latest)
RUNNERIZE_RUNNER_DIRuse a preinstalled runner dir instead of downloading
RUNNERIZE_WSL_DISTROpin the WSL distro (Windows host)

Security model

The job never touches your host.

Private-only, re-checked

Only repos that are private, owned by you, and of type User are dispatched; forks are excluded by default. Privacy is re-verified immediately before every mint and fails closed.

Container isolation

Each job runs in a throwaway rootless container that cannot read the host keychain, credential file, or process memory. Validated end to end: a job could not read a host secret.

Scoped credential

The token is held only by the dispatcher and passed to the runner via env, never on a command line. The runner binary is SHA-256-verified before use. A short-lived, actions:write-scoped GitHub App is the recommended production credential.

Backends

Linux and Windows today. macOS in preview.

Linux is live-validated with rootless containers. The Windows Sandbox backend is validated on Windows 11 24H2. The macOS tart backend is implemented, with Apple Silicon hardware validation pending.

FlavorHostMechanismStatus
linux any — Linux, WSL, Colima rootless container, fat image working
windows Windows 11 Pro / Enterprise Windows Sandbox (disposable) working · Win11 24H2 validated
macos Apple Silicon tart VM preview · hardware validation pending

Honest limitations

Where it fits — and where it doesn't

  • One dispatcher is a single always-on process. If it's down, jobs queue up to GitHub's 24-hour timeout; WSL and macOS aren't guaranteed always-on across a headless reboot.
  • Per-job latency is poll interval plus container start — great for occasional private-repo CI, not for tight edit-run loops.
  • Native Windows and macOS statelessness needs VMs, which are heavier than Linux containers.

Roadmap

What's next

  • Scoped GitHub App credential — short-lived, actions:write only, no code access — as the default production auth.
  • Apple Silicon hardware validation for the macOS tart backend.
  • Published tagged releases via npx github:animeshkundu/runnerize#<tag> and npm.

Stop paying for standing runners.

Run private-repo CI on your own machine, one ephemeral runner at a time.

get started
npx github:animeshkundu/runnerize run