← Back to list

Self-Hosted Github Actions in EKS with Spot Instance

In this post, we will be exploring ways to self-host GitHub actions. Our major focus will be

Binaya Sharma · 2025-07-19 08:49 · 6 claps · 6.9 min read
#github-actions #aws-eks #self-hosted-runner #spot-instances #actions-runner-controller
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud 🔓 · Open Source ⏱️ · Productivity

Self-Hosted Github Actions in EKS with Spot Instance

In this post, we will be exploring ways to self-host GitHub actions. Our major focus will be

  • Runners should be able to autoscale
  • It should be as cost-effective as possible (SPOT Instances)

Pre-requisite for the blogs

Step 1 : Lets create a EKS Cluster using Terraform

In this step, we provision a highly available Amazon EKS cluster using the terraform-aws-modules/eks/aws module. This setup includes VPC, subnets, NAT Gateway, and EKS node groups — optimized for scalability and cost with spot and on-demand instances.

We will be utilizing spot instance for the hosting runners required for the github actions. Sample terraform code looks like

locals {
  cluster_name = "${var.project}-eks"
  common_tags = {
    Project    = "runner"
    Enviroment = "dev"
    Owner      = "Bnay14"
  }
  eks_autoscaling_tags = merge(
    local.common_tags,
    {
      "k8s.io/cluster-autoscaler/enabled"               = "true"
      "k8s.io/cluster-autoscaler/${local.cluster_name}" = "owned"
    }
  )
}

data "aws_availability_zones" "available" {}

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.21.0"
  name    = "${var.project}-vpc"
  cidr    = var.cidr
  azs     = slice(data.aws_availability_zones.available.names, 0, 3)

  public_subnets  = [for i in range(3) : cidrsubnet("${var.cidr}", 8, i)]
  private_subnets = [for i in range(3) : cidrsubnet("${var.cidr}", 8, i + 10)]

  enable_nat_gateway = true
  single_nat_gateway = true # Each AZ gets its own NAT Gateway

  enable_dns_hostnames = true
  enable_dns_support   = true

  public_subnet_tags = {
    "kubernetes.io/role/elb" = 1
  }

  private_subnet_tags = {
    "kubernetes.io/role/internal-elb" = 1
  }

  tags = local.common_tags
}

module "eks" {
  source                                   = "terraform-aws-modules/eks/aws"
  version                                  = "~>20.31"
  cluster_name                             = "${var.project}-eks"
  cluster_version                          = "1.33"
  vpc_id                                   = module.vpc.vpc_id
  subnet_ids                               = module.vpc.private_subnets
  control_plane_subnet_ids                 = module.vpc.private_subnets
  cluster_endpoint_public_access           = true
  enable_cluster_creator_admin_permissions = true
  cluster_endpoint_public_access_cidrs     = ["X.X.X.X.X/32"] # Change this your IP 
  cluster_enabled_log_types                = []
  eks_managed_node_groups = {
    on_demand_nodes = {
      ami_type      = "BOTTLEROCKET_x86_64"
      instance_type = ["t3.small"]
      min_size      = 1
      max_size      = 2
      tags          = local.eks_autoscaling_tags

    }
    actions-nodes = {
      ami_type      = "BOTTLEROCKET_x86_64"
      instance_type = ["t3.small", "t3a.small"]
      desired_size = 0
      min_size      = 0
      max_size      = 3
      capacity_type = "SPOT"
      tags          = local.eks_autoscaling_tags
      taints = [{
        key    = "action-nodes"
        value  = "true"
        effect = "NO_SCHEDULE"
        }
      ]
      labels = {
        action-nodes = true
      }
    }
  }
  cluster_addons = {
    coredns                = {}
    eks-pod-identity-agent = {}
    kube-proxy             = {}
    vpc-cni                = {}

  }
  tags       = local.common_tags
  depends_on = [ module.vpc ]
}

Step 2: Create a pod identity for Cluster Autoscaler

In order to provide appropriate roles to cluster Autoscaler, we are going to use pod identity instead of IRSA.

More about pod identity. || Official Docs

We can do this using terraform. Sample terraform code

