Blog

Deploying Self-Hosted GitHub Runners on Kubernetes (EKS) with a Custom Docker Image

07.05.2026
Reading time: 7 mins.
Last Updated: 07.05.2026

Table of Contents

Deploying self-hosted GitHub runners at the organization level can be more complex than expected – especially when you need full control over tooling, scalability, and execution speed.

In our case, we needed a custom runner environment preloaded with DevOps tools such as Terraform, AWS CLI, kubectl, Helm, and Gitleaks. Installing these at runtime inside every workflow was inefficient and significantly slowed down CI pipelines.

The solution was to build a custom Docker image with a dedicated entrypoint script, combined with GitHub App-based authentication and automatic runner registration.

This approach allows runners to be fully ready at startup – with all dependencies already installed – removing setup overhead from every workflow execution.

The standard ARC (actions-runner-controller) images do not provide this level of customization. By building our own image, we gain full control over the runtime environment and drastically reduce CI execution time.

In this post, we walk through the full setup of organization-level self-hosted GitHub runners on Amazon EKS using ARC, including:

  • Custom Docker image for runners
  • GitHub App authentication flow
  • Kubernetes deployment via Helm
  • Autoscaling with workload demand

Before starting, ensure the following are available:

  • A Kubernetes cluster (EKS, GKE, AKS, or local via minikube)
  • A GitHub organization and repository
  • Helm installed for Kubernetes package management
  • In your github organization open the Developer setting and create github app.

github
  • Provide the Github app a name:

  •  Provide a Homepage URL

  • Uncheck Webhook URL

  • Permissions

For this demo we gave actions and administrator in Repository and self-hosted runners in the organization permissions.
You can give least privileges:

  • App ID and client ID

  • Generate The Private key

The installation ID is available in the URL — store it for later use.


In the K8 cluster run the following commands:
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm search repo cert-manager
helm install \
cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--version v1.17.0 \
--set prometheus.enabled=false \
--set installCRDs=true
kubectl create secret generic controller-manager -n ${NAMESPACE} \
--from-literal=github_app_id=<github_app_id> \
--from-literal=github_app_installation_id=<installation_id> \
--from-file=github_app_private_key=github.pem

helm repo add actions-runner-controller \
https://actions-runner-controller.github.io/actions-runner-controller

helm install ${ORG}-actions actions-runner-controller/actions-runner-controller \
--namespace ${NAMESPACE} \
--version 0.23.7 \
--values arc-values.yaml \
--set syncPeriod=1m

Create an IAM role and attach it to a Kubernetes service account:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: runner-serviceaccount
  namespace: NAMESPACE
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::${accountid}:role/role-name
  • Copy the docker file with the entrypoint script from my github repo
    add more tools as desired.
  • Authenticate to your AWS ECR repo we used build and push the custom github runner image:

aws ecr get-login-password --region us-east-1 | \
docker login --username AWS --password-stdin <account_id>.dkr.ecr.us-east-1.amazonaws.com

docker build --platform linux/arm64 -t <ecr-repo-url>:latest --push .

In our case we build and deployed to arm64 architecture based machine.

FROM mcr.microsoft.com/dotnet/runtime-deps:6.0-focal

ARG TARGETOS
ARG TARGETARCH
ARG RUNNER_VERSION=2.310.2
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.3.2
ARG DOCKER_VERSION=25.0.5
ENV DEBIAN_FRONTEND=noninteractive

# Install base dependencies
RUN apt-get update -y \
    && apt-get install -y --no-install-recommends \
        sudo curl git jq unzip zip \
        build-essential locales tzdata \
        libyaml-dev tini iptables

# Create runner 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

# Install GitHub Actions Runner
WORKDIR /home/runner
RUN export RUNNER_ARCH=${TARGETARCH} \
    && if [ "$RUNNER_ARCH" = "amd64" ]; then export RUNNER_ARCH=x64 ; fi \
    && curl -f -L -o runner.tar.gz https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-${TARGETOS}-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz \
    && tar xzf ./runner.tar.gz \
    && rm runner.tar.gz

