How to Deploy GitHub Actions Runner Controller Scale Sets on K3s

Updated on Oct 26, 2026
Mila H
13 MINS READ
Table of Contents
Deploy ARC Runner Scale Sets on K3s

Running CI jobs on your own servers is cheap and private, but only if the runners scale on their own. Actions Runner Controller (ARC) does this for you. It creates a fresh runner pod for each job and deletes it when the job ends.

In this guide, you will learn how to run ARC scale sets on k3s. You will install ARC on an existing K3s cluster, deploy an ephemeral runner scale set with Helm, and set up GitHub authentication. 

What Are ARC Scale Sets on k3s?

ARC is a Kubernetes operator from GitHub. It runs a controller and a listener. The listener connects to GitHub and watches for queued jobs for your runner scale set. When jobs arrive, the controller creates runner pods. Each runner pod takes one job and is removed after that job ends. That is why the runners are called ephemeral.

Tips: If you only have one server and do not use Kubernetes, you can build a simpler one-job runner with systemd and Docker. You can check this guide on how to set up an ephemeral GitHub Actions self-hosted runner.

If you want automatic scaling on K3s, you must proceed to the rest of this article.

Here is how ARC scale sets on K3s work:

  • A workflow job starts and uses runs-on: <scale-set-name>.
  • GitHub puts the job in the queue for that scale set.
  • The listener pod sees the job and tells the controller how many runners are needed.
  • The controller creates runner pods in the runner namespace.
  • Each pod runs one job and is deleted.
  • When the queue is empty, the pods scale down to your minimum number.

You must use two namespaces. The controller and listener live in arc-systems. The runner pods live in arc-runners. It is recommended to keep runner pods in a different namespace than the controller, because workflows run any code a user writes.

Prerequisites

Before you start with ARC scale sets on k3s, make sure you have:

  • A working K3s cluster and shell access to a server node. 
  • kubectl and Helm 3 on that server.
  • A GitHub repository or organization where you can add runners.
  • Outbound HTTPS access from the cluster to GitHub. ARC does not need any inbound ports.
  • A default storage class. K3s ships with the local-path storage class, which is enough for this guide.

First, you must check the cluster. K3s saves its kubeconfig at /etc/rancher/k3s/k3s.yaml. Helm needs this path, so you must export it with the commands below:

Bash
export KUBECONFIG=/etc/rancher/k3s/k3s.yamlkubectl get nodeskubectl get storageclass

All nodes should show Ready, and you should see local-path (default) in the storage class list.

Also, you must install Helm if it is not installed yet:

Bash
curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3chmod 700 get_helm.sh./get_helm.shhelm version

Step 1: Install the ARC Controller

The controller is the first part of ARC scale sets on k3s, and it goes in the arc-systems namespace. We use a fixed chart version, so your install matches this guide. The latest version right now is 0.15.0. Use the same version for both the controller chart and the scale set chart:

Bash
helm install arc \  --namespace arc-systems \  --create-namespace \  --version 0.15.0 \  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller

Check that the controller pod is running:

Bash
kubectl get pods -n arc-systems

You should see one controller pod with 1/1 Running. Also, the ARC install adds its custom resource definitions (CRDs) to the cluster.

Step 2: Set Up GitHub Authentication

ARC needs a credential to register runners with GitHub. You have two choices, including a GitHub App, which is best for organizations, or a personal access token (PAT).

First, you must create the runner namespace with the command below:

Bash
kubectl create namespace arc-runners

In this option, you must create a GitHub App that is owned by your organization. Use these settings:

  • Homepage URL: https://github.com/actions/actions-runner-controller
  • Repository permissions: Metadata read-only. Add Administration read and write only if you register runners at the repository level.
  • Organization permissions: Self-hosted runners read and write.

Then, you must generate a private key (.pem file), install the app on your organization, and note the App ID and the Installation ID. You can find the Installation ID in the URL of the app installation page:

Bash
https://github.com/organizations/ORGANIZATION/settings/installations/INSTALLATION_ID

Then, create the Kubernetes secret in the arc-runners namespace:

