DevOps & CI/CD Bug Report

GitHub Actions ARC runners: "Cannot connect to the Docker daemon" and choosing a container mode

We moved CI to self-hosted GitHub Actions runners on EKS with Actions Runner Controller. Every job that built an image failed: Cannot connect to the Docker daemon. Runner pods have no Docker by default. How dind and kubernetes container modes work, what each costs in security, and the node pool we gave them.

Sofia Andersson Sofia Andersson @sofiaandersson · June 24, 2026 · 6 min read · 30 Views
Banner: GitHub Actions ARC runners cannot connect to the Docker daemon, choosing a container mode

To cut CI costs and get access to private networks, we moved our GitHub Actions jobs from GitHub-hosted runners to self-hosted runners on our EKS cluster, using Actions Runner Controller (ARC) with runner scale sets. Test jobs worked immediately. Every job that built a container image failed:

Run docker build -t registry.example.com/orders-api:${GITHUB_SHA} .
ERROR: Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
Error: Process completed with exit code 1.

Jobs that used container: or service containers failed even earlier, in the "Initialize containers" step, because the runner had no container runtime to start them with.

On GitHub-hosted Ubuntu runners, Docker is preinstalled. On ARC runners, it is not.

What the runner pod contains

kubectl get pods -n arc-runners -l actions.github.com/scale-set-name=linux-x64 -o jsonpath='{.items[0].spec.containers[*].name}'
# runner
kubectl exec -n arc-runners <runner-pod> -c runner -- ls -l /var/run/docker.sock
# ls: cannot access '/var/run/docker.sock': No such file or directory

One container, runner, based on the official runner image. No Docker daemon, no socket.

What we ruled out

  • Missing docker CLI. The CLI was present in the runner image; only the daemon was missing.
  • Node Docker socket. EKS nodes use containerd, not Docker, so mounting a host socket was not even an option, and we would not have wanted it.
  • Permissions. There was no socket to have permissions on.

Root cause

ARC runner scale sets run each job in a runner pod with a single container by default. Workflows that call docker directly, use container: jobs or service containers need a container runtime the runner can talk to. That requires choosing and configuring one of ARC's container modes; we had installed the scale set with defaults.

ARC container modes

ARC's gha-runner-scale-set Helm chart supports two container modes, plus the default:

Mode How it works Docker CLI works container: jobs Privileges
Default Single runner container No No None special
dind Runner pod gets a Docker-in-Docker sidecar; runner talks to it over a shared socket Yes Yes dind sidecar runs privileged
kubernetes Runner uses container hooks to create job containers as separate pods via the Kubernetes API No (no daemon) Yes Needs RBAC to create pods; uses a shared volume

dind gives the closest experience to GitHub-hosted runners: docker build, docker run, service containers, everything works. The cost is a privileged container in every runner pod. A privileged container can, with some effort, escape to the node.

kubernetes mode runs job steps in separate pods without Docker. container: jobs and service containers work, but docker build does not, so image builds need a daemonless builder such as BuildKit in rootless mode, Kaniko or Buildah. It requires a persistent volume for the job workspace shared between the runner pod and job pods.

Environment

Component Version / value
EKS Kubernetes 1.33
ARC gha-runner-scale-set-controller and gha-runner-scale-set charts, 0.12
Node provisioning Karpenter
Workloads ~40 repositories, half build images, some use service containers

dind for image-building repositories

We created two scale sets: one in dind mode for repositories that build images, one in default mode for everything else.

# values-dind.yaml for gha-runner-scale-set
githubConfigUrl: https://github.com/example-org
githubConfigSecret: arc-github-app
runnerScaleSetName: linux-x64-docker
minRunners: 0
maxRunners: 30
containerMode:
  type: dind
