Zero-Trust CI/CD: Building Secure Self-Hosted GitHub Actions Runners via Reverse Tunnels

 IT

InstaTunnel Team
Published by our engineering team
Zero-Trust CI/CD: Building Secure Self-Hosted GitHub Actions Runners via Reverse Tunnels

Modern engineering teams are caught in a familiar bind: cloud-hosted CI/CD runners are expensive and underpowered for demanding workloads, but routing a public platform like GitHub Actions into a firewall-protected corporate network sounds like an IT nightmare. There is a third path — one that uses outbound-only reverse tunnels, ephemeral containers, and identity-gated authentication to give you bare-metal performance without opening a single inbound port. This article is a production-grade blueprint for building that architecture.


The Problem With “Just Open a Port”

The conventional approach to self-hosted runners assumes inbound connectivity. A runner on your local network registers with GitHub and then sits waiting for jobs to be dispatched to it. For this to work, GitHub needs to be able to reach your machine — which typically means punching a hole in your firewall.

This approach collides with corporate security policy almost immediately. Inbound ports require firewall rule changes, network architecture reviews, and ongoing maintenance of IP allowlists. GitHub operates from dynamic IP address ranges spanning multiple data centers worldwide — maintaining and updating those CIDR blocks is an operational burden that doesn’t scale.

There’s a cleaner model: flip the connection direction entirely.

A self-hosted runner communicates with GitHub via an HTTP(S) long poll. It opens a connection to GitHub for up to 50 seconds waiting for job assignments. If nothing arrives, it times out and reconnects. The traffic is entirely outbound on port 443 — the same port your browser uses for HTTPS. Corporate firewalls that allow web browsing already allow this traffic. No exceptions required.


The Reverse Tunnel Architecture

The key insight is that the runner doesn’t need to receive inbound connections from GitHub — it goes out and fetches work. A reverse tunnel agent compounds this advantage by creating a persistent, authenticated outbound channel that can also carry any webhook or control-plane traffic that does need to reach your machine.

[ Local Bare-Metal Hardware ]
        |
        | (Outbound TLS/QUIC on port 443)
        ▼
[ Identity-Gated Tunnel Proxy ]  ◄─── Mutual TLS / Token Verification
        ▲
        |  (Webhook / Poll Traffic)
        |
[ GitHub Actions Control Plane ]

Because the connection is initiated from inside your network, the firewall sees it as just another HTTPS request. The tunnel proxy authenticates both ends before any traffic flows, and all job dispatch happens over this verified channel.


Identity-Gated Security: More Than Just a Tunnel

Passing traffic through an outbound tunnel is only as secure as the authentication protecting it. Production deployments combine several layers:

Mutual TLS (mTLS): Both the runner client and the tunnel proxy present cryptographically signed certificates. Neither side accepts a connection from an unauthenticated peer.

OIDC / OAuth2 binding: The tunnel session can be tied to verified identity providers — Okta, Google Workspace, or GitHub Organization OIDC tokens — so that only runners belonging to your organization can establish sessions.

Short-lived ephemeral tokens: The registration token used to connect a runner to GitHub is fetched dynamically from the GitHub API at the start of each pipeline run and invalidated immediately when the job completes. There is no long-lived credential sitting on disk.

These controls mean that even though your local hardware is reachable via the tunnel, it is not discoverable, scannable, or accessible by anything that can’t present the correct cryptographic identity.


The Real Threat Model (Updated for 2025–2026)

Before building this infrastructure, it’s worth being clear-eyed about what can go wrong.

In November 2025, the Shai-Hulud worm demonstrated at scale that self-hosted runners can be weaponized as persistent backdoors communicating entirely over GitHub’s own trusted channels. Because all traffic flows to github.com, traditional network defenses are largely blind to this class of threat. GitHub’s own security documentation is explicit: “Self-hosted runners for GitHub do not have guarantees around running in ephemeral clean virtual machines, and can be persistently compromised by untrusted code in a workflow.”

