Skip to content

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

  1. Click your profile picture (top right) → Settings.
  2. In the left sidebar, scroll to the bottom and click Developer settings.
  3. Click Personal access tokens → Fine-grained tokens.
  4. Click Generate new token.
  5. 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.
  6. Open Permissions → Organization permissions, find Self-hosted runners and set it to Read and write.
  7. 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"