Ephemeral Runner

An ephemeral runner is a GitHub Actions runner that takes exactly one job and is destroyed afterwards, so no state from that job reaches the next job.

An ephemeral runner is a GitHub Actions runner that accepts exactly one job and is destroyed once that job finishes, so no files, caches, background processes, or credentials from that job carry into the job that runs next. The machine exists for a single unit of work, and its disk is discarded along with it.

The word describes a lifecycle rule rather than a hardware choice. An ephemeral runner can be a virtual machine, a container, or a physical host, on Linux, macOS, or Windows, supplied by you or by a provider, and it stays ephemeral for as long as the one-job rule holds.

Definition

Two properties define the term, and both have to hold.

  1. One job per runner. The runner agent registers with GitHub, advertises its labels, claims a single job, runs that job's steps, reports the conclusion, then deregisters.
  2. Disposal after the job. The compute and the storage the job used are thrown away instead of being handed to the job that comes next.

A runner that satisfies only the first property is a weaker form. If the agent deregisters cleanly but the host it ran on stays up and a fresh agent starts in the same working directory, the next job still finds last job's files. The guarantee people want from the word comes from the second property, because that is the one that removes the disk.

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

A long-lived runner is the other lifecycle: 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
IsolationOne job per machine, so a compromised job cannot read the next job's checkoutJobs share a filesystem, a package cache, and often a 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 follows from the same rule. Two jobs that never share a machine cannot read each other's source, secrets exported to files, or credential helpers left configured by a login step. This is why untrusted contributions from forks and jobs handling production credentials are usually pinned to ephemeral fleets.

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 runners are ephemeral by construction. Each job is placed on a freshly provisioned machine from GitHub's pools and that machine is discarded when the job ends, which is why a hosted job never finds a previous run's files.

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"

Job second passes. The marker file is absent, tsc is absent, and the alpine:3.20 image is absent from the local image store, because job second runs on a machine that was created after job first finished and has never seen job first.

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.

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.

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.