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:
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:
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:
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:
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:
kubectl create namespace arc-runners
Option A: GitHub App (Recommended)
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:
https://github.com/organizations/ORGANIZATION/settings/installations/INSTALLATION_ID
Then, create the Kubernetes secret in the arc-runners namespace:
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:
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:
nano arc-runner-values.yaml
Add the following content to the file:
1githubConfigUrl: "https://github.com/YOUR_ORG"2githubConfigSecret: arc-github-secret3 4runnerScaleSetName: "k3s-runners"5 6minRunners: 07maxRunners: 58 9containerMode:10 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:
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:
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:
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:
1containerMode:2 type: "kubernetes"3 kubernetesModeWorkVolumeClaim:4 accessModes: ["ReadWriteOnce"]5 storageClassName: "local-path"6 resources:7 requests:8 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:
nano arc-controller-values.yaml
Add the following content to the file:
1metrics:2 controllerManagerAddr: ":8080"3 listenerAddr: ":8080"4 listenerEndpoint: "/metrics"
Once you are done, save and exit. Then, upgrade the controller:
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:
nano arc-listener-metrics.yaml
Add the following content to the file:
1listenerMetrics:2 counters:3 gha_started_jobs_total:4 labels: ["repository", "organization", "enterprise", "job_name", "event_name"]5 gha_completed_jobs_total:6 labels: ["repository", "organization", "enterprise", "job_name", "event_name", "job_result"]7 gauges:8 gha_assigned_jobs:9 labels: ["name", "namespace", "repository", "organization", "enterprise"]10 gha_running_jobs:11 labels: ["name", "namespace", "repository", "organization", "enterprise"]12 gha_registered_runners:13 labels: ["name", "namespace", "repository", "organization", "enterprise"]14 gha_busy_runners:15 labels: ["name", "namespace", "repository", "organization", "enterprise"]16 gha_min_runners:17 labels: ["name", "namespace", "repository", "organization", "enterprise"]18 gha_max_runners:19 labels: ["name", "namespace", "repository", "organization", "enterprise"]20 gha_desired_runners:21 labels: ["name", "namespace", "repository", "organization", "enterprise"]22 gha_idle_runners:23 labels: ["name", "namespace", "repository", "organization", "enterprise"]24 histograms:25 gha_job_startup_duration_seconds:26 labels: ["repository", "organization", "enterprise", "job_name", "event_name"]27 gha_job_execution_duration_seconds:28 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:
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:
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:
nano .github/workflows/arc-test.yml
Paste the following content to the file:
1name: ARC Autoscaling Test2on: workflow_dispatch3 4jobs:5 load:6 runs-on: k3s-runners7 strategy:8 matrix:9 n: [1, 2, 3, 4, 5, 6, 7, 8]10 steps:11 - name: Show runner pod12 run: echo "Job ${{ matrix.n }} is running on $(hostname)"13 - name: Wait14 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:
kubectl get pods -n arc-runners -w
In the second one, watch the scale set status:
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:
- Before the run, there are no runner pods, because
minRunners is 0.
- A few seconds after the run starts, up to 5 runner pods appear and go to
Running.
- The other 3 jobs remain queued on GitHub until a pod becomes available.
- As jobs finish, each pod is deleted, and new pods start for the queued jobs.
- 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:
1 docker-check:2 runs-on: k3s-runners3 steps:4 - run: docker version5 - run: docker run --rm hello-world
If you use kubernetes mode, you can add a container to the job instead:
1 container-check:2 runs-on: k3s-runners3 container: node:204 steps:5 - run: node --version
If no pods appear, check the listener logs and the controller logs with:
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.