# Docker permission denied docker.sock on a CI runner

> Docker permission denied docker.sock in CI means the job user cannot open the daemon socket. Learn which group, which user, and which fix lasts.

Source: https://latchkey.dev/learn/docker/docker-permission-denied-daemon-socket-in-ci  
Updated: 2026-09-21

Docker permission denied docker.sock means the daemon is running and the user your step runs as is not allowed to open the unix socket it listens on. Every Docker command in the job fails identically and instantly, including ones that do nothing, because none of them ever reached the daemon to be refused by it.

## What this error means

The failure is total and immediate: `docker ps`, `docker version` and `docker build` all produce the same line, and they produce it in the same fraction of a second, because the refusal happens at the socket rather than anywhere inside Docker. It appears on self-hosted runners whose service account was never added to the docker group, on container jobs that bind-mount the socket, and after a group change that nothing has picked up yet. A tail of the message names an HTTP request against a percent-encoded socket path, which makes it look like a network problem and is not one.

```The line samber/github-actions-runner#1 reports, build query string abridged at the ellipsis (quoted, not a recorded run)
Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Post http://%2Fvar%2Frun%2Fdocker.sock/v1.40/build?...: dial unix /var/run/docker.sock: connect: permission denied
```

## Common causes

### The runner service account is not in the docker group

The ordinary cause on a self-hosted runner. The machine was provisioned, Docker was installed, and the account the runner agent runs as was never added to the group that owns the socket. Every Docker step has failed since the runner was registered, which is a useful signature: this cause does not appear suddenly.

### The group was added but nothing restarted

A process keeps the group list it was given when it started, so the fix was applied and the running runner agent never saw it. In our experience this is the cause when somebody has already run usermod, confirmed the membership with id in an interactive shell, and the pipeline still fails.

### A container job mounts the socket and runs as another user

Bind-mounting the socket into a container carries the file and its numeric group, not the host group name. If the container user is not in a group with that numeric id, it cannot open the socket, and no amount of configuration on the host will change it.

### The socket ownership or mode was changed

Hardening scripts and hand-written unit overrides sometimes alter the socket group or drop the group write bit. This is rarer than the others and shows up as a socket whose listing does not match a stock install, which is why listing the socket is worth doing before anything else.

## How to fix it

### Add the runner account to the docker group, then restart the runner

1. Add the account the runner agent actually runs as, which may not be your login.
2. Restart the runner service so the agent picks up the new group list.
3. Confirm from inside a job with `id -nG`, not from an interactive shell.

```Terminal and workflow
sudo usermod -aG docker svc_runner
sudo systemctl restart actions.runner.myorg-myrepo.runner01.service
# then, in a workflow step
- run: id -nG
```

### Pass the socket group numerically into container jobs

Read the numeric group of the socket on the host and give it to the container with `--group-add`, because the group name inside the container is almost never the same. This keeps the container running as its normal user instead of solving the problem by running it as root.

```Terminal
docker run --group-add "$(stat -c '%g' /var/run/docker.sock)" \
  -v /var/run/docker.sock:/var/run/docker.sock \
  myorg/ci-tools:1 docker ps
```

### Check the socket itself before changing any user

List the socket and compare it against a stock install: root owner, docker group, group writable. If the listing is different, the fix is the socket rather than the account, and changing group membership will not help no matter how many times it is applied.

```Terminal
ls -l /var/run/docker.sock
stat -c '%U %G %a' /var/run/docker.sock
```

### Say what you need in the job, rather than escalating

Prefixing every Docker command with sudo works and gives the whole step root, which is more than it needed and hides the real configuration problem from whoever reads the workflow next. Fix the group once on the host and keep the steps unprivileged.

```.github/workflows/ci.yml
- name: Confirm socket access before the Docker steps
  run: docker version --format '{{.Server.Version}}'
```

## How to prevent it

- Put the docker group membership in the runner provisioning, not in a manual step.
- Restart the runner service whenever the service account gains a group.
- Give socket-mounting containers the numeric socket group at start time.
- Run `docker version` as the first Docker step so access is proven before real work starts.

## The word "Got" tells you how old your Docker is

This message has been reworded twice, and which version you have is a free reading of the client on the runner. The literal lives in the Docker Go API client, in moby `client/request.go`, which the CLI and every Go tool that talks to the daemon compile in. From v1.13.1 through v20.10.23 it began with a capital `Got`. From v20.10.24 through v28.5.2 the `Got` is gone and the rest is identical. On moby master the wording changed again, to a lowercase reference to the docker API rather than to the daemon socket.

