Setting Up Self-hosted GitHub Actions Runner

HomeLab DevOps

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:

  1. A Kubernetes cluster (K3s, Minikube, or EKS — they all work).
  2. kubectl access.
  3. Helm 3 installed.
  4. A GitHub Personal Access Token (PAT) with repo scope.

👤 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!