Ephemeral runners are created on demand and destroyed after every job. That pattern removes any risk of data persisting between jobs and guarantees a clean environment for every run.
What you will learn
- Understand why a disposable runner is safer than a persistent one
- Enable the
--ephemeralmode of the GitHub runner - Set up auto-scaling with ARC (Kubernetes) or cloud VMs
- Containerise an ephemeral runner with Docker
- Manage the pool and compensate for the missing local cache
Why ephemeral runners?
The problems of persistent runners
These three problems do not carry the same weight, and the third one contains the
other two. A persistent runner keeps its disk and its memory from one job to the
next: a malicious job can therefore drop a binary on it, alter the PATH of the
service user or install a Git hook that will run inside the job of the
neighbouring project. Lateral movement between teams sharing the same pool is
the real scenario behind this pattern.
- Leftover data: files, caches, credentials from a previous job
- Configuration drift: the environment changes over time
- Attack surface: a malicious job can compromise the following ones
The benefits of ephemeral runners
The gain comes down to one property: state does not survive the job. That closes the whole class of attacks described above in one go, but it also deprives you of the local cache and of the tools you may have accumulated by hand on your runners. So it is not a free improvement: it moves the work into the image or into a remote cache.
- Perfect isolation: just like GitHub-hosted runners
- Reproducibility: the same environment on every run
- Security: no persistence between jobs
The --ephemeral option
The GitHub runner natively supports the ephemeral mode:
./config.sh --url https://github.com/OWNER/REPO \ --token TOKEN \ --ephemeralBehaviour:
- The runner accepts a single job
- Once it is done, it deregisters itself automatically
- The VM or container can be destroyed
Architecture with auto-scaling
Creating a runner by hand for every job is not sustainable. Auto-scaling provisions and destroys runners automatically according to the load.
With Actions Runner Controller (ARC)
Mind one confusing point of vocabulary: ARC comes in two modes, and only one
of them is supported by GitHub. The current mode relies on runner scale sets,
installed by the gha-runner-scale-set-controller and gha-runner-scale-set
Helm charts of the actions/actions-runner-controller repository; their runners
are ephemeral by design, there is nothing to enable. The historical mode, the
one with the RunnerDeployment and HorizontalRunnerAutoscaler resources in
actions.summerwind.dev, is now labelled legacy by the project and is only
maintained by the community.
Installing the supported mode, with a runner set named arc-runner-set:
helm install arc \ --namespace arc-systems --create-namespace \ --version 0.14.2 \ oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
helm install arc-runner-set \ --namespace arc-runners --create-namespace \ --version 0.14.2 \ --set githubConfigUrl="https://github.com/OWNER/REPO" \ --set githubConfigSecret.github_token="$GITHUB_TOKEN" \ --set minRunners=0 --set maxRunners=10 \ oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-setThe name of the runner set becomes the value of runs-on in your workflows,
directly. Check the deployment with kubectl get pods -n arc-systems, then
kubectl get autoscalingrunnerset -n arc-runners.
The manifest below illustrates the legacy mode, which you will still meet on
existing clusters. Do not deploy it on a new installation: it is kept here so
that you can recognise it, and the ephemeral: true field is mandatory there
since that mode does not enable it on its own.
# runner-deployment.yaml (legacy mode, community-maintained)apiVersion: actions.summerwind.dev/v1alpha1kind: RunnerDeploymentmetadata: name: ephemeral-runnersspec: replicas: 1 template: spec: ephemeral: true repository: owner/repo labels: - self-hosted - linux - ephemeral---apiVersion: actions.summerwind.dev/v1alpha1kind: HorizontalRunnerAutoscalermetadata: name: ephemeral-runners-autoscalerspec: scaleTargetRef: name: ephemeral-runners minReplicas: 0 maxReplicas: 10 metrics: - type: TotalNumberOfQueuedAndInProgressWorkflowRuns repositoryNames: - owner/repoWith cloud VMs
Outside Kubernetes the principle stays the same, but you carry the lifecycle yourself: a VM boots, registers, runs one job, then deletes itself. The two scripts below illustrate those two halves. The sensitive part is not the scaling but the registration token: it is short-lived (one hour) and obtained through the API, which avoids freezing a secret into the image.
Scaling script with AWS:
#!/bin/bash# Create a VMINSTANCE_ID=$(aws ec2 run-instances \ --image-id ami-xxxxx \ --instance-type t3.medium \ --user-data file://runner-init.sh \ --query 'Instances[0].InstanceId' \ --output text)
echo "Launched: $INSTANCE_ID"Initialisation script:
#!/bin/bash# runner-init.sh (user-data)
set -euo pipefail
# Download the runner and check its fingerprint before extracting.# The SHA-256 sum is published by the project in the release notes.RUNNER_VERSION=2.337.0RUNNER_SHA256=70920811a4f8ad4328818682bca5c6469c1c942fab52448868071d0063816613
cd /optcurl -fsSL -o actions-runner.tar.gz \ "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"echo "${RUNNER_SHA256} actions-runner.tar.gz" | sha256sum --check -tar xzf actions-runner.tar.gz
# Get a registration token (through the GitHub API)TOKEN=$(curl -fsS -X POST \ -H "Authorization: Bearer $GITHUB_PAT" \ -H "Accept: application/vnd.github+json" \ https://api.github.com/repos/OWNER/REPO/actions/runners/registration-token \ | jq -r '.token')
# Configure in ephemeral mode./config.sh --url https://github.com/OWNER/REPO \ --token $TOKEN \ --ephemeral \ --unattended \ --labels ephemeral,linux
# Run (will stop after one job)./run.sh
# Self-destruction: IMDSv2 requires a token first, then the identifierTOKEN=$(curl -sX PUT "http://169.254.169.254/latest/api/token" \ -H "X-aws-ec2-metadata-token-ttl-seconds: 60")INSTANCE_ID=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \ http://169.254.169.254/latest/meta-data/instance-id)aws ec2 terminate-instances --instance-ids "$INSTANCE_ID"With Docker
Without Kubernetes, Docker is enough to get disposable runners: one container per job, destroyed once the run is over.
A runner inside a container
The image below embeds the runner binary but no secret: the token arrives at
runtime through an environment variable, otherwise it would stay readable in an
image layer for anyone able to pull it. Note the base image pinned by digest:
a tag such as 24.04 is mutable and the registry may republish something else
behind it, which would ruin the reproducibility you are after with disposable
runners.
# Base image pinned by digest: a tag such as 24.04 is mutable.# Read the current digest with `docker manifest inspect ubuntu:24.04`.FROM ubuntu:24.04@sha256:224a1869083a311ef3f13648a154ba79832fbef6364d31493642ca03082da254
# Without pipefail, a pipe only returns the exit code of its last link: a# failed download would look like a success.SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN apt-get update && apt-get install -y --no-install-recommends \ curl jq git \ && rm -rf /var/lib/apt/lists/*
# Download the runner, check its fingerprint, then extract.# The SHA-256 sum is published in the actions/runner release notes.ARG RUNNER_VERSION=2.337.0ARG RUNNER_SHA256=70920811a4f8ad4328818682bca5c6469c1c942fab52448868071d0063816613
RUN set -eux; \ curl -fsSL -o /tmp/runner.tar.gz \ "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"; \ echo "${RUNNER_SHA256} /tmp/runner.tar.gz" | sha256sum --check -; \ mkdir /runner; \ tar xzf /tmp/runner.tar.gz -C /runner; \ rm /tmp/runner.tar.gz
WORKDIR /runner
COPY entrypoint.sh /entrypoint.shENTRYPOINT ["/entrypoint.sh"]#!/bin/bash./config.sh --url $REPO_URL \ --token $RUNNER_TOKEN \ --ephemeral \ --unattended \ --labels docker,ephemeral
./run.sh# The container stops after one jobOrchestration with docker-compose
This file starts several containers at once on the same machine. The line that
matters is restart: "no": without it, Docker would restart the container as
soon as it stops, and you would get a runner registering itself in a loop, the
exact opposite of the behaviour you want. Each container must receive its own
registration token, since a token is only valid for one registration.
services: runner: build: context: . dockerfile: Dockerfile.runner environment: - REPO_URL=https://github.com/owner/repo - RUNNER_TOKEN=${RUNNER_TOKEN} restart: "no" # Do not restart (ephemeral) deploy: replicas: 3Managing the runner pool
A pool of ephemeral runners has to be steered: finding the balance between responsiveness (avoiding cold starts) and cost (not keeping too many idle runners).
Warm-up strategy
To avoid cold starts, keep a minimum pool:
# Kubernetes HPAspec: minReplicas: 2 # Always 2 runners ready maxReplicas: 20 # Scale up to 20 if neededMonitoring the pool
Without supervision, an ephemeral pool fails silently: jobs pile up in the queue and nobody notices before a developer complains. The metric to watch first is not the number of active runners but the queue wait, because that is what tells you the scaling ceiling has been reached or that registration is failing.
# Prometheus metrics with ARC- job_name: 'actions-runner-controller' static_configs: - targets: ['actions-runner-controller-metrics:8080']Useful metrics:
actions_runner_controller_running_runners: active runnersactions_runner_controller_pending_runners: waiting for a jobactions_runner_controller_registered_runners: total registered
A workflow using ephemeral runners
On the workflow side, almost nothing changes: you target your runners through
their labels in runs-on. The detail that matters is the split into two
jobs, build then deploy: each one gets a fresh runner, so the deployment job
cannot inherit any file or any variable left behind by the build. That is the
security benefit you are after, and it is also why every artifact to hand over
between the two must go explicitly through actions/upload-artifact.
name: Build with Ephemeral Runner
on: push: branches: [main]
# No rights by default: every job asks for the minimumpermissions: {}
# Cancels the previous run still in flight on the same branch:# no point tying up runners for a commit that has already been replaced.concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true
jobs: build: name: Build the application runs-on: [self-hosted, ephemeral, linux] timeout-minutes: 15 permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false
- name: Build run: | npm ci npm run build
# No cleanup needed: the runner will be destroyed
deploy: name: Deploy to production needs: build runs-on: [self-hosted, ephemeral, linux] timeout-minutes: 10 permissions: contents: read # A fresh runner for the deployment steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - run: ./deploy.shThings to anticipate
The ephemeral model has a price: no local cache, cold starts, a variable infrastructure cost. Here are the points to plan for.
Start-up time
These orders of magnitude are not measurements of your own platform: treat them as a ranking, not as target values. What matters is the ratio between the rows, and it explains the default choice: the container wins as long as the job does not require kernel-level isolation, in which case the VM becomes necessary again despite starting five to ten times slower.
| Method | Cold start |
|---|---|
| Docker container | 5-15s |
| Cloud VM | 30-90s |
| Kubernetes pod | 10-30s |
Cache and dependencies
With ephemeral runners, the local cache does not exist. Options:
- actions/cache: a cache on GitHub Storage
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.npm key: npm-${{ hashFiles('package-lock.json') }}- A cache registry: Docker images with the dependencies pre-installed
container: # Pinned by digest like any other image: a mutable tag would ruin the # reproducibility you are after with disposable runners. Read the current # digest with `docker buildx imagetools inspect <image>:<tag>`. image: registry.example.com/node-with-deps:22.11.0@sha256:YOUR_DIGEST- A shared volume: NFS or EFS mounted on the runners
Cost
The cost of an ephemeral pool depends above all on your provider's billing granularity. Billed by the second, destroying a VM after every job costs about the same as a well-sized permanent pool. Billed by the started hour, every three-minute job costs you an hour, and the arithmetic flips entirely.
- More VMs created and destroyed means more cost if billing is hourly
- Optimise with spot or preemptible instances
- Balance the minimum pool against the cost
Key points
- An ephemeral runner accepts a single job then deregisters: the isolation of hosted runners, on your own infrastructure.
- The native mode is enabled with
./config.sh --ephemeral: no third-party tool required. - On Kubernetes, Actions Runner Controller handles creation, scaling and destruction automatically.
- Without a local cache, compensate with
actions/cache, pre-filled images or a shared volume. - Keep a minimum pool warm to absorb cold starts without blowing up the cost.
Next steps
- Runner maintenance: supervising, sizing and updating a disposable fleet without manual intervention.
- GitHub CLI (gh): registering and removing a runner through the API from your provisioning scripts.