Designing Fast, Safe CI/CD Pipelines with Self-Hosted GitHub Actions Runners on AWS

Designing Fast, Safe CI/CD Pipelines with Self-Hosted GitHub Actions Runners on AWS

Self-hosted GitHub Actions runners can make CI dramatically faster: builds run close to Amazon ECR, private package registries, test databases, and deployment targets. But a long-lived build server is also a long-lived place for credentials, source code, caches, and an attacker to hide.

The design in this guide avoids that trap. GitHub Actions Runner Controller (ARC) creates one ephemeral runner Pod for each job on Amazon EKS. Karpenter supplies EC2 capacity when the queue grows, GitHub OIDC issues short-lived AWS credentials, and strict trust boundaries prevent unreviewed pull requests from reaching privileged infrastructure.

What we are building

Our example repository is acme/payments-api. A GitHub Actions job targeting aws-eks-runners is picked up by an ARC listener. ARC creates a fresh runner Pod in the arc-runners namespace, the job executes once, and the Pod is deleted. Karpenter provisions dedicated nodes in private subnets and consolidates them after demand disappears.

The important boundaries are:

  • ARC’s controller runs in arc-systems; runners run in arc-runners.
  • Runner nodes are tainted and contain no application workloads.
  • The scale set is restricted through the production-ci runner group.
  • AWS access comes from OIDC and a branch- or environment-scoped IAM role—not access keys.
  • Pull requests from forks never execute privileged code on this runner pool.

GitHub recommends ephemeral runners for autoscaling because each runner accepts exactly one job. It also recommends isolating production workloads from runner workloads because a workflow is arbitrary code.

Prerequisites

You need an EKS cluster, kubectl, Helm 3, AWS CLI v2, Karpenter, and organization-owner access in GitHub. The examples use cluster ci-runners-prod in eu-west-1.

Confirm your local context before changing anything:

aws eks update-kubeconfig --name ci-runners-prod --region eu-west-1
kubectl config current-context
kubectl get nodes
helm version

Create separate namespaces for the controller and runner Pods:

kubectl create namespace arc-systems
kubectl create namespace arc-runners

Register a GitHub App for ARC

At organization level, create a GitHub App for ARC. Give it Organization permissions of Self-hosted runners: Read and write and Repository permissions of Administration: Read and write only when repository-level registration requires it. Install it only on the organization or repositories that the scale set serves.

Record the App ID and installation ID, then download its private key. Put the resulting Kubernetes Secret in the runner namespace; do not put the PEM in Helm values or source control.

kubectl -n arc-runners create secret generic arc-github-app \
  --from-literal=github_app_id='123456' \
  --from-literal=github_app_installation_id='654321' \
  --from-file=github_app_private_key='./acme-arc.private-key.pem'

For a real platform, sync this Secret from AWS Secrets Manager with External Secrets Operator and rotate the private key on a schedule.

Install Actions Runner Controller

Install the ARC controller from GitHub’s OCI Helm registry. Pin a tested chart version in production rather than silently taking a new release.

helm upgrade --install arc \
  --namespace arc-systems \
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller

Wait for the controller to become ready:

kubectl -n arc-systems rollout status \
  deployment/arc-gha-rs-controller --timeout=180s
kubectl -n arc-systems get pods

Configure the runner scale set

The runner values below keep two warm runners for low queue latency and cap concurrency at 30. Runner Pods request enough CPU and memory for ordinary builds, tolerate only the CI node taint, and select the dedicated Karpenter NodePool.

# arc-runners-values.yaml
githubConfigUrl: https://github.com/acme
githubConfigSecret: arc-github-app

runnerScaleSetName: aws-eks-runners
runnerGroup: production-ci
minRunners: 2
maxRunners: 30

template:
  spec:
    serviceAccountName: github-actions-runner
    nodeSelector:
      workload: github-actions
    tolerations:
      - key: ci.acme.io/runner
        operator: Equal
        value: "true"
        effect: NoSchedule
    containers:
      - name: runner
        image: ghcr.io/actions/actions-runner:latest
        command: ["/home/runner/run.sh"]
        resources:
          requests:
            cpu: "2"
            memory: 4Gi
          limits:
            cpu: "4"
            memory: 8Gi

Install it into the separate namespace:

helm upgrade --install aws-eks-runners \
  --namespace arc-runners \
  -f arc-runners-values.yaml \
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set

Check the Helm releases and listener:

helm list -A
kubectl -n arc-runners get autoscalingrunnersets
kubectl -n arc-runners get pods -w

The scale set should appear online in GitHub under Organization settings → Actions → Runners. The installation name becomes the value used by runs-on.

