runnerctl
A Rust CLI for managing multiple GitHub Actions runner pools on a single Linux Docker host. Configure each pool's organization, authentication profile, labels, image, and capacity. The manager runs the official GitHub runner in ephemeral containers, removes containers after their jobs finish, and creates replacements.
Requirements and installation
Install and run runnerctl on the Linux host that will run your GitHub Actions runners. You need:
- Rust and Cargo (Rust 1.85 or later; the current stable toolchain is recommended). Install them with rustup if needed.
- A C compiler and linker for building native dependencies during installation.
- Docker Engine and CLI, with Docker running and your user able to access its socket.
A systemd user service is optional.
Install from crates.io
Install the latest published release from crates.io:
Cargo installs the binary into ~/.cargo/bin by default. Make sure that directory (or $CARGO_HOME/bin if customized) is on your PATH. The --locked flag uses the release's pinned dependency versions. Run the same install command again to update to a newer release.
Build the runner image
The CLI installation does not build the Docker image used by runner containers. Before continuing to the quick start, build the default image on the Docker host used by runnerctl. Docker can fetch the build files directly from the v0.1.1 release:
This tag matches the CLI's default pool image. The image build verifies the runner archive's SHA-256 checksum. To build another runner version, supply both the RUNNER_VERSION and architecture-specific RUNNER_SHA256 build arguments, and use a matching image tag. Custom images must follow the image contract below.
Quick start
PAT input is hidden at the terminal. For automation, use auth login --profile NAME --token-stdin or --env VARIABLE. Do not pass tokens as command arguments. Configuration stores only credential file or environment variable references. Credential files use mode 0600, management directories use 0700, and runner containers receive only short-lived registration tokens.
A fine-grained PAT needs the target organization's Self-hosted runners: Read and write permission and any approval required by organization policy. doctor checks runner read access, local image availability, and the observed health of running pools (errors, retry delays, unknown runners, and unavailable capacity). Intentionally stopped or zero-replica pools are not marked degraded; registration write access is checked when creating a runner. GitHub App authentication is planned for a later release.
To run without a service, use runnerctl manager in the foreground. The CLI and manager must use the same --home directory. The default is $XDG_CONFIG_HOME/runnerctl or ~/.config/runnerctl. When the manager is stopped, CLI configuration and intent changes are still saved under an exclusive lock, but container provisioning requires the manager to be running.
File credentials are recommended for systemd because user services do not automatically inherit shell environment variables. To keep the service running without an active login, configure sudo loginctl enable-linger "$USER". service install creates a unit pointing to the current binary and enables and starts it. Reinstall the service if you move the binary.
Managing multiple pools
replicas is the pool's concurrent capacity, including busy runners. New pools are stopped by default. scale updates the desired capacity without starting a stopped pool. Starting or stopping every pool requires --all.
Stopping, scaling down, deleting, and updating pools limit new provisioning and wait for existing jobs to finish naturally. Idle runners may remain until they receive and complete their next job. Checking GitHub's busy flag cannot eliminate the race between job assignment and container termination, so an idle observation alone does not trigger forced termination. These runners are marked draining; elapsed time does not automatically cancel work. Use stop build --force when immediate termination is necessary; this can interrupt running jobs. After a forced stop, remote registration cleanup may remain pending until GitHub connectivity is restored.
Stopping the manager process does not stop running containers. On restart, the manager recovers from SQLite and container ownership labels. Recreating a pool with the same name assigns a new UUID, distinguishing it from the previous pool. Containers belonging to another manager or an existing Compose deployment are left untouched.
To route jobs to a specific pool within an organization, include its automatically registered pool label:
jobs:
validate:
runs-on:
steps:
- uses: actions/checkout@v4
- run: echo 'validation job'
Pool names are not access-control boundaries. Configure runner group repository access policies in GitHub. Sharing the host Docker socket is intended for trusted workflows and does not provide security isolation between pools. The default image includes the Docker CLI for Docker builds in shell steps. Workflows using jobs.<job>.container, service containers, or host bind mounts need separate validation of their workspace path mappings.
Configuration
See config.example.toml for three pools across two organizations.
# Use optimistic concurrency control in external automation.
The sum of all configured replicas, including stopped pools, must not exceed manager.max_runners. Creation reservations and containers awaiting cleanup also count toward actual capacity. The current implementation rotates through pools and creates one runner at a time, staying within max_parallel_creates. Increasing that setting does not yet enable parallel creation.
Manual file edits take effect only after config apply. CLI configuration changes are rejected while unapplied edits exist, preventing accidental overwrites. Delete pools with pool remove. Changing a pool's organization or authentication profile requires the pool to be stopped and its previous registrations fully cleaned up. Image, label, and runner group changes apply to a new generation of runners.
SQLite is the source of truth for applied configuration. If the process exits after saving the database but before writing TOML, the file may differ from the applied state. Compare the applied configuration and revision from --json status with the file, then recover by applying the desired file contents with config apply. Saved credential files are not automatically deleted. After rotating a token, check that an old file is no longer referenced before removing it.
Failures and logs
- GitHub and Docker are polled periodically. Docker event streaming is not yet implemented.
- API observation failures are reported as
unknownand do not cause running containers to be deleted. - Authentication errors and rate limits are shared within an authentication profile. Failed pools retry with bounded exponential backoff and jitter (at most 600 seconds). After runner exits, provisioning pauses during that delay while observation and cleanup continue; dependency failures pause reconciliation to avoid repeatedly hitting a failing Docker daemon or API. Retry scope and deadlines survive manager restarts.
- A runner that exits without completing a job is recorded as a failure. All exited runners found in a reconciliation pass are cleaned before replacement is attempted. Runners started before the current host boot were stopped by the reboot, so they are cleaned and replaced immediately without being recorded as failures. Normal completion requires both the completion hook and container exit.
- Replacement containers receive new names and fresh filesystems, avoiding registration state left by container restarts.
- A live but unresponsive process may remain
unknown. Inspect its state and use an explicitstop --forcefollowed bystartif necessary. - Before deleting a container, the manager saves recent logs, up to 1 MiB, and a
_diagarchive, up to 32 MiB, in<home>/logs. Archive failure delays deletion and reports an error. - Docker logs are limited to three 10 MiB files. Archived files expire after seven days by default; retention cleanup runs during successful reconciliation of an active pool.
logs --followpolls the selected container and ends when that container is replaced. Previous logs remain in the archive directory.- Rolling updates replace runners after their jobs finish, without adding extra capacity. Startup failures trigger backoff for the affected pool while existing jobs continue.
Automatic runner updates are disabled; updates are delivered through images. Automatic tracking of runner version support deadlines is not yet implemented. Monitor GitHub's update requirements and rebuild images accordingly.
Image contract
The default Dockerfile carries forward the Ubuntu, Git, and Docker CLI setup from the existing deployment. /entrypoint.sh starts as root to configure Docker socket group access, consumes /run/runnerctl/token, and then runs config.sh --ephemeral --disableupdate and run.sh as a regular user. The PAT is not injected into this container.
Custom images must handle GITHUB_ORG, RUNNER_NAME, RUNNER_LABELS, and RUNNER_GROUP, consume and remove /run/runnerctl/token during registration, and exit after one job. The completion hook must create /run/runnerctl/completed, and diagnostic files must be written to /home/runner/actions-runner/_diag. Extend runner/Dockerfile with additional build tools when possible.
Testing and migration
For development, clone the repository and install the local checkout instead of the published crate:
Run the checks from the repository root:
See tests/README.md for isolated tests using real Docker containers. These tests create and clean up only dedicated containers labeled with a randomly generated manager ID. They do not register runners with a real organization or execute GitHub workflows.
Start migration with one test runner using a new label. Verify your actual workflows and Docker usage, then stop existing Compose slots individually and increase the new pool's capacity. Adjust the existing recovery timer's management scope so it cannot recreate migrated runners, and disable it once migration is complete.
Publishing a release
Maintainers commit the desired version in Cargo.toml and Cargo.lock, then create and publish a GitHub release for the matching v<version> tag. Publishing the release triggers the repository workflow, which checks the tag against Cargo.toml, runs formatting, clippy, tests and a publication dry run, then uploads the crate to crates.io. The repository's CARGO_REGISTRY_TOKEN secret is already configured; prerelease GitHub releases are rejected.