Earlier supply chain attacks, including the poisoning of the tj-actions/changed-files Action and the Codecov breach, demonstrated that attackers increasingly target CI/CD pipelines because those pipelines typically hold privileged access to production infrastructure, cloud credentials, and package registries.

The architecture described in this article is designed to contain these threats — not ignore them.


Step-by-Step Blueprint

Component 1: The Ephemeral Runner Container

The first rule of self-hosted runner security is that the runner must never execute untrusted code directly on the host operating system. A containerized, single-use environment limits blast radius if something goes wrong.

Version note: As of March 16, 2026, GitHub requires self-hosted runners to be at minimum version v2.329.0 (released October 15, 2025). Runners older than this are blocked at registration time. The current stable release as of this writing is v2.334.0. Always pull the latest from the actions/runner releases page.

FROM ubuntu:22.04

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get install -y \
    curl \
    sudo \
    git \
    jq \
    build-essential \
    ca-certificates \
    && rm -rf /var/lib/apt/lists/*

# Non-root runner user
RUN useradd -m -s /bin/bash runner && \
    usermod -aG sudo runner && \
    echo "runner ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers

USER runner
WORKDIR /home/runner

# Pin to a specific version >= v2.329.0; check releases page for latest
ARG RUNNER_VERSION="2.334.0"

RUN curl -o github-runner.tar.gz -L \
    https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz \
    && tar xzf ./github-runner.tar.gz \
    && rm github-runner.tar.gz

COPY --chown=runner:runner entrypoint.sh /home/runner/entrypoint.sh
RUN chmod +x /home/runner/entrypoint.sh

ENTRYPOINT ["./entrypoint.sh"]

The --privileged flag must never appear in the docker run invocation for this container. A privileged container has nearly the same access to the host as a root process running directly on the OS.

Component 2: The Entrypoint Script

The entrypoint.sh orchestrates registration, execution, and cleanup. It fetches a short-lived registration token from the GitHub API on each invocation — there is no persistent token stored in the image.

#!/bin/bash
set -e

if [ -z "$GH_OWNER" ] || [ -z "$GH_REPOSITORY" ] || [ -z "$GH_PAT" ]; then
    echo "ERROR: Missing required environment variables."
    exit 1
fi

echo "Fetching short-lived registration token..."
REG_TOKEN=$(curl -s -X POST \
    -H "Authorization: token ${GH_PAT}" \
    -H "Accept: application/vnd.github.v3+json" \
    https://api.github.com/repos/${GH_OWNER}/${GH_REPOSITORY}/actions/runners/registration-token \
    | jq -r '.token')

if [ "$REG_TOKEN" == "null" ] || [ -z "$REG_TOKEN" ]; then
    echo "ERROR: Failed to fetch registration token. Verify PAT permissions."
    exit 1
fi

./config.sh \
    --url https://github.com/${GH_OWNER}/${GH_REPOSITORY} \
    --token "${REG_TOKEN}" \
    --name "ephemeral-$(hostname)" \
    --labels "local-highperf,ephemeral" \
    --unattended \
    --replace

cleanup() {
    echo "Job complete. Deregistering runner..."
    REM_TOKEN=$(curl -s -X POST \
        -H "Authorization: token ${GH_PAT}" \
        -H "Accept: application/vnd.github.v3+json" \
        https://api.github.com/repos/${GH_OWNER}/${GH_REPOSITORY}/actions/runners/registration-token \
        | jq -r '.token')
    ./config.sh remove --token "${REM_TOKEN}"
}

trap 'cleanup' EXIT SIGINT SIGTERM

# --once: accept one job, execute it, then exit
./run.sh --once

The --once flag is critical. It instructs the runner agent to accept exactly one job, run it to completion, and then terminate. Combined with a systemd service or cron job that destroys the container after exit and spins a fresh one from the clean base image, this eliminates persistent state between runs.

Component 3: The Tunnel Agent

With the runner container ready, a tunnel agent bridges your local network to GitHub’s control plane. Cloudflare Tunnel (cloudflared) is a widely-used open-source option that establishes an outbound-only connection authenticated against your Cloudflare Zero Trust account. An equivalent configuration for frp or other agents follows the same logical structure.

A minimal tunnel.yaml for a Cloudflare Tunnel deployment:

tunnel: local-ci-runner-tunnel
credentials-file: /home/runner/.cloudflared/tunnel-auth.json

ingress:
  - hostname: ci-proxy.yourcompany.com
    service: http://localhost:8080
  - service: http_status:404

The hostname ci-proxy.yourcompany.com should be protected by Cloudflare Access policies so that only authenticated webhook traffic originating from GitHub’s documented IP ranges can route through the tunnel. Everything else receives a 404.

The tunnel token itself should be passed as an environment variable at container launch time, never baked into an image:

docker run --detach \
  --restart always \
  --network runner-net \
  --name cloudflared \
  cloudflare/cloudflared:latest \
  tunnel --no-autoupdate run --token "${TUNNEL_TOKEN}"

Security Hardening: The Non-Negotiable Constraints

Reverse tunnels eliminate the inbound exposure problem, but they do not protect against malicious code executing inside your perimeter once a job begins. The following hardening controls are required in any production deployment.

1. Hard Isolation Boundaries

The runner must execute inside a container, and that container must run inside a dedicated virtual machine where possible. Firecracker microVMs (used by Actuated and similar services) provide hardware-assisted isolation: if a container breakout occurs via a kernel vulnerability, the attacker is still contained within a throwaway VM with no access to the host OS or network.

Minimum requirements: - No --privileged flag on runner containers - Non-root user inside the container (as shown in the Dockerfile above) - No volume mounts that expose host filesystem paths to the build environment - Container image rebuilt from a clean base for every job; no image reuse with cached state

2. Network Segmentation (VLAN Sandboxing)

The physical machine hosting the runner should sit in a dedicated, isolated VLAN — a development sandbox or DMZ segment — with firewall rules that explicitly block:

  • All traffic from the runner VLAN toward internal corporate networks, file shares, and database clusters
  • All outbound internet traffic except to the specific domains required: *.github.com, *.githubusercontent.com, and your tunnel provider endpoints

This prevents a compromised runner from being used for lateral movement into the rest of your network even if the container and VM isolation layers fail.

3. Gating Untrusted Pull Requests

GitHub’s documentation explicitly recommends against running self-hosted runners on public repositories. Any contributor who can fork the repository and open a pull request can potentially trigger your self-hosted runner. For private repositories, enforce these controls:

  • Require maintainer approval before running workflows for pull requests from external contributors or first-time forks
  • Use environment protection rules and branch protections so that the local-highperf runner label is only invocable from protected branches (main, release/*)
  • Treat the runner as completely untrusted infrastructure that could be compromised at any time — the architecture should be designed to be safe regardless

4. Ephemeral Runners Are Non-Negotiable

Persistent runners accumulate state. A job that caches malicious binaries globally, leaves a rogue process running, or modifies environment variables can poison subsequent jobs even from unrelated pull requests. The --once flag and the destroy-and-rebuild orchestration loop are the technical enforcement of the ephemeral principle.

Note that GitHub acknowledges this control “might not be as effective as intended, as there is no way to guarantee that a self-hosted runner only runs one job.” This is not a reason to skip it — it still raises the bar significantly.


The Autoscaling Layer (2026 Update)

GitHub introduced the Runner Scale Set Client in public preview in early 2026 — a standalone Go-based module that lets teams build custom autoscaling solutions for GitHub Actions runners without requiring Kubernetes. This is distinct from Actions Runner Controller (ARC), which remains the recommended Kubernetes-based autoscaling path.

For bare-metal environments without Kubernetes, the Scale Set Client allows you to build event-driven orchestration that: - Listens for job queued events via GitHub’s scale set APIs - Spins up a fresh runner container on available hardware - Destroys the container after the job completes

This provides true elastic capacity on fixed infrastructure — idle hardware costs nothing in runner minutes, and you only burn capacity when builds are actually running.


Pricing Reality Check (2026)

The economics of self-hosted runners shifted in early 2026. GitHub introduced a $0.002 per-minute platform charge for self-hosted runner usage in private repositories, effective March 1, 2026, covering the Actions control plane (job orchestration, scheduling, workflow automation). This charge applies regardless of where your runners are hosted — your data center, AWS, or bare metal under your desk.

Public repositories remain free. GitHub Enterprise Server is unaffected.

What this means in practice: self-hosted runners are no longer zero-cost from GitHub’s side even if your compute cost is zero. For a team running 3,000 minutes per month beyond the free quota, the platform charge adds $2/month — negligible for most teams. For high-volume CI environments, the math still strongly favors bare metal for compute-intensive workloads, but the platform charge needs to be factored into your cost model.

GitHub-hosted runner prices also dropped by approximately 40% on January 1, 2026, which changes some comparisons with managed runner alternatives.

DimensionGitHub-Hosted RunnersBare-Metal via Reverse Tunnel
Compute costPer-minute (reduced ~40% in Jan 2026)Fixed hardware capex; zero marginal compute
Platform chargeIncluded in runner price$0.002/min for private repos (from Mar 2026)
Inbound exposureHosted on GitHub infrastructureZero inbound ports required
Hardware controlStandard T-shirt sizesFull control: Apple Silicon, GPU clusters, FPGAs
IsolationManaged ephemeral VMsYour responsibility; requires explicit hardening
Public repo costFreeFree

Real-World Performance: The macOS Build Case

Consider the canonical example: a mobile development team compiling iOS applications. GitHub’s M2-powered macOS runners (available on macos-latest-xlarge and similar labels since late 2025) are significantly faster than previous cloud macOS options — but they still run in a virtualized environment with shared resources.

An idle Mac Studio with an M2 Ultra chip sitting in a corporate office, configured as a self-hosted runner via a Cloudflare Tunnel, delivers native bare-metal execution. Build times for a representative iOS application drop from 30–45 minutes on cloud-hosted runners to under 4 minutes on local hardware. Over a year of active development, this difference compounds into thousands of hours of recovered engineering time.

The architecture described in this article makes that hardware accessible to GitHub Actions without requiring any changes to your corporate firewall.


Summary

The combination of outbound-only reverse tunnels, ephemeral containerized runner environments, and identity-gated authentication solves the core tension between security and performance in self-hosted CI/CD:

  • No inbound ports. The runner initiates all connections outbound on port 443.
  • No persistent credentials on disk. Registration tokens are fetched dynamically and expire immediately.
  • No persistent state. The --once flag and container teardown ensure each job runs in a clean environment.
  • No lateral movement. VLAN segmentation and explicit firewall rules contain the runner even if it’s compromised.
  • No surprises on cost. The 2026 platform charge is small for most teams but should be modeled in advance.

Keep your runner version at v2.329.0 or later — GitHub began blocking older versions at registration time as of March 2026. Check the actions/runner releases page and update your Dockerfile ARG RUNNER_VERSION accordingly.

The architecture requires intentional setup and ongoing maintenance. But for teams with compute-intensive workloads, specialized hardware requirements, or security postures that demand local execution, it represents a well-understood and production-proven path.

Continue from this article into the most relevant product guides and workflows.

Related Topics

#self-hosted CI runner tunnel, secure GitHub Actions runner, local CI/CD hardware proxy, firewall bypass DevOps, ephemeral CI/CD runners, GitLab CI local tunnel, alternative to cloud hosted runners, self-hosted runner security 2026, on-premise build runners, bare-metal CI/CD proxy, reverse proxy for GitHub Actions, identity-gated tunnels, secure webhooks for CI/CD, connecting local hardware to GitHub, hybrid CI/CD pipeline, enterprise DevOps security, Apple Silicon CI runner, custom GPU hardware build runner, docker container build runner, ephemeral build agents, zero-trust pipeline networking, tunneling for webhooks, automated build pipeline proxy, cost-effective CI/CD scaling, infrastructure as code runner, private build network, remote build executor tunnel, self-hosted runner scaling, secure reverse tunnels, cloud infrastructure cost optimization

Comments