Setting Up Self-hosted GitHub Actions Runner
Note: This blog post was enhanced with the help of AI to improve grammar and refine tone. All content and opinions are my own.
You know what’s better than free GitHub Actions minutes? Unlimited GitHub Actions minutes. Okay, maybe not technically unlimited, but when you run them on your own hardware, it sure feels like it.
In this post, I’ll walk you through setting up a self-hosted GitHub Actions runner inside your Kubernetes cluster — for fun, for control, and for that sweet feeling of DevOps empowerment.
🧐 Why Self-hosted?
Sure, GitHub’s cloud runners are nice, but what if:
- You need special tools not installed by default?
- You want to keep your workloads private?
- You’re deploying to internal systems?
- Or maybe you just want to save money and squeeze every CPU cycle out of your home lab?
For me, I wanted to run CI/CD jobs right inside my home Kubernetes cluster — no internet exposure, no funky network holes, just straight YAML-powered automation bliss.
🔧 Prerequisites
Before we dive into the YAML soup, make sure you have:
- A Kubernetes cluster (K3s, Minikube, or EKS — they all work).
kubectlaccess.- Helm 3 installed.
- A GitHub Personal Access Token (PAT) with
reposcope.
👤 Create the Runner’s Service Account
This account will let the runner mess with your cluster — so give it powers wisely. In my case (home lab!), I gave it full admin because… well, I trust myself. Mostly.
apiVersion: v1
kind: ServiceAccount
metadata:
name: gha-action-runner
namespace: github-actions
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: runner-role-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: admin
subjects:
- kind: ServiceAccount
name: gha-action-runner
namespace: github-actions
Apply it:
kubectl apply -f service-account-for-runner.yaml
🤖 Deploy the Runner Controller
The controller keeps your runners healthy and auto-scales them as needed. Install it with Helm:
helm install arc --namespace github-actions --create-namespace oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
Boom. Controller is in.
🔐 Add Your GitHub Token
Now let’s let the runner talk to GitHub.
kubectl create secret generic github-pat --namespace github-actions --from-literal=github_token='ghp_...'
Paste in your PAT and don’t leak it to Twitter.
🏃 Deploy the Runner
Now for the fun part. This is the pod that does the actual CI/CD lifting.
Create a runners.yaml like this:
githubConfigUrl: https://github.com/your-org
githubConfigSecret: github-pat
minRunners: 1
maxRunners: 5
Then install it:
helm upgrade --install self-hosted --namespace github-actions -f runners.yaml oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
You’re now officially running a GitHub Actions runner inside your cluster. 🥳
🧪 Testing the Setup
Time to check that everything works! Create this GitHub workflow:
name: Self-hosted Test
on:
workflow_dispatch:
jobs:
test:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- run: echo "Hello from my cluster!"
- run: kubectl version --client
- run: helm version
Trigger it from the GitHub UI. If you see logs, you’re golden.
🧰 Optional: Custom Docker Image
Want your runner to come preloaded with Docker, kubectl, helm, Hugo, or your favorite obscure CLI tool? Build your
own image.
By default, the self-hosted runner uses a Docker image named ghcr.io/actions/runner:latest. This
image contains the necessary software and tools to run GitHub Actions workflows. But let’s say you
want to customize the Docker image to include additional tools or dependencies that your workflows
require. We can create another layer on top of the default image to include the additional tools, but
I found it is easier to create a new Docker image from scratch. Easier to maintain and understand.
Here is an example of a Dockerfile that creates a custom image for the self-hosted runner:
FROM debian:trixie-slim
ARG RUNNER_VERSION="2.319.1"
ARG RUNNER_CONTAINER_HOOKS_VERSION="0.6.1"
ARG HUGO_VERSION="0.134.2"
ARG DOCKER_VERSION="27.2.0"
ARG BUILDX_VERSION="0.17.1"
RUN apt-get update && apt-get upgrade -y \
&& apt-get install -y --no-install-recommends \
ca-certificates curl wget less zip unzip jq sudo lsb-release gpg-agent software-properties-common \
&& apt-get install --only-upgrade openssh-client \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean -y && apt-get autoremove -y
RUN mkdir -p -m 755 /etc/apt/keyrings \
&& wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt update \
&& apt install gh -y \
&& apt-get clean -y && apt-get autoremove -y
# Set up the user
RUN adduser --disabled-password --gecos "" --uid 1001 runner \
&& groupadd docker --gid 123 \
&& usermod -aG sudo runner \
&& usermod -aG docker runner \
&& echo "%sudo ALL=(ALL:ALL) NOPASSWD:ALL" > /etc/sudoers \
&& echo "Defaults env_keep += \"DEBIAN_FRONTEND\"" >> /etc/sudoers
# Make and set the working directory
RUN mkdir -p /home/runner \
&& chown -R runner:docker /home/runner
WORKDIR /home/runner
# Runner download and install
RUN curl -L -o runner.tar.gz https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz \
&& tar xzf ./runner.tar.gz \
&& rm runner.tar.gz \
&& ./bin/installdependencies.sh
# Install container hooks
RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-container-hooks/releases/download/v${RUNNER_CONTAINER_HOOKS_VERSION}/actions-runner-hooks-k8s-${RUNNER_CONTAINER_HOOKS_VERSION}.zip \
&& unzip ./runner-container-hooks.zip -d ./k8s \
&& rm runner-container-hooks.zip
# Install Docker
RUN export DOCKER_ARCH=x86_64 \
&& curl -fLo docker.tgz https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz \
&& tar zxvf docker.tgz \
&& rm -rf docker.tgz \
&& mkdir -p /usr/local/lib/docker/cli-plugins \
&& curl -fLo /usr/local/lib/docker/cli-plugins/docker-buildx \
"https://github.com/docker/buildx/releases/download/v${BUILDX_VERSION}/buildx-v${BUILDX_VERSION}.linux-amd64" \
&& chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx
RUN install -o root -g root -m 755 docker/* /usr/bin/ && rm -rf docker
# Install kubectl and helm
RUN curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \
&& chmod +x kubectl \
&& mv kubectl /usr/local/bin/
RUN curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# Install Hugo
RUN wget -O hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \
&& dpkg -i hugo.deb \
&& rm hugo.deb
USER runner
The post above includes a full Dockerfile with:
- Debian slim base
- Docker CLI
- GitHub CLI
- Helm
kubectl- Hugo (for blog nerds like me)
Build and push:
docker build -t my-runner .
docker tag my-runner my-registry/my-runner
docker push my-registry/my-runner
And reference it in your runner config.
githubConfigUrl: https://github.com/{your organization}
githubConfigSecret: github-pat
minRunners: 1
template:
spec:
serviceAccountName: gha-action-runner
initContainers:
- name: init-dind-externals
image: my-registry/my-custom-runner:latest
command:
[ "cp", "-r", "-v", "/home/runner/externals/.", "/home/runner/tmpDir/" ]
volumeMounts:
- name: dind-externals
mountPath: /home/runner/tmpDir
containers:
- name: runner
image: my-registry/my-custom-runner:latest
command: [ "/home/runner/run.sh" ]
env:
- name: DOCKER_HOST
value: unix:///var/run/docker.sock
volumeMounts:
- name: work
mountPath: /home/runner/_work
- name: dind-sock
mountPath: /var/run
- name: dind
image: docker:dind
args:
- dockerd
- --host=unix:///var/run/docker.sock
- --group=$(DOCKER_GROUP_GID)
env:
- name: DOCKER_GROUP_GID
value: "123"
securityContext:
privileged: true
volumeMounts:
- name: work
mountPath: /home/runner/_work
- name: dind-sock
mountPath: /var/run
- name: dind-externals
mountPath: /home/runner/externals
volumes:
- name: work
emptyDir: { }
- name: dind-sock
emptyDir: { }
- name: dind-externals
emptyDir: { }
Please note that the values file above is using docker-in-docker (dind) to build docker images. This is just an example, and you can customize it based on your needs.
💭 Final Thoughts
Running GitHub Actions runners on your own infra isn’t just a cool trick — it’s practical, fast, and flexible. Whether you’re hacking in a homelab or tightening CI/CD security at work, self-hosted runners give you power.
Happy deploying!