GitHub Actions runner scale set online

GitHub Actions shows the aws-eks-runners scale set online, with six idle runners and two active jobs in the production-ci runner group.

Add an isolated Karpenter NodePool

Runners should not share nodes with customer-facing workloads. The NodePool below uses current karpenter.sh/v1 APIs, applies a hard taint, accepts both Spot and On-Demand capacity, and gives Karpenter several CPU families to choose from.

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: github-actions
spec:
  template:
    metadata:
      labels:
        workload: github-actions
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: github-actions
      taints:
        - key: ci.acme.io/runner
          value: "true"
          effect: NoSchedule
      requirements:
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64"]
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["c", "m", "r"]
        - key: karpenter.k8s.aws/instance-generation
          operator: Gt
          values: ["5"]
      expireAfter: 168h
  limits:
    cpu: "200"
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 2m

The EC2NodeClass selects private subnets and runner-specific security groups using discovery tags. It also encrypts the root disk and enforces IMDSv2.

apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: github-actions
spec:
  role: KarpenterNodeRole-ci-runners-prod
  amiSelectorTerms:
    - alias: al2023@latest
  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: ci-runners-prod
        network.acme.io/tier: private
  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: ci-runners-prod
  metadataOptions:
    httpEndpoint: enabled
    httpProtocolIPv6: disabled
    httpPutResponseHopLimit: 1
    httpTokens: required
  blockDeviceMappings:
    - deviceName: /dev/xvda
      ebs:
        volumeSize: 80Gi
        volumeType: gp3
        encrypted: true
        deleteOnTermination: true

Apply both resources and confirm readiness:

kubectl apply -f github-actions-nodepool.yaml
kubectl get nodepool github-actions
kubectl get ec2nodeclass github-actions

Use OIDC instead of AWS access keys

The runner itself does not need broad AWS credentials. A workflow asks GitHub for a signed OIDC token, and AWS STS exchanges it for a short-lived role session. The trust policy must constrain both aud and sub.

This example permits only the protected production GitHub environment in acme/payments-api:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
    },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
        "token.actions.githubusercontent.com:sub": "repo:acme/payments-api:environment:production"
      }
    }
  }]
}

Attach a least-privilege policy. A build role might push only to one ECR repository; a deployment role should be separate and protected by environment approval.

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "ecr:GetAuthorizationToken"
    ],
    "Resource": "*"
  }, {
    "Effect": "Allow",
    "Action": [
      "ecr:BatchCheckLayerAvailability",
      "ecr:CompleteLayerUpload",
      "ecr:InitiateLayerUpload",
      "ecr:PutImage",
      "ecr:UploadLayerPart"
    ],
    "Resource": "arn:aws:ecr:eu-west-1:123456789012:repository/payments-api"
  }]
}

Build the workflow

Start every workflow with minimal token permissions. Grant id-token: write only to the job that exchanges the OIDC token, and pin third-party actions to full commit SHAs in production.

name: CI / Build and Deploy

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  test:
    runs-on: aws-eks-runners
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci --ignore-scripts
      - run: npm test

Keep cloud authentication in a separate build job and run it only for trusted refs:

  build-and-push:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    needs: test
    runs-on: aws-eks-runners
    environment: production
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-payments-build
          aws-region: eu-west-1
      - uses: aws-actions/amazon-ecr-login@v2
        id: ecr
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.ecr.outputs.registry }}/payments-api:${{ github.sha }}
          cache-from: type=gha,scope=payments-api
          cache-to: type=gha,mode=max,scope=payments-api
Successful GitHub Actions pipeline on AWS runners

The workflow completed on the production-ci runner group, including image scanning, an ECR push, and staging deployment.

Choose a container-build mode deliberately

ARC supports Docker-in-Docker and Kubernetes container modes. DinD is familiar, but its daemon normally requires privileged access. Treat that runner pool as trusted infrastructure, isolate its nodes and network, and never route arbitrary fork code to it.

For stronger boundaries, use ARC’s Kubernetes mode so job containers are separate Pods. It requires each workflow job to declare a container unless you weaken the default guardrail.

containerMode:
  type: kubernetes
  kubernetesModeWorkVolumeClaim:
    accessModes: ["ReadWriteOnce"]
    storageClassName: gp3
    resources:
      requests:
        storage: 20Gi

If your pipeline needs ordinary Dockerfiles without privileged DinD, consider a rootless BuildKit image and export cache layers to ECR or GitHub Actions cache. Benchmark your own workload; cached dependency installation often matters more than raw CPU.

Keep untrusted pull requests away from privileged runners