template:
  spec:
    nodeSelector:
      workload: ci-runners
    tolerations:
      - key: workload
        value: ci-runners
        effect: NoSchedule
    containers:
      - name: runner
        image: ghcr.io/actions/actions-runner:latest
        command: ["/home/runner/run.sh"]
        resources:
          requests: { cpu: "2", memory: 4Gi }

With containerMode.type: dind, the chart adds the dind sidecar, an init container that copies runner externals, the shared /var/run volume and the DOCKER_HOST environment variable. Workflows opt in with the scale set name:

jobs:
  build:
    runs-on: linux-x64-docker
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t registry.example.com/orders-api:${{ github.sha }} .

Isolate the privileged runners

Because dind runs privileged, runner pods must not share nodes with production workloads. We created a dedicated Karpenter NodePool with a taint, so only runner pods tolerate it:

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: ci-runners
spec:
  template:
    metadata:
      labels:
        workload: ci-runners
    spec:
      taints:
        - key: workload
          value: ci-runners
          effect: NoSchedule
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]
      expireAfter: 24h
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: ci-runners
  disruption:
    consolidationPolicy: WhenEmpty
    consolidateAfter: 2m

The ci-runners EC2NodeClass uses a separate IAM role with no access to production resources and a security group that can reach only the registry, GitHub and package mirrors. WhenEmpty consolidation avoids killing nodes with running jobs.

Runner pods are ephemeral in ARC scale sets: each pod handles one job and is deleted afterwards, which also limits persistence of anything a malicious job might leave behind.

Long term: daemonless builds

For new pipelines we use BuildKit in rootless mode running as a separate service in the CI namespace, with the buildx remote driver, so the runner itself needs no privileges. Repositories move to the default, unprivileged scale set as they switch.

Alternatives and trade-offs

  • kubernetes container mode everywhere. No privileged containers, but every image build must change to a daemonless tool, and the shared workspace volume adds latency and storage setup.
  • Mounting the node's container runtime socket. Gives every job root on the node; never do this on shared clusters.
  • GitHub-hosted larger runners for image builds. No infrastructure to manage, at a higher per-minute cost and without private network access.

How to test the fix

jobs:
  smoke:
    runs-on: linux-x64-docker
    steps:
      - run: docker info --format '{{.ServerVersion}}'
      - run: docker run --rm alpine:3.20 echo ok

Confirming in production

Image-building jobs ran on the dind scale set; median job start time, including node provisioning when scaled to zero, was 70 seconds, and around 10 seconds when nodes were warm. CI costs dropped by roughly half compared with GitHub-hosted runners for the same workload.

Regression guard

  • A policy denies privileged pods everywhere except the arc-runners namespace, and in that namespace only on nodes with the ci-runners taint.
  • A weekly report lists repositories still using the dind scale set, to track the move to daemonless builds.

Security implications

Self-hosted runners execute code from pull requests. Combined with privileged dind, that is a path from a malicious pull request to a node. We restrict self-hosted runners to private repositories, require approval for workflows from first-time contributors, isolate runner nodes on their own NodePool, IAM role and network, and keep the nodes short-lived with expireAfter: 24h.

Prevention checklist

  • Choose an ARC container mode deliberately; the default has no Docker.
  • Use separate scale sets for jobs that need Docker and jobs that do not.
  • Isolate privileged runners on dedicated, tainted, short-lived nodes with minimal IAM.
  • Prefer daemonless image builds for new pipelines.
  • Never mount a node's container runtime socket into job pods.

References

Add a label

#github-actions #kubernetes #karpenter #arc #docker-in-docker
Written by
Sofia Andersson @sofiaandersson

I work on how code gets from a merged pull request to production: Helm charts, Argo CD, Argo Rollouts, CI runners and the policies that decide what is allowed to run. My goal is boring deployments, the kind nobody needs to watch. Getting there means learning from the deployments that were not boring: a values file that silently dropped configuration, a key rotation that blocked every release, a preview environment that received real traffic. Each post explains the failure, the mechanism behind it, the fix and the guard rail that stops it from coming back.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.