Bash
kubectl create secret generic arc-github-secret \  --namespace arc-runners \  --from-literal=github_app_id=123456 \  --from-literal=github_app_installation_id=654321 \  --from-file=github_app_private_key=private-key.pem

Replace the IDs with your own values. You cannot use a GitHub App for enterprise-level runners.

Option B: Personal Access Token (Classic)

With this option, you can create a classic PAT with the repo scope for repository runners, or the admin:org scope for organization runners. Then, create the secret with:

Bash
kubectl create secret generic arc-github-secret \  --namespace arc-runners \  --from-literal=github_token='YOUR_PAT_HERE'

Both options use the same secret name, which is arc-github-secret. Always pass a secret name to Helm. Do not paste tokens in the helm install command, because they can leak in your shell history.

Step 3: Deploy the Ephemeral Runner Scale Set

At this point, you must create a values file for the runner scale set. This keeps your settings for ARC scale sets on k3s in one place. Create the file with your desired text editor:

Bash
nano arc-runner-values.yaml

Add the following content to the file:

YAML
githubConfigUrl: "https://github.com/YOUR_ORG"githubConfigSecret: arc-github-secret runnerScaleSetName: "k3s-runners" minRunners: 0maxRunners: 5 containerMode:  type: "dind"

Once you are done, save and exit. What each line does:

  • githubConfigUrl: The repository, organization, or enterprise that owns the runners. For one repository, use https://github.com/YOUR_ORG/YOUR_REPO.
  • githubConfigSecret: The name of the secret from Step 2.
  • runnerScaleSetName: The name you will put in runs-on. If you leave it empty, ARC uses the Helm release name.
  • minRunners and maxRunners: The scaling range. We explain these in Step 4.
  • containerMode: How the runner starts containers. We explain this in Step 5.

Then, install the scale set in the arc-runners namespace:

Bash
helm install k3s-runners \  --namespace arc-runners \  --version 0.15.0 \  -f arc-runner-values.yaml \  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set

Verify the installation with the commands below:

Bash
helm list -Akubectl get pods -n arc-systems

You should see two Helm releases, arc and k3s-runners, both with status deployed. In arc-systems, you should see the controller pod and a new listener pod whose name ends in -listener. 

In GitHub, go to your organization or repository, Settings > Actions > Runners. The new scale set should be listed there.

Step 4: Set Min and Max Runners

The minRunners and maxRunners values set the smallest and largest number of runners. ARC adds minRunners and the number of assigned jobs to get the runner count. It never goes above maxRunners.

Here are the example settings and what each one does. It helps you pick the right values for your needs:

Setting Result
minRunners: 0, maxRunners: 5 Scales to zero when idle. Scales up to 5 runners. Best for saving resources.
minRunners: 2, maxRunners: 10 Always keeps 2 idle runners ready. Jobs start faster.
Both commented out No limit. The set scales to the number of jobs and back to zero.
minRunners: 0, maxRunners: 0 Stops new runner pods. Use it to drain the queue during maintenance.

Keep these rules in mind when you tune ARC scale sets on k3s:

  • minRunners can never be higher than maxRunners, unless you comment out maxRunners.
  • ARC has no built-in schedule for min and max values. If you want different values at night, change the values with a cron job and helm upgrade.
  • Always set a maxRunners value on a small K3s cluster. This stops a busy queue from using all CPU and memory on your nodes.

To change the values later, you can edit the values file and run:

Bash
helm upgrade k3s-runners \  --namespace arc-runners \  --version 0.15.0 \  -f arc-runner-values.yaml \  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set

ARC scales runner pods based on queued jobs, not on CPU or memory. Also, if you want to scale your own apps by events or metrics on the same cluster, check this guide on KEDA autoscaling on K3s.

Step 5: Choose Docker or Kubernetes Job Mode

You need a container mode only if your jobs use container jobs, service containers, or container actions, or if they run docker build. ARC gives you two options. Set one of them in containerMode.type.

Docker Mode ( dind )

