Set Up an Ephemeral GitHub Actions Self-Hosted Runner on a Linux VPS

Updated on Oct 19, 2026
Mila H
12 MINS READ
Table of Contents
Ephemeral GitHub Actions Runner

Most teams start with a self-hosted runner that stays online all day. It is easy, but every job leaves files, caches, and tokens behind. The next job can read them. In this guide, you will learn to build an ephemeral GitHub Actions self-hosted runner on a Linux VPS. Each runner takes one job, then it is deleted, and a clean one takes its place.

If you do not use GitHub and want a full self-hosted Git server with CI instead, check our Gitea self-hosted CI/CD with Docker Compose guide.

Why Use an Ephemeral GitHub Actions Self-Hosted Runner

GitHub says autoscaling should use ephemeral runners, not persistent ones. An ephemeral runner gets only one job, and GitHub removes it after that job. This limits what a bad job can leave behind and stops a hacked runner from getting new jobs. 

Also, GitHub docs say you should clean the machine after the runner is removed.

Only the one-job mode gives you a real guarantee. If you just delete a normal runner after each job, GitHub cannot promise that the runner only ran one job. A JIT runner is different. It does at most one job and is removed automatically. We use JIT runners in this guide.

What You Will Build

You will learn to build a small system on one Ubuntu 24.04 VPS or Ubuntu 26.04. It has four parts:

  • A Docker image that holds the official GitHub runner, running as a normal user.
  • A short script that asks GitHub for a one-job runner (a "just-in-time" or JIT runner) and starts it in a new container.
  • A systemd service that runs this script all the time, so a new runner starts after each job.
  • Log forwarding and cleanup, so you keep the logs, and no old data stays on the server.

This is one ephemeral GitHub Actions self-hosted runner for each job, with a new container every time.

Prerequisites

Before you set up an ephemeral GitHub Actions self-hosted runner, check this list. You need:

  • A Linux VPS with Ubuntu 24.04 or 26.04 LTS, 2 vCPU and 4 GB RAM or more, and root or sudo access. A PerLod Linux VPS works well, because you can pick the CPU and RAM that match your build size.
  • A GitHub repository where you are an admin.
  • Outbound HTTPS (port 443) to GitHub. The runner does not need any open inbound port.
  • An x64 server. For ARM64, change x64 to arm64 in the build script below.

Note: Use this setup only for private repositories you trust. Self-hosted runners should never be used with public repositories, because anyone can open a pull request and run code on your runner.

Step 1: Update the Server and Set the Firewall

The first step is to log in, update the system, and install the required tools:

Bash
sudo apt update && sudo apt upgrade -ysudo apt install ufw curl jq git -y

Allow SSH, then turn on the firewall with the commands below:

Bash
sudo ufw allow OpenSSHsudo ufw enablesudo ufw status

We do not publish any container port, so Docker will not open anything to the internet.

Step 2: Install Docker Engine

You must remove old or unofficial Docker packages if you have it on your server:

Bash
sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc | cut -f1)

Then, use the commands below to add Docker's official repository:

Bash
sudo apt updatesudo apt install ca-certificates curl -ysudo install -m 0755 -d /etc/apt/keyringssudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.ascsudo chmod a+r /etc/apt/keyrings/docker.asc sudo tee /etc/apt/sources.list.d/docker.sources <<EOFTypes: debURIs: https://download.docker.com/linux/ubuntuSuites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")Components: stableArchitectures: $(dpkg --print-architecture)Signed-By: /etc/apt/keyrings/docker.ascEOF sudo apt update

Once you are done, install Docker and test it with the commands below:

Bash
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -ysudo systemctl status docker --no-pagersudo docker run --rm hello-world

We run the loop script as root, so you do not need to add any user to the docker group. Do not install Docker with snap, because the runner can fail with it.

Step 3: Create a GitHub Token

The script needs a token to ask GitHub for JIT runners. Create a fine-grained personal access token by following:

  1. Open GitHub, then go to Settings, Developer settings, Personal access tokens, Fine-grained tokens.
  2. Click Generate new token.
  3. Under Repository access, choose Only select repositories and pick your repo.
  4. Under Repository permissions, set Administration to Read and write.
  5. Set a short expiry, such as 90 days, and copy the token.

This token only lives on the VPS. It never goes into the job container.

