Run GitHub Actions self-hosted runners as containers, with a private Docker
daemon in each runner. The runners use the Sysbox
runtime. No privileged mode. No access to the host docker.sock.
You can deploy with Docker Compose or with Docker Swarm.
Moving to GHCR: the image is now published to GitHub Container Registry. The Docker Hub image
moveyourdigital/docker-swarm-github-actions-runnerdoes not get updates anymore. Change your image toghcr.io/moveyourdigital/docker-swarm-github-actions-runner.
Image: ghcr.io/moveyourdigital/docker-swarm-github-actions-runner
(linux/amd64, linux/arm64). Tags: latest (branch master), Git tags,
and the full commit SHA. The image is rebuilt every week to get the latest
base image.
- Each container starts its own
dockerd, then registers as an ephemeral runner. - The runner takes one job, deregisters, and the container restarts with a clean state.
- Jobs cannot see containers, networks, or images of other runners or of the host.
- Linux host with Sysbox CE installed
(the
sysbox-runcruntime must show indocker info). - A way to manage organization self-hosted runners, one of:
- a GitHub App (recommended, see GitHub App), or
- a personal access token (fine-grained: Self-hosted runners: read and write).
cp .env.example .env # set ORGANIZATION (and RUNNER_LABELS, if needed)| Variable | Description | Default |
|---|---|---|
ORGANIZATION |
GitHub organization that owns the runners | required |
RUNNER_LABELS |
Comma-separated runner labels | sysbox |
ACCESS_TOKEN_FILE |
Path to the file that holds the PAT | set in the compose files |
APP_ID |
GitHub App ID (with APP_PRIVATE_KEY_FILE, replaces the PAT) |
none |
APP_PRIVATE_KEY_FILE |
Path to the GitHub App private key (.pem) |
none |
APP_INSTALLATION_ID |
GitHub App installation ID. If not set, the runner gets it from the API. | none |
echo -n '<PAT>' > access-token && chmod 600 access-token
docker compose up -dScale: docker compose up -d --scale runner=5
Update: docker compose pull && docker compose up -d
Compose restarts the same container after each job. start.sh deletes the
inner Docker data (containers, volumes, images) at each start, so jobs do not
share Docker state. Images are not cached between jobs.
A GitHub App belongs to the organization, not to a user, and has no expiration date. Use it instead of a PAT.
-
In the organization: Settings → Developer settings → GitHub Apps → New GitHub App.
- Webhook: off.
- Permissions: Organization → Self-hosted runners: Read and write. No other permissions.
- Where can this GitHub App be installed: Only on this account.
-
Note the App ID. Generate a private key (a
.pemfile). -
Install App on the organization.
-
Docker Compose: save the key as
app-key.pem(chmod 600), and createdocker-compose.override.yml:secrets: github_app-key: file: ./app-key.pem # Never commit this file. services: runner: secrets: !override [github_app-key] environment: APP_ID: "123456" APP_PRIVATE_KEY_FILE: /run/secrets/github_app-key
Then:
docker compose up -d --force-recreate. You can then deleteaccess-tokenand revoke the PAT.Docker Swarm: create the secret with
docker secret create github_app-key app-key.pem, and make the same changes indocker-stack.yml.
Important: Docker Swarm does not support the
runtimeoption for services. You must set Sysbox as the default runtime on the node that runs the runners.
-
Install Sysbox on the node.
-
Label the node:
docker node update --label-add sysbox=true <node-name>
-
On that node, add this to
/etc/docker/daemon.json(keep theruntimesentry that the Sysbox installer created):{ "default-runtime": "sysbox-runc" }Then restart Docker:
sudo systemctl restart docker.Note: all services that run on this node now use Sysbox. Use a dedicated node, or check that your other services work with Sysbox.
-
Create the secret and deploy:
printf '%s' '<PAT>' | docker secret create github_access-token - set -a; . ./.env; set +a docker stack deploy -c docker-stack.yml runners
Scale: docker service scale runners_runner=5
jobs:
build:
runs-on: [self-hosted, sysbox]
steps:
- uses: actions/checkout@v5
- run: docker build .- The inner daemon log is in
/tmp/dockerd.login the runner container. - If
dockerddoes not start, the container does not use Sysbox. Checkdocker inspect --format '{{.HostConfig.Runtime}}' <container>.
The Test workflow installs Sysbox on a GitHub-hosted runner and runs:
tests/compose-smoke.sh: innerdockerd,docker run,docker build, isolation from the host, and a clean state after a restart.tests/app-jwt.sh: the GitHub App JWT (claims and RS256 signature).tests/swarm-smoke.sh:docker-stack.ymlon a one-node Swarm, the node label constraint, and the innerdockerdunder the default runtime.
The Sysbox release check workflow runs every Monday. It opens an issue when
a newer Sysbox release exists than the version that the tests use.
The tests do not register runners. SKIP_REGISTRATION=true keeps a runner
container up after dockerd starts. Use it for tests only.
To run a test on a host with Sysbox:
docker build -t runner:test .
IMAGE=runner:test tests/compose-smoke.sh- Keep the PAT or the GitHub App private key in a Docker secret or in an untracked file. Never put it in the image or in Git.
- Self-hosted runners must not run workflows from untrusted forks.