In dind (Docker-in-Docker) mode, each runner pod gets a Docker daemon sidecar. Your workflow can run docker build and docker run like on a normal server. This is the easiest mode, and it is the one we used in Step 3.

The trade-off is that the dind container must run in privileged mode. Use it only for trusted private repositories.

Kubernetes Mode

In kubernetes mode, there is no Docker daemon. When a job uses a container, ARC creates a new pod in the same namespace to run it. The runner pod and the job pod share a work volume, so you need a storage class that can create volumes on demand. K3s already has local-path for this.

To switch, you must replace the containerMode block in arc-runner-values.yaml:

YAML
containerMode:  type: "kubernetes"  kubernetesModeWorkVolumeClaim:    accessModes: ["ReadWriteOnce"]    storageClassName: "local-path"    resources:      requests:        storage: 1Gi

Then, run the same helm upgrade command from Step 4.

In Kubernetes mode, every job must use a container: value. A job without one fails with this error: Jobs without a job container are forbidden on this runner.

To allow jobs without a container, you can set the ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER environment variable to "false" on the runner container in the template section of the values file.

Note that local-path volumes live on one node's disk. This is fine for a single node or a test. On a multi-node cluster, you must use a shared storage system such as Longhorn.

Here is a quick comparison between two modes:

Mode Needs privileged pods Needs storage class Best for
dind Yes No Docker builds and simple setups
kubernetes No Yes Clusters that block privileged pods

Step 6: Enable Metrics

Monitoring helps you see how ARC scale sets on k3s are working. ARC exposes Prometheus metrics from the controller and the listener. You need to turn them on in the controller values and the scale set values.

First, create the controller values file with the command below:

Bash
nano arc-controller-values.yaml

Add the following content to the file:

YAML
metrics:  controllerManagerAddr: ":8080"  listenerAddr: ":8080"  listenerEndpoint: "/metrics"

Once you are done, save and exit. Then, upgrade the controller:

Bash
helm upgrade arc \  --namespace arc-systems \  --version 0.15.0 \  -f arc-controller-values.yaml \  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller

Next, you must tell the listener which metrics to publish. If the controller publishes metrics but the listenerMetrics field is empty, the listener can fail to start on some chart versions. Create the file with:

Bash
nano arc-listener-metrics.yaml

Add the following content to the file:

YAML
listenerMetrics:  counters:    gha_started_jobs_total:      labels: ["repository", "organization", "enterprise", "job_name", "event_name"]    gha_completed_jobs_total:      labels: ["repository", "organization", "enterprise", "job_name", "event_name", "job_result"]  gauges:    gha_assigned_jobs:      labels: ["name", "namespace", "repository", "organization", "enterprise"]    gha_running_jobs:      labels: ["name", "namespace", "repository", "organization", "enterprise"]    gha_registered_runners:      labels: ["name", "namespace", "repository", "organization", "enterprise"]    gha_busy_runners:      labels: ["name", "namespace", "repository", "organization", "enterprise"]    gha_min_runners:      labels: ["name", "namespace", "repository", "organization", "enterprise"]    gha_max_runners:      labels: ["name", "namespace", "repository", "organization", "enterprise"]    gha_desired_runners:      labels: ["name", "namespace", "repository", "organization", "enterprise"]    gha_idle_runners:      labels: ["name", "namespace", "repository", "organization", "enterprise"]  histograms:    gha_job_startup_duration_seconds:      labels: ["repository", "organization", "enterprise", "job_name", "event_name"]    gha_job_execution_duration_seconds:      labels: ["repository", "organization", "enterprise", "job_name", "event_name", "job_result"]

Once you are done, save and exit. If you leave buckets empty, ARC uses its default histogram buckets. Upgrade the scale set with both values files:

Bash
helm upgrade k3s-runners \  --namespace arc-runners \  --version 0.15.0 \  -f arc-runner-values.yaml \  -f arc-listener-metrics.yaml \  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set

Wait until the new listener pod is Running, then read the metrics with a port-forward:

Bash
LISTENER=$(kubectl get pods -n arc-systems -o name | grep listener)kubectl port-forward -n arc-systems $LISTENER 8080:8080 &curl -s localhost:8080/metrics | grep -E "^gha_(assigned_jobs|busy_runners|idle_runners|desired_runners|min_runners|max_runners)"

