runnerctl 0.1.0

Manage independent ephemeral GitHub Actions runner pools on one Docker host
runnerctl-0.1.0 is not a library.

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

Requires Linux, Rust 1.85 or later, Docker Engine and CLI, and access to the Docker socket. A systemd user service is optional.

# After the first release is published, install from crates.io.
cargo install runnerctl --locked

# Or install the current checkout during development.
# cargo install --path . --locked

# Build the image on the Docker host used by runnerctl.
docker build -t local/runnerctl-runner:2.337.0 runner

The image build verifies the runner archive's SHA-256 checksum. To build another 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

runnerctl init --org '<YOUR_ORG>'
runnerctl auth login --profile '<YOUR_ORG>'

runnerctl pool add validate --org '<YOUR_ORG>' --auth '<YOUR_ORG>' \
  --labels validate --replicas 4
runnerctl pool add build --org '<YOUR_ORG>' --auth '<YOUR_ORG>' \
  --labels build --replicas 2 --docker-socket

runnerctl doctor
runnerctl service install
runnerctl start --all
runnerctl status

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 and local image availability; 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

runnerctl pool list
runnerctl pool show build
runnerctl status --pool build
runnerctl --json status
runnerctl scale build 3
runnerctl stop build
runnerctl start build
runnerctl logs <runner-id> --pool build --follow
runnerctl upgrade --pool build --image local/runnerctl-runner:2.337.0
runnerctl pool remove build

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: [self-hosted, 'pool:validate', validate]
    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.

runnerctl config check
runnerctl config apply
# Use optimistic concurrency control in external automation.
runnerctl config apply --revision 3

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 unknown and do not cause running containers to be deleted.
  • Authentication errors and rate limits are shared within an authentication profile. Failed pools retry with exponential backoff and jitter.
  • A runner that exits without completing a job is recorded as a failure. 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 explicit stop --force followed by start if necessary.
  • Before deleting a container, the manager saves recent logs, up to 1 MiB, and a _diag archive, 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 --follow polls 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

cargo fmt --check
cargo clippy --all-targets --locked -- -D warnings
cargo test --locked

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.

References