Self-hosted runners should be assumed reachable by whatever code a workflow executes. A malicious fork can read the workspace, probe the network, consume compute, or attack a privileged Docker daemon.

Use this two-lane design:

  1. Fork pull requests run linting and unit tests on GitHub-hosted runners with no secrets.
  2. Trusted branch pushes or manually approved environments use aws-eks-runners.
  3. Never use pull_request_target to check out and execute an untrusted pull request head.
  4. Restrict the runner group to selected repositories and require CODEOWNERS review for workflow changes.

A simple routing expression makes the public lane explicit:

runs-on: ${{
  github.event_name == 'pull_request' &&
  github.event.pull_request.head.repo.fork &&
  'ubuntu-latest' || 'aws-eks-runners'
}}

For high assurance, use separate workflow files and repository rulesets rather than relying only on a compact expression.

Network and cluster hardening

Place runner nodes in private subnets. Permit outbound HTTPS only to the GitHub endpoints ARC and runners require, plus approved registries and package mirrors. Add VPC endpoints for ECR API, ECR Docker, S3, CloudWatch Logs, and STS where practical. Do not expose runner Pods through a Service or public load balancer.

Apply a default-deny NetworkPolicy, then allow DNS and explicitly required destinations through your chosen egress control.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: runner-default-deny
  namespace: arc-runners
spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]
  ingress: []
  egress: []

Use Pod Security Admission, a restricted runner service account, read-only access wherever possible, and admission rules that reject host networking, host PID, hostPath volumes, and unexpected privileged containers. Kubernetes mode requires carefully scoped permissions to create job Pods; keep those permissions namespace-local.

Observe queue time, startup time, and failures

Job duration alone does not tell you whether the platform is fast. Track:

  • queued-to-assigned latency in GitHub;
  • runner Pod pending and image-pull time;
  • Karpenter NodeClaim launch time and Spot interruption rate;
  • active, idle, and failed runner counts;
  • controller, listener, and ephemeral-runner logs;
  • ECR cache hit rate and bytes transferred.

GitHub explicitly recommends forwarding ephemeral-runner logs to external storage. Send container logs to CloudWatch or your central log platform before Pods disappear.

kubectl -n arc-runners get pods -o wide
kubectl -n arc-runners logs -l \
  actions.github.com/scale-set-name=aws-eks-runners \
  --all-containers --prefix
kubectl get nodeclaims
ARC runner pods in Amazon EKS

Amazon EKS shows active, completed, and listener Pods in arc-runners, with workload Pods scheduled onto private EC2 nodes.

Tune for speed without wasting money

Set minRunners from your latency objective, not guesswork. Zero warm runners is cheapest but every burst may wait for both a Pod and an EC2 node. Two warm runners absorb normal traffic; Karpenter handles spikes. Keep a small On-Demand fallback so a Spot shortage does not stop releases.

Build a versioned runner image containing stable tools such as git, language runtimes, scanners, and the AWS CLI. Pull it from ECR in the same region. Do not bake repository secrets into the image, and rebuild it frequently for security updates.

Inspect scaling after a test burst:

kubectl -n arc-runners get pods --sort-by=.metadata.creationTimestamp
kubectl get nodes -l workload=github-actions
kubectl describe nodepool github-actions

Use concurrency controls to cancel stale builds when a pull request receives new commits:

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Production checklist

  • ARC and runner scale-set chart versions are pinned and tested in staging.
  • Every runner is ephemeral and handles one job.
  • Controller and runners use separate namespaces.
  • Dedicated nodes have taints, encrypted disks, IMDSv2, and private networking.
  • GitHub App credentials live in a secret manager and rotate.
  • AWS roles use OIDC with exact repository, branch, or environment subjects.
  • Fork code runs on a non-privileged lane with no secrets.
  • Workflow permissions are minimal and actions are SHA-pinned.
  • Runner logs survive Pod deletion.
  • minRunners, maxRunners, NodePool limits, and budgets have alerts.

Final thoughts

Fast and safe are not competing goals when the runner is disposable. ARC provides queue-aware ephemeral Pods, EKS provides the isolation boundary, Karpenter supplies elastic capacity, and OIDC removes long-lived AWS keys. The result is a CI/CD platform that starts close to the services it needs, scales with demand, and throws away the execution environment after every job.

The key is to design trust before tuning speed. Separate untrusted pull requests, keep cloud roles narrow, isolate runner nodes, and retain audit logs. Once those controls are in place, warm capacity and aggressive caching can make the developer experience genuinely fast without turning CI into a permanent foothold.

References

Posts Carousel

Leave a Comment

Your email address will not be published. Required fields are marked with *

Latest Posts

Most Commented

Featured Videos