Building a GitHub Self-Hosted Runner Image for Kubernetes (1)
Note: This blog post was enhanced with the help of AI to improve grammar and refine tone. All content and opinions are my own.
I’ve been running GitHub self-hosted runners in my homelab K3s cluster for about a year now. The default GitHub runner images work fine, but I wanted something more customized for my setup - preloaded with Docker, kubectl, Helm, AWS CLI, and a few other tools I use regularly.
Here’s how I built a custom runner image that’s worked well for me. This is the first part covering just the Docker image itself.
Base Image Choice
I started with debian:bookworm-slim instead of Ubuntu. Why? Smaller image size and I like having control over exactly what gets installed. Ubuntu’s great but comes with more stuff I don’t need for a CI runner.
FROM debian:bookworm-slim
🛠 Step 2: Base Utilities and Locale Configuration
RUN apt-get update && apt-get upgrade -y \
&& apt-get install -y --no-install-recommends \
ca-certificates locales 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 \
&& echo "en_US.UTF-8 UTF-8" > /etc/locale.gen \
&& locale-gen \
&& update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
This is our “Swiss Army knife” setup step—updating packages, installing essential tools, and making sure the container speaks UTF-8 English fluently.
🧰 What we’re doing and why:
-
apt-get update && apt-get upgrade -y: Pull in the latest security patches and upgrades right off the bat. -
--no-install-recommends: Avoid installing extras we don’t need. We’re going for minimalism with purpose. -
Installed packages:
ca-certificates: For secure HTTPS connections.locales: To handle character encoding properly.curl,wget,less,zip,unzip,jq,sudo: Essential tools for downloading, viewing, and manipulating files.lsb-release: Helps identify the Linux distribution version.gpg-agent,software-properties-common: Needed for adding external repositories securely.
-
apt-get install --only-upgrade openssh-client: Just in case we have an older version pre-installed—we’re making sure the SSH client is current. Helpful for private repo access or CI SSH deploys. -
Cleanup:
rm -rf /var/lib/apt/lists/*,apt-get clean -y,apt-get autoremove -y: These help keep the image lean and free from unused cache and dependencies.
-
Locale configuration:
- Without this, you may run into weird encoding issues (
UTF-8errors in logs, anyone?). We’re explicitly enabling and generating theen_US.UTF-8locale, and setting it as default.
- Without this, you may run into weird encoding issues (
🔍 TL;DR
This step makes sure our runner image starts off secure, lean, and ready for serious scripting. It also ensures that any logs or strings printed by tools inside the container won’t go haywire due to encoding issues.
🛠 Step 3: Install GitHub CLI (gh)
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
Time to bring in a power tool: the GitHub CLI (gh). This tool makes scripting against GitHub ridiculously easier. Want
to create issues, manage workflows, authenticate tokens, or query repos directly from the command line? gh has your
back.
🧠 Why we’re doing all this
mkdir -p -m 755 /etc/apt/keyrings: Debian’s Bookworm prefers storing third-party GPG keys in/etc/apt/keyringsinstead of the old/etc/apt/trusted.gpg.d. Good hygiene.wget ... | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg: Pulls in GitHub’s official GPG key and places it where Debian expects it.chmod go+r: Ensures the keyring is readable by the APT system.echo "deb ...": Adds GitHub CLI’s APT repository securely, scoped to the key we just installed.apt update && apt install gh -y: Install the CLI from GitHub’s official repo.cleanandautoremove: Again, for image hygiene. Get in, install, get out.
🧰 Why gh Matters in CI/CD
Using GitHub CLI in a self-hosted runner gives you flexibility to:
- Authenticate to GitHub using a token or SSH
- Trigger or inspect workflows
- Manage releases and assets
- Query APIs without writing custom curl + jq spaghetti
It’s one of those tools that might not be required in every job, but when you need it—you’ll be glad it’s there.
🛠 Step 4: Set Up the runner User
# 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
We’re creating a dedicated user named runner here, and giving it just the right amount of privilege to operate
smoothly in a CI/CD setting.
👤 Why this matters:
adduser --disabled-password: Creates the user without login access. Perfect for containerized automation.--uid 1001: Explicit UID assignment for consistent file permissions across environments.groupadd docker --gid 123: Prepares a custom Docker group with a static GID to help manage permissions inside the container.usermod -aG sudo runner: Grants sudo access to our user.usermod -aG docker runner: Allows the user to execute Docker commands without needing root.sudoerslines:- Disables password prompts for sudo.
- Preserves
DEBIAN_FRONTENDenv var, which is useful during scripted installs.
This setup provides a secure, non-root user that still has just enough power to execute builds, deployments, and infrastructure commands reliably.
Perfect for a runner that’s expected to behave like a DevOps multitool.
🛠 Step 5: Set Up the Working Directory
RUN mkdir -p /home/runner \
&& chown -R runner:docker /home/runner
WORKDIR /home/runner
We’re laying the groundwork—literally. This step creates the home directory for our runner user and makes sure it owns
it.
📁 Why it matters:
mkdir -p /home/runner: Creates the directory if it doesn’t exist yet.chown -R runner:docker: Ensures therunneruser (who belongs to thedockergroup) has full ownership. This is important for writing config files, logs, and storing ephemeral data during job execution.WORKDIR /home/runner: Sets the default working directory for all subsequent instructions in the Dockerfile and commands run in the container.
Think of it as the “home base” for your GitHub runner: a safe, writable place to get work done.
🛠 Step 6: Add Job Lifecycle Hooks
COPY --chown=runner:docker run-before-job-start.sh /home/runner/run-before-job-start.sh
RUN chmod +x run-before-job-start.sh
COPY --chown=runner:docker run-after-job-end.sh /home/runner/run-after-job-end.sh
RUN chmod +x run-before-job-start.sh run-after-job-end.sh
ENV ACTIONS_RUNNER_HOOK_JOB_STARTED=/home/runner/run-before-job-start.sh
ENV ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/home/runner/run-after-job-end.sh
This step introduces two scripts that run at key points during a job’s lifecycle: before it starts and after it ends. These are useful for setup and teardown tasks.
🔄 What’s going on here:
COPY --chown=runner:docker: Ensures therunneruser owns these scripts right out of the gate.run-before-job-start.sh: This might include steps like configuring credentials, fetching secrets, or pre-warming tools.run-after-job-end.sh: Could handle things like cleaning up temporary files, unmounting volumes, or sending job summaries to a webhook.chmod +x: Gives both scripts execution permission so they’re ready to roll.
📂 For Now: Just Placeholders
These scripts are empty for now—but that’s intentional! You can treat them as lifecycle hooks and fill them in later when your CI/CD needs grow more complex.
🧪 Why it matters:
Lifecycle hooks help you manage everything around the job, not just the job itself. This gives your CI environment more reliability, control, and polish—especially when you’re operating in a complex cloud or Kubernetes setup.
🛠 Step 7: Download and Install GitHub Actions Runner
# Runner download and install
RUN export RUNNER_VERSION=$(curl "https://api.github.com/repos/actions/runner/releases" | jq -r '.[0].tag_name' | cut -c2-) \
&& curl -L -o runner.tar.gz "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz" \
&& tar xzf ./runner.tar.gz \
&& rm runner.tar.gz \
&& ./bin/installdependencies.sh
Time to bring in the core engine—the GitHub Actions Runner itself.
🧠 Why this is done dynamically:
- GitHub doesn’t offer long-term support (LTS) for runner versions.
- By using the GitHub API, we always grab the latest stable release—no need to hardcode versions.
- This keeps your image future-proof and aligned with GitHub’s own upgrade cadence.
🧰 What this does:
curl+jq: Fetches the latest release tag from GitHub’s API.cut -c2-: Strips the leadingvfrom the version string (e.g.,v2.316.0→2.316.0).curl -L ...: Downloads the release tarball for thearm64architecture.tar xzf: Extracts the runner binary and support files../bin/installdependencies.sh: Installs native dependencies the runner needs, likelibicu,libcurl, etc.
🚀 Why it matters:
This is the heart of your GitHub runner pod. Without this, your container is just a smart Linux box. With it, it becomes a full-fledged participant in your GitHub CI/CD pipeline—ready to pick up jobs and do real work.
And because we’re pulling the latest version on build, you’ll never get caught off guard by a deprecated runner version.
🛠 Step 8: Install GitHub Runner Container Hooks
# Install container hooks
RUN export RUNNER_CONTAINER_HOOKS_VERSION=$(curl "https://api.github.com/repos/actions/runner-container-hooks/releases" | jq -r '.[0].tag_name' | cut -c2-) \
&& 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
This step brings in container hooks, which are an essential companion to GitHub self-hosted runners running inside Kubernetes.
🔍 What are container hooks?
These are scripts and tools used by the GitHub Actions Runner Controller to manage the lifecycle of runner pods more intelligently. Think of them as Kubernetes-native integrations for:
- Injecting job environment info
- Managing pre-job/post-job state
- Supporting graceful pod shutdowns
💡 Why it works like this:
curl+jq: Fetches the latest version dynamically, again avoiding version drift.unzip ... -d ./k8s: We extract it into ak8sfolder in the runner directory, where the controller expects to find the hooks.rm: Clean up the downloaded archive to keep the image size lean.
These hooks are what allow your Kubernetes runner to behave more like a first-class citizen in the GitHub Actions ecosystem—especially when using advanced job features or scaling setups.
🛠 Step 9: Install Docker and Buildx
# Install Docker
RUN export BUILDX_VERSION=$(curl "https://api.github.com/repos/docker/buildx/releases" | jq -r '.[0].tag_name' | cut -c2-) \
&& curl -fLo docker.tgz "https://download.docker.com/linux/static/stable/aarch64/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-arm64" \
&& chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx
RUN install -o root -g root -m 755 docker/* /usr/bin/ && rm -rf docker
Now we’re bringing in Docker itself—along with Buildx, which is Docker’s official CLI plugin for multi-platform builds.
🐳 What’s going on here:
DOCKER_VERSIONshould be set externally (likely via--build-arg) to pick the correct Docker version.- We download and extract the static binaries for Docker CLI and related tools.
install -o root -g root -m 755: Moves the Docker binaries into/usr/bin, making them globally available in the container.buildx: This is Docker’s multi-platform build plugin. It allows you to rundocker buildx build, useful if you’re doing cross-architecture builds (like buildingarm64images on ax86host).
🧠 Why we do this manually:
Installing Docker via apt often brings in unnecessary extras or outdated versions. By going the manual route:
- You control the version
- You keep the image slim
- You ensure compatibility with ARM (
aarch64) architecture
This step ensures your GitHub runner is capable of running Docker-based workflows and even building images across platforms. A must-have for modern CI/CD pipelines.
🛠 Step 10: Install kubectl and Helm
# Install kubectl and helm
RUN curl -LO "https://dl.k8s.io/release/v1.31.1/bin/linux/arm64/kubectl" \
&& chmod +x kubectl \
&& mv kubectl /usr/local/bin/
RUN curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
Now we’re adding tools that let the runner interact directly with Kubernetes: kubectl and helm.
🔧 Why this matters:
kubectl: This is the CLI tool for communicating with Kubernetes clusters. Many CI/CD jobs involve deploying to a cluster, inspecting pods, or applying manifests.helm: Often called the “package manager” for Kubernetes, Helm simplifies deployment of complex applications with templated configuration.
🔍 Why it’s done like this:
- We’re grabbing the specific version of
kubectl(v1.31.1) directly from the official Kubernetes release site. This ensures version consistency, especially useful if your cluster requires exact client-server matching. - The Helm script from GitHub’s official repo fetches and installs the latest version of Helm 3. It’s battle-tested and reliable.
This combo gives your self-hosted runner the ability to deploy, configure, and manage Kubernetes-based infrastructure directly from GitHub Actions workflows. Essential for GitOps workflows!
🛠 Step 11: Install AWS CLI
# Install AWS CLI
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip" -o "awscliv2.zip" \
&& unzip awscliv2.zip \
&& ./aws/install
We’re closing out the setup by adding the AWS CLI, another essential tool in the modern DevOps toolkit.
☁️ Why it’s here:
Many GitHub Actions workflows involve interacting with AWS—whether it’s pushing artifacts to S3, deploying with CloudFormation, or managing EKS clusters. The AWS CLI makes all of this possible from within the runner.
🛠️ Breakdown:
curl: Downloads the official AWS CLI v2 package for ARM64 architecture.unzip: Extracts the installer../aws/install: Runs the installation script, placing theawscommand in your path.
✅ Bonus Tip:
No cleanup step? Totally optional here. The AWS install script does a decent job of managing leftovers. If you’re
optimizing for size, you can always follow up with rm -rf aws awscliv2.zip.
This rounds out your GitHub self-hosted runner image with everything it needs to deploy, manage, and automate in AWS cloud environments.
🛠 Step 12: Switch to Non-Root User
USER runner
This final step tells Docker to run the container as the runner user going forward.
🔐 Why it matters:
Running containers as root is a big security no-no—especially when the container runs untrusted code, like CI jobs
from various contributors. This simple directive switches execution to the non-root runner user we set up earlier.
✅ Good practice:
- Protects the host and container from potential escalation vulnerabilities.
- Ensures any temporary files or job artifacts are created with limited permissions.
- Makes your CI setup more production-ready and security-compliant.
With this line, we cap off our secure, fully-equipped self-hosted GitHub Actions runner image. Ready to plug into your Kubernetes cluster!
✅ Final Thoughts
We’ve walked through building a fully loaded, security-conscious GitHub self-hosted runner image tailored for Kubernetes. This image includes all the must-haves:
- Lightweight Debian base
- Secure, non-root user setup
- Lifecycle hook scripts
- GitHub CLI and official runner
- Docker, Buildx, kubectl, Helm, AWS CLI
It’s optimized for flexibility and future-proofed with version-aware installation logic. Perfect for running in your own GitHub Actions Runner Controller setup inside a Kubernetes cluster.
Here’s the complete Dockerfile:
FROM debian:bookworm-slim
ARG DOCKER_VERSION="27.5.1"
RUN apt-get update && apt-get upgrade -y \
&& apt-get install -y --no-install-recommends \
ca-certificates locales 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 \
&& echo "en_US.UTF-8 UTF-8" > /etc/locale.gen \
&& locale-gen \
&& update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
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
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
RUN mkdir -p /home/runner \
&& chown -R runner:docker /home/runner
WORKDIR /home/runner
COPY --chown=runner:docker run-before-job-start.sh /home/runner/run-before-job-start.sh
RUN chmod +x run-before-job-start.sh
COPY --chown=runner:docker run-after-job-end.sh /home/runner/run-after-job-end.sh
RUN chmod +x run-before-job-start.sh run-after-job-end.sh
ENV ACTIONS_RUNNER_HOOK_JOB_STARTED=/home/runner/run-before-job-start.sh
ENV ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/home/runner/run-after-job-end.sh
RUN export RUNNER_VERSION=$(curl "https://api.github.com/repos/actions/runner/releases" | jq -r '.[0].tag_name' | cut -c2-) \
&& curl -L -o runner.tar.gz "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz" \
&& tar xzf ./runner.tar.gz \
&& rm runner.tar.gz \
&& ./bin/installdependencies.sh
RUN export RUNNER_CONTAINER_HOOKS_VERSION=$(curl "https://api.github.com/repos/actions/runner-container-hooks/releases" | jq -r '.[0].tag_name' | cut -c2-) \
&& 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
RUN export BUILDX_VERSION=$(curl "https://api.github.com/repos/docker/buildx/releases" | jq -r '.[0].tag_name' | cut -c2-) \
&& curl -fLo docker.tgz "https://download.docker.com/linux/static/stable/aarch64/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-arm64" \
&& chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx
RUN install -o root -g root -m 755 docker/* /usr/bin/ && rm -rf docker
RUN curl -LO "https://dl.k8s.io/release/v1.31.1/bin/linux/arm64/kubectl" \
&& chmod +x kubectl \
&& mv kubectl /usr/local/bin/
RUN curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip" -o "awscliv2.zip" \
&& unzip awscliv2.zip \
&& ./aws/install
USER runner