GitHub Self-Hosted Action Runner
Docker Container as Host for Runner
Composer File
Repository repo Version
Runner configuration for just a single repository when no GitHub organization is available.
services:
runner:
image: myoung34/github-runner:latest
privileged: true
# Give the container a name as the runner.
container_name: ${GITHUB_RUNNER_NAME}
# Use the network stack of the host (DNS, proxy, VPN routes) instead of a compose bridge network.
# Only affects the runner container: job containers ('container:') are started by the host Docker daemon
# on a network the runner creates per job.
network_mode: host
restart: unless-stopped
environment:
# Place sensitive values in a .env file next to this compose file.
# Use RUNNER_TOKEN (manually generated from GitHub UI) instead of ACCESS_TOKEN.
# ACCESS_TOKEN requires org owner permissions, which often causes 403 errors.
RUNNER_TOKEN: ${GITHUB_RUNNER_TOKEN}
# --- Choose ONE scope below: org OR repo ---
# For organization-level runners (needs a GitHub organization):
#RUNNER_SCOPE: org
#ORG_NAME: Scanframe
# For repository-level runners:
RUNNER_SCOPE: repo
REPO_URL: ${GITHUB_REPO_URL}
RUNNER_NAME: ${GITHUB_RUNNER_NAME}
RUNNER_WORKDIR: /tmp/runner/work
# Custom label used in workflows (runs-on: [sf-build]).
# Default labels (self-hosted, linux, x64) are added automatically.
LABELS: sf-build
# Ephemeral mode: automatically unregister and stop after each job.
EPHEMERAL: "false"
# Unset all config env vars after registration to prevent leaking secrets.
UNSET_CONFIG_VARS: "true"
# Disable auto-updates so the runner image version stays in control.
DISABLE_AUTO_UPDATE: "true"
# Script run before every job to give it an empty workspace like a GitHub-hosted runner (see job-started.sh).
ACTIONS_RUNNER_HOOK_JOB_STARTED: /opt/runner-hooks/job-started.sh
# Run the runner as user 'runner' (uid 1001) like the GitHub-hosted runners, so the workspace is not owned by
# root. The build image entrypoint takes the build user uid:gid from the workspace owner and fails on 0:0.
# The image entrypoint aligns the 'docker' group gid with the mounted socket and chowns the work directory.
RUN_AS_ROOT: "false"
security_opt:
- label:disable
volumes:
# Docker socket for Docker-in-Docker support (required for --privileged jobs).
- /var/run/docker.sock:/var/run/docker.sock
# Persistent runner registration data (only needed without EPHEMERAL mode).
#- ./runner/data:/runner/data
# Work directory — must be the same path on host and in container.
- /tmp/runner:/tmp/runner
# Job-started hook script.
- ./job-started.sh:/opt/runner-hooks/job-started.sh:ro
# Runner externals (node20/node24) must exist on the HOST at the SAME path, because job containers
# are started by the host Docker daemon and mount them as /__e. See README 'Container jobs'.
- /actions-runner/externals:/actions-runner/externals
Organisation org Version
services:
runner:
image: myoung34/github-runner:latest
privileged: true
# Give the container a name as the runner.
container_name: ${GITHUB_RUNNER_NAME}
# Use the network stack of the host (DNS, proxy, VPN routes) instead of a compose bridge network.
# Only affects the runner container: job containers ('container:') are started by the host Docker daemon
# on a network the runner creates per job.
network_mode: host
restart: unless-stopped
environment:
# Place sensitive values in a .env file next to this compose file.
# Use RUNNER_TOKEN (manually generated from GitHub UI) instead of ACCESS_TOKEN.
# ACCESS_TOKEN requires org owner permissions, which often causes 403 errors.
#RUNNER_TOKEN: ${GH_RUNNER_TOKEN}
ACCESS_TOKEN: ${GITHUB_PAT}
# For organization-level runners (needs a GitHub organization):
RUNNER_SCOPE: org
ORG_NAME: ${GITHUB_ORG}
RUNNER_NAME: ${GITHUB_RUNNER_NAME}
RUNNER_WORKDIR: /tmp/runner/work
# Custom label used in workflows (runs-on: [sf-build]).
# Default labels (self-hosted, linux, x64) are added automatically.
LABELS: sf-build
# Ephemeral mode: automatically unregister and stop after each job.
EPHEMERAL: "false"
# Unset all config env vars after registration to prevent leaking secrets.
UNSET_CONFIG_VARS: "true"
# Disable auto-updates so the runner image version stays in control.
DISABLE_AUTO_UPDATE: "true"
# Script run before every job to give it an empty workspace like a GitHub-hosted runner (see job-started.sh).
ACTIONS_RUNNER_HOOK_JOB_STARTED: /opt/runner-hooks/job-started.sh
# Run the runner as user 'runner' (uid 1001) like the GitHub-hosted runners, so the workspace is not owned by
# root. The build image entrypoint takes the build user uid:gid from the workspace owner and fails on 0:0.
# The image entrypoint aligns the 'docker' group gid with the mounted socket and chowns the work directory.
RUN_AS_ROOT: "false"
security_opt:
- label:disable
volumes:
# Docker socket for Docker-in-Docker support (required for --privileged jobs).
- /var/run/docker.sock:/var/run/docker.sock
# Persistent runner registration data (only needed without EPHEMERAL mode).
#- ./runner/data:/runner/data
# Work directory — must be the same path on host and in container.
- /tmp/runner:/tmp/runner
# Job-started hook script.
- ./job-started.sh:/opt/runner-hooks/job-started.sh:ro
# Runner externals (node20/node24) must exist on the HOST at the SAME path, because job containers
# are started by the host Docker daemon and mount them as /__e. See README 'Container jobs'.
- /actions-runner/externals:/actions-runner/externals
Environement .env File
Create a Docker Composer .env file for keeping the secret tokens to pass them to the container.
# Runner registration token, manually generated from the GitHub UI.
# This runner is configured for a single repository (scanframe-org/sf-gitlab-devops).
#
# How to generate the token:
# 1. Go to: https://github.com/<your-organisation>/sf-gitlab-devops/settings/actions/runners/new
# (requires admin access to the repository)
# 2. Select Linux x64
# The page shows download/config.sh commands. Do NOT run them: nothing is installed on the host,
# the Docker image already contains the runner and runs config.sh by itself.
# 3. Only copy the token: the value after '--token' in the './config.sh --url ... --token XXXX' line
# 4. Paste it below
#
# Note: The token expires after 1 hour and can be used only once.
# With EPHEMERAL mode, the runner needs a new token on each container re-creation.
# For persistent runners, remove EPHEMERAL and mount /runner/data volume.
GITHUB_RUNNER_TOKEN=your-registration-token-here
GITHUB_RUNNER_NAME="runner-01"
GITHUB_REPO_URL="https://github.com/scanframe-org/sf-trial-devops"
# Organisation self-hosted runner using a PAT (Personal Access Token).
# 1. Go to https://github.com/settings/personal-access-tokens
# 2. Fill in the form:
# - Token name: e.g. 'build-runner'
# - Expiration: pick a date. Fine-grained tokens must expire (the maximum is 1 year, depending on org policy). Put
# a reminder in your calendar, because the runner will fail to restart after expiry.
# - Resource owner: select '<your-organisation>', not your personal account. If the org isn't in the dropdown, an
# owner has to allow fine-grained PATs first (see below).
# - Repository access: choose 'Public repositories' (the minimum). It doesn't matter for org runners, since the
# runner permission is org-level.
# 3 Paste it below.
GITHUB_ORG="scanframe-org"
GITHUB_PAT="<your-self-hosted-finegraind-pat-here>"
GITHUB_RUNNER_NAME="runner-01"
Registering the Runner
Creating the Token
- Click your profile picture (top right) → Settings.
- In the left sidebar, scroll to the bottom and click Developer settings.
- Click Personal access tokens → Fine-grained tokens.
- Click Generate new token.
- Fill in the form:
- Token name: e.g.
sf-build-runner - Expiration: pick a date. Fine-grained tokens must expire (the maximum is 1 year, depending on org policy). Put a reminder in your calendar, because the runner will fail to restart after expiry.
- Resource owner: select scanframe-org, not your personal account. If the org isn't in the dropdown, an owner has to allow fine-grained PATs first (see below).
- Repository access: choose Public repositories (the minimum). It doesn't matter for org runners, since the runner permission is org-level.
- Token name: e.g.
- Open Permissions → Organization permissions, find Self-hosted runners and set it to Read and write.
- Click Generate token and copy it immediately, because it's only shown once. It starts with
github_pat_.
If the org isn't selectable or the token is pending
An org owner needs to check these under Org Settings → Personal access tokens:
- Settings: under Fine-grained personal access tokens, allow access via fine-grained tokens. This is what makes the org appear as a resource owner.
- Pending requests: if the org requires approval, your token stays pending until an owner approves it there. Until then the API call returns a 403.
Registration Token Retrieval Script
This script allows for retrieving the runner registration token for a GitHub organization.
#!/usr/bin/env bash
# Bailout on first error.
set -e
# Get the script directory.
script_dir="$(cd "$(dirname "${0}")" && pwd)"
# Form the environment file.
env_file="${script_dir}/.env"
if [[ -z "$1" ]]; then
echo "Usage: $0 <organisation-name> <fine-grained-pat>
Requests a fresh runner registration token for the scanframe-org organisation.
The argument is a fine-grained PAT (github_pat_...) created by an org owner with
'Self-hosted runners: Read and write' under Organization permissions.
Use '--' as organisation name to read arguments from file: ${env_file}
"
exit 0
fi
if [[ "$1" == "--" ]]; then
if [[ ! -f "${env_file}" ]]; then
echo "File environment file not found!: ${env_file}"
exit 1
fi
source "${env_file}"
org="${GITHUB_ORG}"
pat="${GITHUB_PAT}"
else
org="$1"
pat="$2"
fi
curl -s -X POST \
-H "Authorization: Bearer ${pat}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/orgs/${org}/actions/runners/registration-token"