Runner Infrastructure

Ephemeral Runner

An ephemeral runner accepts one CI job before being deregistered. Secure implementations also dispose of or sanitize the execution environment afterward; GitHub's ephemeral registration flag alone does not delete the host or its disk.

An ephemeral runner accepts one job and then deregisters. The agent lifecycle and the host lifecycle are separate: GitHub's --ephemeral flag does not delete the virtual machine or sanitize its disk.

Definition

The agent is registered for one job. After that job finishes, GitHub deregisters it. The provisioning system must separately dispose of or sanitize the execution environment to prevent unintended state from reaching another job.

Ephemeral runners can use virtual machines, containers, or physical hosts. Isolation depends on the execution boundary and cleanup implementation, not merely the one-job registration rule. Intentionally restored artifacts, caches, or snapshots can carry selected data into a fresh environment.

How the agent knows to stop after one job

The runner agent is open source and published at github.com/actions/runner under the MIT license. It is the same binary whether the machine belongs to GitHub, to you, or to a provider, and ephemeral behavior is a configuration flag on it:

./config.sh \
  --url https://github.com/acme \
  --token "$RUNNER_REGISTRATION_TOKEN" \
  --labels self-hosted,linux,x64 \
  --ephemeral

./run.sh

The --ephemeral flag is what changes the lifecycle. Labels are only routing keys, so writing ephemeral into the --labels list is a naming convention that changes nothing about behavior. A runner configured without the flag stays registered and keeps accepting jobs until someone stops it.

The lifecycle, in order

An ephemeral runner moves through the same sequence every time:

  1. Something creates the machine: a controller reacting to a queued job, an autoscaling group, or a provider's scheduler.
  2. The agent configures itself with a short-lived registration token and the --ephemeral flag, then opens an outbound long poll to GitHub and waits.
  3. GitHub sends one job message to a runner whose labels satisfy the job's runs-on key.
  4. The agent acknowledges the message, which claims the job so no other runner takes it, and spawns a worker process.
  5. The worker downloads every action the job references, runs the steps in order, and streams logs back to GitHub.
  6. The agent reports the conclusion, deregisters itself, and exits.
  7. The creator of the machine deletes the machine and its disk.

Step 7 is the step teams forget when they build this themselves. An agent that exits without the machine being deleted leaves a paid-for host sitting idle, and worse, leaves a disk that a later agent may reuse.

Ephemeral compared with long-lived runners

The table below compares disposable per-job machines with long-lived runners: one registration, many jobs, in whatever order the queue hands them over. Neither shape is correct in general, and the tradeoffs are consistent enough to tabulate.

DimensionEphemeral runnerLong-lived runner
State between jobsNone; every job starts from the base imageWhatever the previous jobs left on disk
IsolationA fresh execution boundary and cleanup reduce unintended cross-job state; shared caches and credentials still need controlsJobs can share a filesystem, package cache, and Docker daemon
Cancelled or crashed jobsMachine is discarded with the mess inside itPartial files and stray processes stay behind
MaintenanceRebuild the image; the fleet picks it up on the next jobPatch, prune disks, and restart agents on running hosts
Reproducing a failureSame starting state every runDepends on the host's history
Scaling to zeroNatural; no runner exists between jobsRequires draining a runner mid-lifecycle
Start of job workSetup repeats on every jobWarm caches are already on disk

State is the property most people mean when they use the term. On an ephemeral runner, a node_modules directory, a Gradle cache, a pulled container image, and a leftover .env file all disappear at the job boundary. Anything a later job needs has to be published deliberately through artifacts, a cache action, or a prebuilt image.

Isolation also requires appropriate credentials, network policies, and controls over shared storage. A fresh machine reduces accidental local state reuse, but does not protect a shared cache or external service from credentials that a job can access. Untrusted pull requests should not receive production secrets.

Maintenance changes shape rather than disappearing. There is no fleet of hosts to patch, prune, or restart, and no disk that fills with Docker layers until jobs start failing on a full volume. In exchange, the image becomes the artifact you own: tooling versions live in an image build pipeline, and rolling out a change means publishing a new image rather than running a configuration tool across live hosts.

Where ephemeral runners show up

GitHub-hosted jobs normally receive a fresh execution environment. The implementation varies by runner type; single-CPU hosted runners use containers. Consult the runner catalog rather than assuming every hosted job uses its own VM.