# Install runner container hooks (required for k8s mode)
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 CLI and AWS CLI
RUN export RUNNER_ARCH=${TARGETARCH} \
    && if [ "$RUNNER_ARCH" = "amd64" ]; then export DOCKER_ARCH=x86_64 ; fi \
    && if [ "$RUNNER_ARCH" = "arm64" ]; then export DOCKER_ARCH=aarch64 ; fi \
    && curl -fLo docker.tgz https://download.docker.com/${TARGETOS}/static/stable/${DOCKER_ARCH}/docker-${DOCKER_VERSION}.tgz \
    && tar zxvf docker.tgz \
    && rm -rf docker.tgz \
    && install -o root -g root -m 755 docker/* /usr/bin/ \
    && rm -rf docker \
    && curl "https://awscli.amazonaws.com/awscli-exe-linux-${DOCKER_ARCH}.zip" -o "awscliv2.zip" \
    && unzip awscliv2.zip \
    && ./aws/install

RUN mkdir /opt/hostedtoolcache \
    && chown runner:docker /opt/hostedtoolcache

#############################################
# Install your custom packages here
# Example: Terraform, Packer, kubectl, Helm, Gitleaks, etc.
# Add any tools your CI/CD pipelines need
#############################################

# Copy the custom entrypoint script
USER root
COPY scripts/entrypoint-org.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && chown runner:runner /entrypoint.sh

VOLUME /var/lib/docker

USER runner

ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
CMD ["/home/runner/run.sh"]

The key idea: everything your pipelines need is baked into the image at build time. No more `apt-get install` or `curl | tar` in every workflow run.

Create a runner-deployment manifast change the ‘ECR_REPO’ with you image url:



apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
	name: "self-hosted-runner"
spec:
   replicas: 2
   template:
   spec:
       image: ECR_REPO	
       imagePullPolicy: IfNotPresent	
       organization: ${ORG} # Add your GitHub organization name
    labels:
       - "my-custom-runner" should match as the lable in the entrypoint.sh 
    env:
       - name: GITHUB_ORG
       value: "${ORG}" # Add your GitHub organization name
       - name: GITHUB_APP_ID
    valueFrom:
       secretKeyRef:
       name: controller-manager
       key: github_app_id
       - name: GITHUB_INSTALLATION_ID
       valueFrom:
           secretKeyRef:
              name: controller-manager
              key: github_app_installation_id
        - name: GITHUB_PRIVATE_KEY
           valueFrom:
               secretKeyRef:
	name: controller-manager
	key: github_app_private_key



Lastly create a hpa for the runner, the HPA scales the runners number based on jobs registered in github actions in your organization.

apiVersion: actions.summerwind.dev/v1alpha1
kind: HorizontalRunnerAutoscaler
metadata:
name: self-hosted-runner-hra
namespace: arc-securly-engineering
spec:
scaleDownDelaySecondsAfterScaleOut: 120
scaleTargetRef:
kind: RunnerDeployment
name: "self-hosted-runner"
minReplicas: 2
maxReplicas: 10
metrics:
- type: TotalNumberOfQueuedAndInProgressWorkflowRuns
repositoryNames:
# pass here the repo names that you want to scale on(without the org name)

Key behavior:

  • Keeps 2 runners warm at all times
  • Scales up to 10 based on workload
  • Prevents rapid scale fluctuations
  • Uses GitHub job queue metrics for scaling decisions

This is the glue that makes everything work. The entrypoint script runs when each runner pod starts and handles the full GitHub App authentication and runner registration flow. No secrets are hardcoded – everything comes from environment variables injected via the Kubernetes secret.

#!/bin/bash
set -e
GITHUB_APP_ID="${GITHUB_APP_ID}"
GITHUB_INSTALLATION_ID="${GITHUB_INSTALLATION_ID}"
GITHUB_PRIVATE_KEY="${GITHUB_PRIVATE_KEY}"
RUNNER_NAME=$HOSTNAME
GITHUB_ORG="${GITHUB_ORG}"  # Organization name

# Ensure required environment variables are set
if [[ -z "$GITHUB_APP_ID" || -z "$GITHUB_INSTALLATION_ID" || -z "$GITHUB_PRIVATE_KEY" || -z "$GITHUB_ORG" ]]; then
    echo "ERROR: Missing required GitHub App authentication variables!"
fi

RUNNER_NAME=$HOSTNAME
# Function to generate a JWT for GitHub App authentication
generate_jwt() {
    local header='{"alg":"RS256","typ":"JWT"}'
    local payload='{"iat":'$(date +%s)',"exp":'$(($(date +%s) + 600))',"iss":"'"${GITHUB_APP_ID}"'"}'

    local b64_header=$(echo -n "${header}" | openssl base64 -A | tr -d '=' | tr '/+' '_-')
    local b64_payload=$(echo -n "${payload}" | openssl base64 -A | tr -d '=' | tr '/+' '_-')

    local signature=$(echo -n "${b64_header}.${b64_payload}" | \
        openssl dgst -sha256 -sign <(echo -n "${GITHUB_PRIVATE_KEY}") | \
        openssl base64 -A | tr -d '=' | tr '/+' '_-')

    echo "${b64_header}.${b64_payload}.${signature}"
}
# Generate a JWT for GitHub App authentication
JWT=$(generate_jwt)
# Request an installation token from GitHub
INSTALLATION_TOKEN=$(curl -s -X POST \
    -H "Authorization: Bearer ${JWT}" \
    -H "Accept: application/vnd.github.v3+json" \
    "https://api.github.com/app/installations/${GITHUB_INSTALLATION_ID}/access_tokens" | jq -r .token)

if [[ -z "$INSTALLATION_TOKEN" || "$INSTALLATION_TOKEN" == "null" ]]; then
    echo "ERROR: Failed to obtain installation token"
fi

# Get the runner registration token for the organization
RUNNER_TOKEN=$(curl -s -X POST \
    -H "Authorization: token ${INSTALLATION_TOKEN}" \
    -H "Accept: application/vnd.github.v3+json" \
    "https://api.github.com/orgs/${GITHUB_ORG}/actions/runners/registration-token" | jq -r .token)

if [[ -z "$RUNNER_TOKEN" || "$RUNNER_TOKEN" == "null" ]]; then
    echo "ERROR: Failed to obtain runner registration token"
fi

# Register the runner at the organization level
./config.sh --url "https://github.com/${GITHUB_ORG}" --token "${RUNNER_TOKEN}" --unattended --replace --name "${RUNNER_NAME}" --labels my-custom-runner --ephemeral

echo "Runner registered for organization: ${GITHUB_ORG}"

# Start the runner
exec ./run.sh

Here’s what happens step by step:

  • JWT generation using GitHub App private key Exchange for installation token Retrieval of runner registration token Organization-level runner registration Ephemeral runner execution

Every runner pod is ephemeral – it picks up a job, runs it, and terminates. ARC then spins up a fresh pod to replace it.

After everything is deployed, you’ll be able to see the github self-hosted runner register in your organization by going to one of your repositories -> actions -> runners -> self-hosted runners. 

Using the Runners in Your Workflows:

Once the runners are deployed, you can target them in your GitHub Actions workflows using the label we defined earlier in the entrypoint script (`–labels my-custom-runner`). Just set the value to which label you set in the entrypoint script in your workflow file:

jobs:
  build:
    runs-on: my-custom-runner
    steps:
      - uses: actions/checkout@v4
      - run: terraform --version

The runner will show up in your organization’s runner list with the labels `self-hosted`, `Linux`, `ARM64`, and `my-custom-runner`.

By combining:

  • Custom Docker images
  • GitHub App authentication
  • ARC on Kubernetes
  • Autoscaling runners

we achieve a highly scalable and efficient self-hosted CI/CD execution platform.

Key benefits:

  • Faster CI execution (no runtime installs)
  • Fully controlled build environments
  • Ephemeral and secure runners
  • Scalable Kubernetes-native architecture

Check out more of our blog posts here.

More Posts

The ITGix AWS Landing Zone continues to evolve with a clear goal: enabling organizations to build secure, compliant, and scalable AWS environments with less operational overhead. Across recent releases (v1.2.0...
Reading
In earlier articles, we covered migrating repositories from Bitbucket Cloud, securely updating project dependencies, and modernizing CI/CD workflows by translating Jenkins and Bash-based pipelines to GitHub Actions. With those foundations...
Reading
Get In Touch
ITGix provides you with expert consultancy and tailored DevOps services to accelerate your business growth.
Newsletter for
Tech Experts
Join 12,000+ business leaders and engineers who receive blogs, e-Books, and case studies on emerging technology.