So a log that starts with `Got` is a client at v20.10.23 or older, which is a strong hint on its own: it usually means an old self-hosted runner image rather than a hosted one. The GitHub-hosted ubuntu-22.04 and ubuntu-24.04 images both listed Docker 28.0.4 when we read them on 2026-09-21, so a hosted Linux runner prints the form without `Got`.

| Docker client version | How the socket refusal reads |
| --- | --- |
| v1.13.1 through v20.10.23 | `Got permission denied while trying to connect to the Docker daemon socket at ...` |
| v20.10.24 through v28.5.2 | `permission denied while trying to connect to the Docker daemon socket at ...` |
| moby master, after v28.5.2 | `permission denied while trying to connect to the docker API at ...` |

> The tail after the colon is not part of any of those literals. The client wraps the underlying error, so the HTTP request line and the `dial unix ... connect: permission denied` ending come from the Go standard library and vary with the command you ran.

## Nothing in the daemon decided this

It is worth being precise about who refused you, because it changes where you look. The daemon never saw the request. The client tried to open a unix socket, the kernel checked the file mode against the calling user, and the open failed; the client then recognized a permission error and replaced the raw failure with a readable sentence. Restarting the daemon therefore does nothing, and neither does anything inside daemon.json.

What decides it is ordinary filesystem permission on one file. The socket is owned by root, its group is docker on a normal install, and it is group writable, so the question is only whether the user running your step is in that group at the moment the process started.

```Terminal
# the three facts that settle it
ls -l /var/run/docker.sock
id
id -nG | tr ' ' '\n' | grep -x docker || echo "not in the docker group"
```

## Group membership is decided when the process starts

The reason this error survives the obvious fix is that adding a user to a group does not change any process that is already running. The runner service inherited its groups when it started, so `usermod -aG docker` fixes the next login and not the job currently failing, and not the runner agent that spawned it.

On a self-hosted runner, add the group and then restart the runner service, in that order. Inside a container job that bind-mounts the socket, there is no login at all, so the container has to be given the socket group numerically at start time instead.

```Terminal
# self-hosted runner host
sudo usermod -aG docker "$(whoami)"
sudo systemctl restart actions.runner.*.service

# a container that mounts the socket
docker run --group-add "$(stat -c '%g' /var/run/docker.sock)" \
  -v /var/run/docker.sock:/var/run/docker.sock myorg/ci-tools:1
```

## What the runner does about it

No repair, and no recorded run, and on this page the reason is close to home: our runners are built so that the job user is already in the docker group, so producing this failure on one would mean removing that membership first. The capture would then be of a runner configured differently from the runners we ship, which tells you nothing about either. The failure belongs to self-hosted setups and to socket-mounting containers, which is where the fixes above are aimed.

It is also not a failure anything should repair automatically. Granting a process access to the daemon socket is granting it root-equivalent control of the host, and a runner that quietly added a user to the docker group to make a red build go green would be making a security decision on your behalf. That one should stay manual.

## FAQ

### Why does the error start with "Got" in some logs and not others?

Because the client was reworded. The literal in moby `client/request.go` began with a capital Got from v1.13.1 through v20.10.23, and from v20.10.24 onward the word is gone. Nothing else about the sentence changed, so a log that still says Got is telling you the client on that machine is at least a few years old.

### I ran usermod and it still fails. Why?

Because group membership is fixed when a process starts. The runner agent is still running with the group list it had before the change, so it passes that list to every job it spawns. Restart the runner service, and confirm from inside a job rather than from an interactive shell, which will show the new group and prove nothing.

### Does this happen on GitHub-hosted runners?

Essentially never, because the job user on a hosted Linux runner already has access to the daemon. When it shows up in a hosted workflow it is almost always a container job that mounts the socket, where the user inside the container is the one being refused rather than the runner user.

### Is adding a user to the docker group safe?

It is equivalent to giving that user root on the host, because anyone who can talk to the daemon can start a privileged container. That is a reasonable trade on a dedicated CI runner and a poor one on a shared machine, so decide it deliberately rather than as a step in fixing a red build.

## References

- [moby v28.5.2: the socket permission literal, client/request.go](https://github.com/moby/moby/blob/v28.5.2/client/request.go)
- [moby v20.10.23: the same line with the older "Got" wording](https://github.com/moby/moby/blob/v20.10.23/client/request.go)
- [samber/github-actions-runner#1: the line quoted above, reported on a runner](https://github.com/samber/github-actions-runner/issues/1)
- [Docker docs: post-installation steps, managing Docker as a non-root user](https://docs.docker.com/engine/install/linux-postinstall/)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