module "cluster_autoscaler_pod_identity" {
  source = "terraform-aws-modules/eks-pod-identity/aws"
  version = "1.11.0"
  name = "${var.project}-eks-clusterAutoscaler"
  attach_cluster_autoscaler_policy = true 
  cluster_autoscaler_cluster_names = ["module.eks.cluster_name"]
  associations = {
    eks = {
    cluster_name = module.eks.cluster_name
    namespace = "cluster-autoscaler"
    service_account = "cluster-autoscaler-sa"

    }
  }
  tags = local.common_tags
  depends_on = [module.eks]
}

From above, module we need to make sure two things.

  • We need to deploy cluster autoscaler in namespace cluster-autoscaler.
  • We need to have a service account named cluster-autoscaler-sa.

If you are deploying in different service account, and name space changes can be made inside association block.

Step 3: Accessing EKS cluster using kubectl

For this step, your IAM account should have required permission.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "eks:DescribeCluster"
      ],
      "Resource": "arn:aws:eks:<region>:<account-id>:cluster/<cluster-name>"
    }
  ]
}

More about permissions and other requirement can be found of official docs.

$ aws eks update-kubeconfig --region <region> --name <cluster_name>

Step 4: Install Cluster Autoscaler

In this step we will be installing cluster autoscaler. This will basically take care of scaling up nodes whenever there are pending pods in cluster.

$ helm repo add cluster-autoscaler https://kubernetes.github.io/autoscaler

Lets us create values.yaml for the custom values we will be using,

autoDiscovery:
  clusterName: runner-eks # Name of EKS cluster

awsRegion: us-east-1 # Region

extraArgs:
  balance-similar-node-groups: true
  scale-down-enabled: true
  scale-down-delay-after-add: 2m
  scale-down-unneeded-time: 3m
  scale-down-utilization-threshold: "0.4"
  skip-nodes-with-system-pods: true
  expander: least-waste
  max-node-provision-time: 15m
  scan-interval: 30s
  max-empty-bulk-delete: 10
  scale-down-delay-after-delete: 0s
  scale-down-delay-after-failure: 3m

rbac:
  create: true
  serviceAccount:
    create: true
    name: cluster-autoscaler-sa # Match this with defined in TF

resources:
  limits:
    cpu: 200m
    memory: 600Mi
  requests:
    cpu: 100m
    memory: 300Mi

priorityClassName: system-cluster-critical

nodeSelector:
  kubernetes.io/os: linux

Lets install cluster-autoscaler

$ helm upgrade --install cluster-autoscaler cluster-autoscaler/cluster-autoscaler \
  --namespace cluster-autoscaler \
  --create-namespace \
  -f values.yaml

After this you should be able to see pods up and running.

$ kubectl get pods -n cluster-autoscaler

NAME                                                         READY   STATUS    RESTARTS   AGE
cluster-autoscaler-aws-cluster-autoscaler-6c445fccfc-2rvsg   1/1     Running   0          28s

At this point we should have a cluster upa and running, also cluster autoscaling should also work fine. This means without the workload action-nodes should scale down to zero.

Step 5: Setup Authentication in Github

Before installing the actual actions-runner-controller we would be configuring authentication in github. This would enable us to connect runner to our desired github organization or repo.

There are couple of ways to manage authentication (Only can can be used at a time)

  • Using Github App (Not supported for Enterprise)
  • Using Personal Access Token (PAT)

We will be using PAT to configure authentication. Following is the permission required by token

# Required Scopes for Repository Runners
repo (Full control)

# Required Scopes for Organization Runners
repo (Full control)
admin:org (Full control)
admin:public_key (read:public_key)
admin:repo_hook (read:repo_hook)
admin:org_hook (Full control)
notifications (Full control)
workflow (Full control)

# Required Scopes for Enterprise Runners
admin:enterprise (manage_runners:enterprise)

Repository Runner: repo(Full Control)

Repository Runner: repo(Full Control)

Now lets keep the the generated token as a secrets object in k8s.

kubectl create namespace actions-runner-system && \
kubectl create secret generic controller-manager \
   --namespace=actions-runner-system \
   --from-literal=github_token='YOUR-PAT'

Step 6: Install Actions-Runner-Controller

We need to install cert-manager. This is required by arc.

$ helm repo add cert-manager https://charts.jetstack.io
$ helm install cert-manager cert-manager/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set installCRDs=true \
  --wait

We will be using helm charts to install arc.

$ helm repo add actions-runner-controller https://actions-runner-controller.github.io/actions-runner-controller
$ helm upgrade --install --namespace actions-runner-system actions-runner-controller actions-runner-controller/actions-runner-controller -f values.yaml --wait

After the installation you should see a pod running in actions-runner-controller namespace.

Step 7: Configuring ARC Runner

Now we would install arc runner. We now have a CRDs where we can define the runner configuration. We will be deploying runners in seprate namespace.

$ kubectl create ns actions-runners

We want to make sure the actions nodes are configured in SPOT instance. So we would need to specify labels, and toleration in deployment. Our Runner deployment will look something like

apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
  name: github-runners
  namespace: actions-runner
spec:
  replicas: 2
  template:
    spec:
      repository: BS14/self-hosted-runner  # or use organization: your-org
      labels:
        - self-hosted
        - linux
        - x64
        - custom-runner
      env:
        - name: RUNNER_NAME_PREFIX
          value: "self-hosted-runner"
      resources:
        requests:
          cpu: "500m"
          memory: "512Mi"
        limits:
          cpu: "1"
          memory: "1Gi"
      dockerEnabled: false  # set to true if you want docker-in-docker
      nodeSelector:
        action-nodes: "true"
      tolerations:
        - key: "action-nodes"
          operator: "Equal"
          value: "true"
          effect: "NoSchedule"

Scaling Activity in SPOT Instance

Scaling Activity in SPOT Instance

On GitHub repository actions you could see two runner that would be attached.

Runners Registration in Repo Level

Runners Registration in Repo Level

Step 8: Lets autoscale Runners

Instead of hardcoding number of replicas in runner, we can actually autoscaling policies. This make sure we have runner as required.

Manifest for autoscaling would be

---
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
  name: github-runners
  namespace: actions-runner
spec:
  #replicas: 1  # will be defined in HorizontalRunnerAutoscaler
  template:
    spec:
      repository: BS14/self-hosted-runner  # change to your repo or use organization: your-org
      labels:
        - self-hosted
      resources:
        requests:
          cpu: "500m"
          memory: "512Mi"
        limits:
          cpu: "1"
          memory: "1Gi"
      nodeSelector:
        action-nodes: "true"
      tolerations:
        - key: "action-nodes"
          operator: "Equal"
          value: "true"
          effect: "NoSchedule"
---
apiVersion: actions.summerwind.dev/v1alpha1
kind: HorizontalRunnerAutoscaler
metadata:
  name: github-runners-autoscaler
  namespace: actions-runner
spec:
  scaleTargetRef:
    name: github-runners
  minReplicas: 1
  maxReplicas: 3
  metrics:
    - type: PercentageRunnersBusy
      scaleUpThreshold: "0.75"
      scaleDownThreshold: "0.25"
      scaleUpFactor: "2"
      scaleDownFactor: "0.5"

We are using PercentageRunnerBusy metrics for scaling. We can also other metrics More on Scaling

Scaled Runner

Scaled Runner

Step 9: Testing Workflow

Now lets us write a testing workflow which will run in the self-hosted runner. Sample workflow file would look something like

name: Self-Hosted Runner

on:
  push:
    branches:
      - main

jobs:
  test-runner:
    name: Verify Self-Hosted Runner
    runs-on: [self-hosted]  # Match the labels from your RunnerDeployment
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Print Runner Info
        run: |
          echo "Running on: $RUNNER_NAME"
          echo "Operating System: $RUNNER_OS"
          echo "Architecture: $RUNNER_ARCH"
          uname -a

      - name: Run Sample Task
        run: |
          echo "This is a test job running on a self-hosted GitHub Actions runner!"
          sleep 100

Workflow Output

Workflow Output

This way we can leverage spot instances and self host the runner.

All the codes are in the repo https://github.com/BS14/self-hosted-runner

Another noteworthy mention apart from ARC would be runs-on. I have heard many good things about runs-on but haven’t tried yet. I will try this and let you know about the details.

HAPPY WORKFLOW fellow Engineers!!!


메타데이터
post_id
a087dfc35c82
slug
self-hosted-github-actions-in-eks-with-spot-instance-a087dfc35c82
url
https://medium.com/@bnay14/self-hosted-github-actions-in-eks-with-spot-instance-a087dfc35c82
canonical_url
https://medium.com/@bnay14/self-hosted-github-actions-in-eks-with-spot-instance-a087dfc35c82
author_url
https://medium.com/@bnay14
status
ok
fetched_at
2026-06-10 22:22:12