Step 4: Build the Runner Image

First, you must create the folders to build the runner image:

Bash
sudo mkdir -p /opt/gh-runner/image /etc/gh-runner

Then, create the entrypoint file. It starts the runner with the JIT config and removes the value from the environment:

Bash
sudo tee /opt/gh-runner/image/entrypoint.sh <<'EOF'#!/usr/bin/env bashset -euo pipefail: "${JIT_CONFIG:?JIT_CONFIG is missing}"cfg="$JIT_CONFIG"unset JIT_CONFIGexec ./run.sh --jitconfig "$cfg"EOF

Next, you must create the Dockerfile. Use the command below to create it:

Bash
sudo tee /opt/gh-runner/image/Dockerfile <<'EOF'FROM ubuntu:24.04 ARG RUNNER_VERSIONARG RUNNER_SHA256ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl git jq tar gzip unzip \ && rm -rf /var/lib/apt/lists/* \ && useradd -m -s /bin/bash runner WORKDIR /home/runner/actions-runner RUN curl -fsSL -o runner.tar.gz \      "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz" \ && echo "${RUNNER_SHA256}  runner.tar.gz" | sha256sum -c - \ && tar xzf runner.tar.gz \ && rm runner.tar.gz \ && ./bin/installdependencies.sh \ && rm -rf /var/lib/apt/lists/* COPY entrypoint.sh /home/runner/entrypoint.shRUN chmod +x /home/runner/entrypoint.sh \ && chown -R runner:runner /home/runner USER runnerENTRYPOINT ["/home/runner/entrypoint.sh"]EOF

Now you must create the build script with the commands below. It finds the latest runner release and checks the file hash before it builds:

Bash
sudo tee /opt/gh-runner/build-image.sh <<'EOF'#!/usr/bin/env bashset -euo pipefailcd /opt/gh-runner/image REL=$(curl -fsSL https://api.github.com/repos/actions/runner/releases/latest)VER=$(jq -r '.tag_name' <<<"$REL" | sed 's/^v//')SHA=$(jq -r --arg n "actions-runner-linux-x64-${VER}.tar.gz" \  '.assets[] | select(.name==$n) | .digest' <<<"$REL" | sed 's/^sha256://') echo "Building runner image with runner version ${VER}"docker build \  --build-arg RUNNER_VERSION="$VER" \  --build-arg RUNNER_SHA256="$SHA" \  -t gh-runner:latest .docker image prune -fEOF sudo chmod +x /opt/gh-runner/build-image.sh /opt/gh-runner/image/entrypoint.shsudo /opt/gh-runner/build-image.sh

Check that the image exists with the command below:

Bash
sudo docker images gh-runner

Step 5: Add the Settings File

At this point, you must create the settings file and lock it down, because it holds your token. For this, use the commands below:

Bash
sudo tee /etc/gh-runner/runner.env <<'EOF'GH_OWNER=your-user-or-orgGH_REPO=your-repoGH_TOKEN=github_pat_replace_meRUNNER_LABELS=self-hosted,Linux,X64,ephemeralRUNNER_IMAGE=gh-runner:latestJOB_CPUS=2JOB_MEMORY=4gLOG_HOST=EOF sudo chown root:root /etc/gh-runner/runner.envsudo chmod 600 /etc/gh-runner/runner.envsudo nano /etc/gh-runner/runner.env

In nano, set GH_OWNER, GH_REPO, and GH_TOKEN to your real values. Save with Ctrl+O, press Enter, and exit with Ctrl+X. Leave LOG_HOST empty for now.

Step 6: Create the One-Job Loop Script

This script is the core of the ephemeral GitHub Actions self-hosted runner setup. For each round, it asks GitHub for a JIT runner, starts a fresh container, waits for the job to end, deletes the runner entry, and starts again.

To create the file, use the command below and set its correct permissions:

Bash
sudo tee /opt/gh-runner/run-loop.sh <<'EOF'#!/usr/bin/env bashset -uo pipefail SLOT="${1:-1}"API="https://api.github.com/repos/${GH_OWNER}/${GH_REPO}/actions/runners"HDR=(-H "Accept: application/vnd.github+json"     -H "Authorization: Bearer ${GH_TOKEN}"     -H "X-GitHub-Api-Version: 2026-03-10") if [ -n "${LOG_HOST:-}" ]; then  LOG_ARGS=(--log-driver syslog            --log-opt "syslog-address=tcp://${LOG_HOST}:514"            --log-opt "tag=gh-runner")else  LOG_ARGS=(--log-driver journald --log-opt "tag=gh-runner")fi while true; do  NAME="$(hostname -s)-${SLOT}-$(date +%s)"  LABELS_JSON=$(jq -cn --arg l "$RUNNER_LABELS" '$l | split(",")')  BODY=$(jq -cn --arg n "$NAME" --argjson l "$LABELS_JSON" \    '{name:$n, runner_group_id:1, labels:$l, work_folder:"_work"}')   RESP=$(curl -fsS -X POST "${HDR[@]}" "${API}/generate-jitconfig" -d "$BODY") || {    echo "Could not get a JIT config. Retry in 30 seconds."    sleep 30    continue  }   RID=$(jq -r '.runner.id' <<<"$RESP")  JIT=$(jq -r '.encoded_jit_config' <<<"$RESP")   echo "Starting runner $NAME (id $RID)"  docker run --rm --init --name "$NAME" \    --cpus "${JOB_CPUS:-2}" --memory "${JOB_MEMORY:-4g}" --pids-limit 1024 \    --cap-drop ALL --security-opt no-new-privileges \    --tmpfs /tmp:rw,size=1g \    "${LOG_ARGS[@]}" \    -e JIT_CONFIG="$JIT" \    "${RUNNER_IMAGE:-gh-runner:latest}"   echo "Runner $NAME finished. Removing it from GitHub."  curl -sS -X DELETE "${HDR[@]}" "${API}/${RID}" >/dev/null || true  sleep 2doneEOF sudo chmod 700 /opt/gh-runner/run-loop.sh

Note: Jobs in this setup cannot run docker build or use service containers, because the runner container has no Docker access on purpose. If you need Docker inside jobs, you can use a separate VPS for those jobs.

Step 7: Run the Runner Loop as a systemd Service

Now you can create the service file with the command below. The @ lets you start more than one runner slot later:

Bash
sudo tee /etc/systemd/system/gh-runner@.service <<'EOF'[Unit]Description=One-job GitHub runner loop (slot %i)After=network-online.target docker.serviceWants=network-online.targetRequires=docker.service [Service]Type=simpleEnvironmentFile=/etc/gh-runner/runner.envExecStart=/opt/gh-runner/run-loop.sh %iRestart=alwaysRestartSec=5 [Install]WantedBy=multi-user.targetEOF

Start slot 1, make it start on boot, and watch the loop:

Bash
sudo systemctl daemon-reloadsudo systemctl enable --now gh-runner@1sudo systemctl status gh-runner@1 --no-pagersudo journalctl -u gh-runner@1 -f

In GitHub, open your repo, then Settings, Actions, Runners. You should see one runner with the status Idle. To run two jobs at the same time, start another slot with sudo systemctl enable --now gh-runner@2.

Step 8: Send Jobs to the Runner with runs-on

Labels tell GitHub which jobs can use your ephemeral GitHub Actions self-hosted runner. The runner has the labels self-hosted, Linux, X64, and ephemeral. A workflow picks it with runs-on. If no runner matches, the job waits in the queue and fails after 24 hours.

You can create a test workflow in your repo. Run this from your local copy of the repo:

Bash
mkdir -p .github/workflowsnano .github/workflows/ephemeral-test.yml

Paste this content, then commit and push it to the default branch:

Bash
name: ephemeral-teston: workflow_dispatch jobs:  test:    runs-on: [self-hosted, linux, ephemeral]    steps:      - uses: actions/checkout@v6      - name: Show the runner and check for old files        run: |          hostname          whoami          ls -la ~/leftover.txt || echo "Clean runner: no old file found"          echo "dirty" > ~/leftover.txt

Note: Use a custom label like ephemeral for these runners only. This keeps normal jobs from landing on them by mistake.

Step 9: Protect Tokens and Secrets on Runner

A short-lived runner limits the damage if something goes wrong, but it does not protect your secrets by itself. A leaked token can still be used after the runner is gone. In this step, you can lock down the GitHub token on your VPS and learn simple rules for the secrets your jobs use.

  • Keep the GitHub token only in /etc/gh-runner/runner.env with chmod 600. Never put it in the image or in a workflow.
  • Store job secrets in GitHub Secrets, not in the workflow file, and give each secret the lowest access it needs.
  • Never pass secrets as command-line arguments. Another process can read them with ps.
  • Set the default GITHUB_TOKEN to read-only in your repo settings, and raise it only for jobs that need more.
  • Use OIDC for cloud logins when you can, so you do not store long-term keys.

For a full view of pipeline security, you can read our DevSecOps CI/CD integration guide.

Step 10: Forward the Runner Logs

Because the container is deleted after each job, its logs are lost with it. Logs from an ephemeral GitHub Actions self-hosted runner must be sent to an outside place. The script already does this in two ways.

By default, logs go to the host journal with the tag gh-runner. You can read them with:

Bash
sudo journalctl CONTAINER_TAG=gh-runner -n 100 --no-pager

To send logs to a remote server, set LOG_HOST to the address of a server that accepts TCP syslog on port 514 (rsyslog, Graylog, or similar):

Bash
sudo nano /etc/gh-runner/runner.env

Change the line to the real address, for example:

Bash
LOG_HOST=10.0.0.20

Once you are done, save and close the file. Restart the service:

Bash
sudo systemctl restart gh-runner@1

Note: Make sure the remote server is up. If the Docker syslog driver cannot reach it, the container will not start.

Step 11: Clean Up and Keep the Runner Updated

The --rm flag already removes each container. You still need to update the runner image. GitHub stops sending jobs to a runner that is too old, and the runner updates itself at job start if it can. A fresh image avoids that delay.

You can rebuild the image every week with a cron job:

Bash
sudo tee /etc/cron.d/gh-runner-rebuild <<'EOF'0 3 * * 0 root /opt/gh-runner/build-image.sh >> /var/log/gh-runner-build.log 2>&1EOF

The next container uses the new image. Old image layers are removed by docker image prune -f inside the build script. Keep the VPS itself up to date with sudo apt update && sudo apt upgrade -y.

Step 12: Test That Each Job Gets a Clean Runner

Now it is time to check that everything works. You can run the test workflow twice and look at the results. If the second run shows no old file, then each job gets a clean runner. Also, you can check the container name and the runner list on GitHub.

  • In GitHub, open Actions, pick ephemeral-test, and click Run workflow.
  • Watch the log. The first run should print Clean runner: no old file found.
  • Run the workflow a second time. It should print the same clean message again. This proves the old file is gone.
  • On the VPS, check that the container name changed:
Bash
sudo docker ps --format '{{.Names}}  {{.Status}}'
  • Check the runner list through the API. Replace the owner and repo, and use your token:
Bash
curl -s -H "Authorization: Bearer YOUR_TOKEN" \  -H "Accept: application/vnd.github+json" \  https://api.github.com/repos/OWNER/REPO/actions/runners | jq '.runners[] | {name, status, ephemeral}'

You should see only the current idle runner, and each new job gets a new name. Your ephemeral GitHub Actions self-hosted runner is now working.

Troubleshooting Common Runner Errors

Sometimes the runner does not start, or a job stays in the queue. Most problems come from a wrong label, a bad token, or a log server that can't be reached. Use the checks below to find the cause quickly:

  • Job stays queued: the labels in runs-on do not match. Check RUNNER_LABELS and the workflow.
  • JIT request fails with 403 or 404: the token lacks Administration: Read and write on the repo, or it expired.
  • Container fails at start: run sudo journalctl -u gh-runner@1 -n 50 and read the error. If LOG_HOST is set, check that the log server is reachable.
  • Network check: run a runner test from the image with ./config.sh --check, using your repo URL and a token, to see if GitHub domains are reachable.

Conclusion

At this point, you have a clean ephemeral GitHub Actions self-hosted runner on your own VPS. Each job runs in a new container with limited power, the runner is deleted from GitHub when the job ends, and a new one starts at once. Logs are kept outside the container, and the image stays fresh with a weekly rebuild.

Ready to try it? Use PerLod Linux VPS instances as isolated CI workers, with custom CPU and RAM and private-service access.

Ephemeral is a runner that takes one job and is then removed.

No. Both run one job. A JIT runner uses a config from the API, with no registration token or config.sh step.

The job waits up to 24 hours, then fails. Check the service with systemctl status gh-runner@1.