Stop the port-forward when you finish with kill %1. These are the most useful metrics:

Metric What it tells you
gha_assigned_jobs Jobs assigned to the scale set
gha_running_jobs Jobs running or queued to run
gha_desired_runners The runner count ARC wants right now
gha_busy_runners Runners that are running a job
gha_idle_runners Runners that are waiting for a job
gha_job_startup_duration_seconds How long jobs wait before they start
gha_controller_failed_ephemeral_runners Runners in a failed state (controller metric)

Point your Prometheus at port 8080 of the controller and listener pods to build dashboards and alerts. Listener counters reset when the listener pod restarts.

Step 7: Run a Real Autoscaling Test

Now you must test that the scale set scales up and down. This test starts 8 jobs at once, but maxRunners is 5. You should see 5 runner pods first, and the other 3 jobs wait in the queue.

Create the test file inside your GitHub repository. The repository must be able to use your runner scale set:

Bash
nano .github/workflows/arc-test.yml

Paste the following content to the file:

YAML
name: ARC Autoscaling Teston: workflow_dispatch jobs:  load:    runs-on: k3s-runners    strategy:      matrix:        n: [1, 2, 3, 4, 5, 6, 7, 8]    steps:      - name: Show runner pod        run: echo "Job ${{ matrix.n }} is running on $(hostname)"      - name: Wait        run: sleep 90

The runs-on value must match runnerScaleSetName exactly. In this guide, that is k3s-runners.

Open two terminals on your K3s server. In the first one, watch the runner pods:

Bash
kubectl get pods -n arc-runners -w

In the second one, watch the scale set status:

Bash
kubectl get autoscalingrunnersets -n arc-runnerskubectl get ephemeralrunners -n arc-runners

Now go to the Actions tab in GitHub, choose ARC Autoscaling Test, and click Run workflow. You should see this order of events:

  1. Before the run, there are no runner pods, because minRunners is 0.
  2. A few seconds after the run starts, up to 5 runner pods appear and go to Running.
  3. The other 3 jobs remain queued on GitHub until a pod becomes available.
  4. As jobs finish, each pod is deleted, and new pods start for the queued jobs.
  5. After all 8 jobs finish, the pod list is empty again.

While the test runs, check the metrics from Step 6. The gha_desired_runners value should go up to 5. The gha_busy_runners value should match the number of running pods. When the test ends, both values should go back to 0.

If you use dind mode, you can also test Docker in a second job:

YAML
  docker-check:    runs-on: k3s-runners    steps:      - run: docker version      - run: docker run --rm hello-world

If you use kubernetes mode, you can add a container to the job instead:

YAML
  container-check:    runs-on: k3s-runners    container: node:20    steps:      - run: node --version

If no pods appear, check the listener logs and the controller logs with:

Bash
kubectl logs -n arc-systems $(kubectl get pods -n arc-systems -o name | grep listener)kubectl logs -n arc-systems $(kubectl get pods -n arc-systems -o name | grep controller)

The most common causes are a wrong githubConfigUrl, a secret with wrong permissions, or a runs-on name that does not match the scale set name.

Conclusion

At this point, you have ARC scale sets on k3s running with separate controller and runner namespaces, secure GitHub authentication, and clear min and max limits. Also, you picked a job mode, enabled metrics, and proved with a real test that runners appear when jobs arrive and disappear when they end.

For the best results, it is recommended to run it on strong hardware. You can run ARC on a reliable dedicated server to get elastic private CI capacity with full control over your runners.

It is a group of runners managed by ARC. The group scales up and down based on the jobs waiting in GitHub.

Yes. Set a low maxRunners value so jobs do not use all the CPU and memory on that node.

Helm cannot upgrade the CRDs. Uninstall the scale sets, wait for cleanup, uninstall the controller, remove the old actions.github.com CRDs if they changed, and install again.

You are in Kubernetes mode. Add a "container:" to the job, or set ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER to "false".