Skip to content
Français
CI/CD & Automatisation medium

Ephemeral GitHub Actions runners

40 min de lecture

Read this page in French

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 --ephemeral mode 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.

  1. Leftover data: files, caches, credentials from a previous job
  2. Configuration drift: the environment changes over time
  3. 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.

  1. Perfect isolation: just like GitHub-hosted runners
  2. Reproducibility: the same environment on every run
  3. Security: no persistence between jobs

The --ephemeral option

The GitHub runner natively supports the ephemeral mode:

Fenêtre de terminal
./config.sh --url https://github.com/OWNER/REPO \
--token TOKEN \
--ephemeral

Behaviour:

  • 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:

Fenêtre de terminal
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-set

The 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/v1alpha1
kind: RunnerDeployment
metadata:
name: ephemeral-runners
spec:
replicas: 1
template:
spec:
ephemeral: true
repository: owner/repo
labels:
- self-hosted
- linux
- ephemeral
---
apiVersion: actions.summerwind.dev/v1alpha1
kind: HorizontalRunnerAutoscaler
metadata:
name: ephemeral-runners-autoscaler
spec:
scaleTargetRef:
name: ephemeral-runners
minReplicas: 0
maxReplicas: 10
metrics:
- type: TotalNumberOfQueuedAndInProgressWorkflowRuns
repositoryNames:
- owner/repo

With 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:

launch-ephemeral-runner.sh
#!/bin/bash
# Create a VM
INSTANCE_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.0
RUNNER_SHA256=70920811a4f8ad4328818682bca5c6469c1c942fab52448868071d0063816613
cd /opt
curl -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 identifier
TOKEN=$(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.

Dockerfile.runner
# 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.0
ARG 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.sh
ENTRYPOINT ["/entrypoint.sh"]
entrypoint.sh
#!/bin/bash
./config.sh --url $REPO_URL \
--token $RUNNER_TOKEN \
--ephemeral \
--unattended \
--labels docker,ephemeral
./run.sh
# The container stops after one job

Orchestration 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.

docker-compose.yml
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: 3

Managing 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 HPA
spec:
minReplicas: 2 # Always 2 runners ready
maxReplicas: 20 # Scale up to 20 if needed

Monitoring 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 runners
  • actions_runner_controller_pending_runners: waiting for a job
  • actions_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 minimum
permissions: {}
# 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.sh

Things 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.

MethodCold start
Docker container5-15s
Cloud VM30-90s
Kubernetes pod10-30s

Cache and dependencies

With ephemeral runners, the local cache does not exist. Options:

  1. actions/cache: a cache on GitHub Storage
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
  1. 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
  1. 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.

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn