Self-hosted GitHub Actions runner with DooD and Actions Cache Server support
834
A containerized, multi-architecture GitHub Actions self-hosted runner with Docker workflow support (Docker-outside-of-Docker) and GitHub Actions Cache Server integration.
The easiest way to deploy single or multi-repository runners with an optional shared Actions Cache Server:
curl -fsSL https://raw.githubusercontent.com/bestony/self-hosted-action-runner/main/install.sh | bash
get.docker.com) and Homebrew setup on macOS.ghcr.io/falcondev-oss/github-actions-cache-server:latest with automatic DooD host IP detection.docker-compose.yml and .env with strict chmod 600 permissions. Tokens are never inlined into Compose files.--dir <path>: Target directory (default: /opt/github-runner for root, $HOME/github-runner for non-root).--non-interactive: Automate deployment without interactive prompts using GHR_* environment variables.--no-start: Generate configuration files and directory structure without starting containers.--skip-docker-install: Skip automatic Docker and Compose plugin installation attempts.--uninstall: Stop containers and prompt to delete persistent volumes.--debug: Enable verbose debug logging.-h, --help: Show help text and options.Run the command without any GHR_* variables or flags. The installer reads your answers from the terminal (/dev/tty), so this works through curl | bash. Press Enter to accept a default value. The token input is hidden.
$ curl -fsSL https://raw.githubusercontent.com/bestony/self-hosted-action-runner/main/install.sh | bash
==> [3/6] Collecting configuration
Enter installation directory [default: /home/me/github-runner]:
--- Configuring Runner #1 ---
GitHub Repository or Organization URL (e.g. https://github.com/org/repo): https://github.com/my-org/repo-a
GitHub Runner Registration Token:
Runner name prefix [default: repo-a-]:
Runner labels (comma-separated) [default: self-hosted,linux,docker]:
Number of runner instances for this target (to run jobs concurrently) [default: 1]: 2
Add another repository/org runner? [y/N]: y
--- Configuring Runner #2 ---
GitHub Repository or Organization URL (e.g. https://github.com/org/repo): https://github.com/my-org
GitHub Runner Registration Token:
Runner name prefix [default: my-org-]:
Runner labels (comma-separated) [default: self-hosted,linux,docker]:
Number of runner instances for this target (to run jobs concurrently) [default: 1]: 1
Add another repository/org runner? [y/N]:
--- Cache Server Configuration ---
Enable shared GitHub Actions cache server? [Y/n]:
Select cache URL mode: [1] Internal (http://cache-server:3000) or [2] Host IP (reachable by container jobs) [default: 1]:
Runner container image [default: ghcr.io/bestony/self-hosted-action-runner:latest]:
(configuration summary, tokens masked)
Write configuration and continue? [Y/n]:
Get a registration token from Settings > Actions > Runners > New self-hosted runner of the repository or organization. The token expires after 1 hour, but the runner needs it only for the first registration.
If you run the installer again with the same directory, it shows the configured runners and asks you to choose [A]dd runners, [R]econfigure from scratch or [Q]uit.
Use --non-interactive only when no person is at the terminal. In this mode the installer reads the GHR_* variables and does not ask questions. Without --non-interactive, the installer asks for all values and ignores the GHR_* variables (only GHR_DEBUG=1 applies in both modes).
curl -fsSL https://raw.githubusercontent.com/bestony/self-hosted-action-runner/main/install.sh | \
GHR_RUNNER_1_URL="https://github.com/my-org/repo-a" \
GHR_RUNNER_1_TOKEN="YOUR_REPO_A_TOKEN" \
GHR_RUNNER_1_INSTANCES=2 \
GHR_RUNNER_2_URL="https://github.com/my-org/repo-b" \
GHR_RUNNER_2_TOKEN="YOUR_REPO_B_TOKEN" \
GHR_CACHE=1 \
bash -s -- --non-interactive --dir /opt/github-runner
| Topology | Docker Compose | CapRover | Kubernetes |
|---|---|---|---|
| Single Runner | docker-compose.yml | Single App Guide | Deployment + PVC |
| Multi-Runner (Independent) | docker-compose.multi.yml | Multi-App Setup | StatefulSet Cluster |
| Multi-Runner + Shared Cache | Multi-Repo Cache Compose | One-Click App Template | Kustomize Manifests |
To start a single runner container quickly, use docker run.
Generate a runner registration token from your GitHub repository or organization:
Settings > Actions > Runners > New runner.Organization Settings > Actions > Runners > New runner.Run this command on your host:
docker run -d \
--name github-runner \
--restart unless-stopped \
-e RUNNER_URL="https://github.com/your-org/your-repo" \
-e RUNNER_TOKEN="YOUR_REGISTRATION_TOKEN" \
-v /var/run/docker.sock:/var/run/docker.sock \
-v runner_data:/runner \
bestony/self-hosted-runner:latest
The runner configures itself, registers with GitHub, and begins listening for jobs.
Runner credentials persist in the runner_data volume. Container restarts do not require re-registration.
This image supports an external GitHub Actions Cache Server (such as falcondev-oss/github-actions-cache-server).
The runner image includes a binary patch for ACTIONS_RESULTS_URL and bundles zstd for fast compression.
Set the ACTIONS_RESULTS_URL environment variable:
docker run -d \
--name github-runner \
--restart unless-stopped \
-e RUNNER_URL="https://github.com/your-org/your-repo" \
-e RUNNER_TOKEN="YOUR_REGISTRATION_TOKEN" \
-e ACTIONS_RESULTS_URL="http://cache-server:3000/" \
-v /var/run/docker.sock:/var/run/docker.sock \
-v runner_data:/runner \
bestony/self-hosted-runner:latest
When ACTIONS_RESULTS_URL is configured:
actions/cache workflow steps save and restore cache directly with your cache server.--disableupdate) to prevent overwriting the internal cache patch.ACTIONS_RESULTS_URL uses an address reachable from the host (such as http://host.docker.internal:3000/ or your server IP).You can manage the runner and the cache server together with Docker Compose.
docker-compose.ymlservices:
runner-1: &runner-base
build:
context: .
dockerfile: Dockerfile
image: bestony/self-hosted-runner:latest
restart: unless-stopped
environment:
- RUNNER_URL=${RUNNER_URL}
- RUNNER_TOKEN=${RUNNER_TOKEN}
- RUNNER_NAME=${RUNNER_NAME_1:-${RUNNER_NAME:-}}
- RUNNER_NAME_PREFIX=${RUNNER_NAME_PREFIX_1:-${RUNNER_NAME_PREFIX:-runner-1-}}
- RUNNER_LABELS=${RUNNER_LABELS:-}
- RUNNER_GROUP=${RUNNER_GROUP:-}
- RUNNER_WORKDIR=${RUNNER_WORKDIR_1:-${RUNNER_WORKDIR:-${PWD}/work/runner-1}}
- ACTIONS_RESULTS_URL=${ACTIONS_RESULTS_URL:-}
- DISABLE_AUTO_UPDATE=${DISABLE_AUTO_UPDATE:-}
- LOG_LEVEL=${LOG_LEVEL:-info}
- FIX_WORKSPACE_OWNERSHIP=${FIX_WORKSPACE_OWNERSHIP:-true}
- RUNNER_EXTRA_APT_PACKAGES=${RUNNER_EXTRA_APT_PACKAGES:-}
- RUNNER_WRITABLE_PATHS=${RUNNER_WRITABLE_PATHS:-}
volumes:
- runner_1_data:/runner
- /var/run/docker.sock:/var/run/docker.sock
- ${RUNNER_WORKDIR_1:-${RUNNER_WORKDIR:-${PWD}/work/runner-1}}:${RUNNER_WORKDIR_1:-${RUNNER_WORKDIR:-${PWD}/work/runner-1}}
depends_on:
cache-server:
condition: service_started
required: false
runner-2:
<<: *runner-base
environment:
- RUNNER_URL=${RUNNER_URL}
- RUNNER_TOKEN=${RUNNER_TOKEN}
- RUNNER_NAME=${RUNNER_NAME_2:-}
- RUNNER_NAME_PREFIX=${RUNNER_NAME_PREFIX_2:-${RUNNER_NAME_PREFIX:-runner-2-}}
- RUNNER_LABELS=${RUNNER_LABELS:-}
- RUNNER_GROUP=${RUNNER_GROUP:-}
- RUNNER_WORKDIR=${RUNNER_WORKDIR_2:-${PWD}/work/runner-2}
- ACTIONS_RESULTS_URL=${ACTIONS_RESULTS_URL:-}
- DISABLE_AUTO_UPDATE=${DISABLE_AUTO_UPDATE:-}
- LOG_LEVEL=${LOG_LEVEL:-info}
- FIX_WORKSPACE_OWNERSHIP=${FIX_WORKSPACE_OWNERSHIP:-true}
- RUNNER_EXTRA_APT_PACKAGES=${RUNNER_EXTRA_APT_PACKAGES:-}
- RUNNER_WRITABLE_PATHS=${RUNNER_WRITABLE_PATHS:-}
volumes:
- runner_2_data:/runner
- /var/run/docker.sock:/var/run/docker.sock
- ${RUNNER_WORKDIR_2:-${PWD}/work/runner-2}:${RUNNER_WORKDIR_2:-${PWD}/work/runner-2}
profiles:
- multi
- scale
- all
cache-server:
image: ghcr.io/falcondev-oss/github-actions-cache-server:latest
restart: unless-stopped
# ports:
# - "${CACHE_PORT:-3000}:3000"
environment:
API_BASE_URL: ${CACHE_API_BASE_URL:-http://cache-server:3000}
STORAGE_DRIVER: filesystem
STORAGE_FILESYSTEM_PATH: /data/cache
DB_DRIVER: sqlite
DB_SQLITE_PATH: /data/cache-server.db
volumes:
- cache_data:/data
profiles:
- cache
- full
- all
volumes:
runner_1_data:
runner_2_data:
runner_3_data:
runner_4_data:
cache_data:
.env file:
cp .env.example .env
RUNNER_URL and RUNNER_TOKEN in .env.# Start single runner instance (default)
docker compose up -d
# Start 2 concurrent runner instances
docker compose --profile multi up -d
# Scale to 4 concurrent runner instances
docker compose --profile scale up -d
# Start runner with cache server
docker compose --profile cache up -d
docker compose logs -f
You can run multiple independent runner stacks on the same host machine (for different teams, environments, or projects) without collisions:
COMPOSE_PROJECT_NAME in .env or top-level name: in docker-compose.yml). The installer automatically generates deterministic, collision-free project names (ghr-<dir>-<sha256:8>).container_name attributes are omitted. Containers are dynamically named <project>-<service>-<index> by Compose.runner_data, cache_data) are scoped per project, ensuring runner credentials and SQLite databases never conflict.${PWD}/work/<runner> or <install_dir>/work/runner-<n>), ensuring Docker-outside-of-Docker (DooD) host mounts do not collide.Marketplace actions are written for the GitHub-hosted runner images. They rely on conventions that a bare self-hosted container does not have. A missing convention causes errors such as EACCES: permission denied, mkdir '/opt/hostedtoolcache' (ruby/setup-ruby, actions/setup-python) or EACCES: permission denied, stat '/root/.gitconfig' (actions/checkout).
The image supplies these conventions as a contract. The container examines the contract at each start.
| Convention | What the image supplies |
|---|---|
| Runner identity | The runner process runs as the unprivileged runner user with HOME=/home/runner and passwordless sudo. |
| Tool cache | RUNNER_TOOL_CACHE and AGENT_TOOLSDIRECTORY are /opt/hostedtoolcache. The runner user owns the directory. Installed tools are kept in the /runner volume (/runner/_tool-cache), so a new container does not download them again. |
| Workspace ownership | A job hook (/opt/runner-hooks/fix-workspace-ownership.sh) gives root-owned files in the job workspace, RUNNER_TEMP and the tool cache back to the runner user before and after each job. |
| Baseline tools | Docker CLI with the Buildx and Compose plugins (through /var/run/docker.sock), GitHub CLI (gh), git, git-lfs, curl, wget, jq, zip, unzip, xz, zstd, rsync, openssh-client, python3. |
| Native builds | build-essential, pkg-config and the headers libssl-dev, libyaml-dev, libffi-dev, libreadline-dev, libgmp-dev, zlib1g-dev. Ruby gems, Node.js addons and Python wheels with native code can be compiled. |
| Locale | LANG=C.UTF-8. |
gh needs a token in the job, for example env: GH_TOKEN: ${{ github.token }}. To install a system package in one workflow only, use sudo apt-get update && sudo apt-get install -y <package> in a step, as on a hosted runner.
If a workflow needs something that the image does not supply, change the container configuration. An image rebuild is not necessary.
| Variable | Default | Description |
|---|---|---|
RUNNER_EXTRA_APT_PACKAGES | - | apt packages to install when the container starts (separated by spaces or commas), for example libpq-dev libsqlite3-dev. |
RUNNER_WRITABLE_PATHS | - | Colon-separated absolute paths. The container creates each directory and gives it to the runner user. System directories such as /usr are refused. |
RUNNER_INIT_DIR | /opt/runner-init.d | Each *.sh file in this directory runs with bash before the runner starts. Mount your scripts into this directory. |
RUNNER_TOOL_CACHE | /opt/hostedtoolcache | Tool cache path. Do not change it unless it is necessary: prebuilt Ruby and Python binaries embed the default path. |
RUNNER_TOOL_CACHE_PERSIST | true | Set to false to keep the tool cache in the container filesystem. A volume that you mount at the tool cache path is always used as-is. |
FIX_WORKSPACE_OWNERSHIP | true | Set to false to disable the ownership hook. |
ACTIONS_RUNNER_HOOK_JOB_STARTED | ownership hook | Set your own script to replace the job-started hook. |
ACTIONS_RUNNER_HOOK_JOB_COMPLETED | ownership hook | Set your own script to replace the job-completed hook. |
A problem in an extension point does not stop the runner. The container log shows a warning.
The entrypoint runs runner-doctor as the runner user before the runner starts. If a convention is not satisfied, the container log shows a FAIL line with the cause. To get the full report from a container that is in operation:
docker exec -u runner <container> runner-doctor
To run matrix jobs in parallel, register more than one runner: run the installer again in the same directory and choose [A]dd runners. One runner executes one job at a time.
For comprehensive documentation, refer to:
Content type
Image
Digest
sha256:0ea99e213…
Size
461.6 MB
Last updated
6 days ago
docker pull bestony/self-hosted-runner