Self-hosted fleets choose. Actions Runner Controller, the Kubernetes controller GitHub publishes, watches the job queue and creates one ephemeral runner pod per queued job, which is what makes scale-to-zero safe: there is no risk of terminating a runner in the middle of a job, because a runner only exists while it has one. Runner providers register machines against your organization through the same GitHub registration API, so the lifecycle they implement is described in their own documentation rather than in the workflow file.

Example

The clearest demonstration is a workflow that tries to hand a file from one job to the next without using a documented mechanism.

name: ephemeral-demo
on: workflow_dispatch

jobs:
  first:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Leave something behind
        run: |
          echo "written by job first" > "$HOME/marker.txt"
          npm install -g typescript
          docker pull alpine:3.20

  second:
    needs: first
    runs-on: ubuntu-latest
    steps:
      - name: Look for the marker
        run: |
          if [ -f "$HOME/marker.txt" ]; then
            echo "state carried over"
            exit 1
          fi
          echo "clean machine: marker absent"
      - name: Look for the globally installed tool
        run: command -v tsc || echo "tsc absent"
      - name: Look for the pulled image
        run: docker image inspect alpine:3.20 || echo "image absent"

The unique marker should be absent on a fresh machine. The other checks are illustrative: the base image may already contain TypeScript or container images, so their presence alone does not establish state leakage.

Two details in that file are worth reading carefully. The needs: first edge orders the two jobs; it does not connect their filesystems. And job second has no actions/checkout step, so it has no source code either: the repository that job first cloned went away with job first's disk.

Changing the label changes which fleet claims the job while the shape of the workflow stays the same:

jobs:
  first:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4
      - run: echo "written by job first" > "$HOME/marker.txt"

  second:
    needs: first
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - run: test ! -f "$HOME/marker.txt" && echo "clean machine"

Label shapes like warp-ubuntu-latest-x64-4x encode an operating system, an architecture, and a machine size, and GitHub treats the whole string as one opaque routing key. When the fleet behind that label registers ephemeral runners, the marker check behaves exactly as it does above, because the lifecycle rule lives with the runner rather than with the workflow file. The cloud runners documentation describes the machine shapes behind each label, and the runner security documentation describes the isolation model.

When you wanted the state to survive

A clean machine on every job is the point of the term, and the cost of it is that setup work repeats. Four mechanisms move state across the boundary on purpose:

  • Artifacts carry files that a later job needs, uploaded by the producing job and downloaded by the consumer.
  • Caches carry directories that a later job would otherwise rebuild, keyed by a lockfile hash so a changed dependency set misses cleanly.
  • Prebuilt images move setup out of the job entirely by baking the toolchain into the machine image the runner boots from.
  • Saved disk state boots a new machine from an image captured during an earlier run, so the packages and pulled containers from that run are present at step one. Snapshot runners work this way, and the job stays ephemeral because a fresh machine is still allocated and still destroyed.

The time an ephemeral job spends between machine creation and the first useful step is a separate topic, covered under cold start. Boot time, image pull time, and agent registration all land in that window, and it is the number to watch when a fleet moves from long-lived hosts to ephemeral ones.

WarpBuild relevance

WarpBuild documents ephemeral VM allocation and destruction for its cloud runners.

FAQ

Is an ephemeral runner the same thing as a self-hosted runner?

No. Self-hosted describes who supplies the machine, and ephemeral describes how long the machine lives. A self-hosted runner can be ephemeral or long-lived depending on how the agent was configured, and GitHub-hosted runners are ephemeral by construction because each job gets a fresh machine that is discarded when the job ends.

How do I make a self-hosted runner ephemeral?

Configure the runner agent with the --ephemeral flag, so the agent accepts one job and then deregisters and exits. The second half is up to whatever created the machine: a controller or provisioning script has to delete the virtual machine or container after the agent exits, otherwise the disk survives for the next agent that starts on that host.

Do ephemeral runners lose my dependency cache?

The machine's disk goes away with the machine, so anything warm has to be restored on purpose. The usual mechanisms are actions/cache for dependency directories, artifacts for files a later job needs, a prebuilt image that already contains the toolchain, or a runner that boots from saved disk state captured on an earlier run.

Why does every job repeat the same setup steps on ephemeral runners?

Because every job starts from the same base image with an empty workspace. A workflow of five jobs runs its package install, its checkout, and its container pulls five times unless caching, a prebuilt image, or restored disk state shortens the start of each job.

Sources

Related FAQ: How do I make a self-hosted runner ephemeral?

Definition reviewed .

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.

← Return to glossary