>_C2CZ
Serverless · Self-Hosting · Security · Independent Ops

Cannot connect to the Docker daemon: four branches behind one message

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? is not a diagnosis. The docker client prints that one sentence for a daemon that is stopped, for a socket file left behind by a daemon that died, and for a client pointed at an endpoint nobody is listening on — and only its sibling message, permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock, says anything specific about the cause. If you have been searching the first one and getting "add yourself to the docker group", you have been handed the fix for the other error.

I built every branch of this failure by hand on 2026-09-16: Debian 13 (trixie), aarch64, kernel 6.17.0-1019-oracle, Docker CLI 26.1.5+dfsg1 (client API 1.45) and no daemon running at all — which turned out to be the ideal rig, because each cause is reproducible as a unix socket in a scratch directory with no root. The finding that changed how I read this error is the fourth branch: against a socket that accepted connections and never answered, docker ps printed nothing at all and was still blocked when I killed it at 90 seconds. When that same listener closed on its own a moment later, the client produced the familiar "is the docker daemon running?" line, after 62.069 s. The sentence is not a statement about the daemon; it is a statement about the client's attempt to have a conversation.

Everything quoted with output below was executed in that rig. The handful of commands that manage a systemd daemon are attributed to Docker's official post-installation and rootless documentation, because this container has no systemctl to run them on.

TL;DR

  • Two messages, not one. Cannot connect to the Docker daemon at <endpoint>. Is the docker daemon running? covers every transport failure the client can reach: no socket file, a socket file with nothing listening, a peer that accepted and stayed silent, an unreachable TCP endpoint. permission denied while trying to connect to the Docker daemon socket at <endpoint>: … connect: permission denied is the kernel refusing connect() — a group or file-mode problem, and never a stopped daemon.
  • A stopped daemon fails in 13 ms; a wedged one does not fail at all. Measured against an absent socket, a stale socket and a dead TCP port: the identical sentence in 0m0.013s every time. Measured against a socket that accepts and never answers: no output, no exit code, still blocked at 90 s. If your terminal is silent rather than wrong, you are in the fourth branch.
  • The silently-wedged case still ends in the same sentence. When my listener closed on its own, the client printed "is the docker daemon running?" at 62.069 s (and at 32.044 s in a second run where the socket closed sooner) — about a daemon it had connected to successfully. Same text, same exit code 1 as the missing-socket case.
  • A socket that answers is a third case with a third message. When a proxy, a stray socat or a rotted tunnel owns the socket you get error during connect: … malformed HTTP status code, or invalid character '<' looking for beginning of value when the impostor speaks valid HTTP but not the Docker API.
  • Probe the socket before you touch systemd. curl --unix-socket splits the cases the docker CLI merges: (7) means refused or not allowed (read it next to ls -l), (28) means connected but silent, and a 200 with Docker-Experimental and Ostype headers means a daemon is answering. Bound it with --max-time: curl has no default transfer timeout, so against a wedged daemon the probe hangs exactly like the client does.
  • Check the environment, not just the context. DOCKER_HOST and DOCKER_CONTEXT both override the active context. With DOCKER_HOST set, docker context ls prints the environment's endpoint in the default row and warns on stderr; DOCKER_CONTEXT changes the endpoint silently, and a value that does not exist shows up only in the table's ERROR column. The endpoint you configured and the endpoint in force are two different questions.
  • Never "fix" this with chmod 666 /var/run/docker.sock. Anything that can talk to that socket can start a privileged container, and Docker's own documentation states that the docker group grants root-level privileges.

On this page

The messages, and what each one means

The four rows below cover every variant I could produce against a real docker CLI: an endpoint that did not exist, a socket file with nothing listening, a socket that refused the connection, and two that were owned by processes which were not dockerd. The endpoint printed in these measured strings is my fixture's path; on your host the same message carries /var/run/docker.sock. The exit code is 1 in every case, so the status alone tells you nothing you did not already know.

What the client printsWhat happened at the transportWhere the fix lives
Cannot connect to the Docker daemon at unix:///…/absent.sock. Is the docker daemon running? stat() found no socket file, or the file exists with nothing listening (ECONNREFUSED). Measured real 0m0.013s in both cases. In the daemon or the endpoint: start it, or correct DOCKER_HOST / docker context.
— nothing, for as long as you are willing to wait; then the same sentence once the peer's connection dies connect() succeeded and no byte ever came back. Still blocked when killed at 90 s; 62.069 s and 32.044 s in two runs where the listening socket closed on its own first. In the daemon, which is hung: read its log, then restart it.
permission denied while trying to connect to the Docker daemon socket at unix:///…/docker.sock: Get "http://%2F…%2Fdocker.sock/v1.45/containers/json": dial unix …/docker.sock: connect: permission denied connect() returned EACCES: the socket's mode, or the search permission on a directory above it, denies you. Fails in 13 ms like the rest. In group membership and the session — the socket itself is fine.
error during connect: Get "http://%2F…%2Fgarb.sock/v1.45/containers/json": net/http: HTTP/1.x transport connection broken: malformed HTTP status code "nonsense" Something is accepting connections on that path and is not an HTTP server, so the transport breaks mid-handshake. A valid HTTP server that is not the Docker API fails later, at the JSON layer. In whatever process owns the socket — find it and stop it, or re-point the client.

Row three is the one that carries real information: the word permission is the kernel telling you it refused the connection, which cannot happen because a daemon is stopped. If your message contains it, skip everything in this article about systemd.

Rows one and two are the same sentence, and that is the trap — with a twist that makes it worse. A daemon that died an hour ago returns in 13 ms. A daemon that is deadlocked right now returns nothing at all, and when something eventually closes the socket you get the same text you would have got if nothing had ever been listening. If your monitoring scrapes that string, it cannot tell you which of the two you have — and "start the daemon" versus "the daemon is up and wedged" is the difference between a two-second fix and an investigation.

Three checks, no sudo, before you change anything

Start with the client's own view of the world, because some of the cases people hit are a client pointed somewhere they forgot about — and there are two layers to that. DOCKER_HOST and DOCKER_CONTEXT both win over the active context, but only the first one announces itself. docker context ls does not merely show a stale value: the default context is resolved from the environment, so with DOCKER_HOST set the table prints the environment's endpoint in that row, moves the * to it, and writes Warning: DOCKER_HOST environment variable overrides the active context… to stderr. Named contexts keep their stored endpoint. DOCKER_CONTEXT is the quiet one: measured with DOCKER_CONTEXT=doesnotexist, stderr stays empty and the failure appears in the table's own ERROR column instead, next to the *. If you only read stdout you will see one of these; if you only read stderr you will see the other.

All the commands below are client-side or plain filesystem reads. None needs root, and none needs the daemon to be alive, which is the entire point: you want a diagnosis before you start restarting services. On this particular rig there was no daemon at that path, so the third command printed curl: (7); the healthy answer shown in the comments is what your host prints when it is working.

# 1. Is something overriding your context? DOCKER_HOST and DOCKER_CONTEXT both win
#    over it; only DOCKER_HOST warns you on stderr. This is what DOCKER_HOST set looks like:
printenv DOCKER_HOST DOCKER_CONTEXT
docker context ls
# NAME        DESCRIPTION                               DOCKER ENDPOINT         ERROR
# default *   Current DOCKER_HOST based configuration   tcp://192.0.2.10:2375
# Warning: DOCKER_HOST environment variable overrides the active context. To use a
# context, either set the global --context flag, or unset DOCKER_HOST environment variable.

# DOCKER_CONTEXT overrides too, but silently: no warning on stderr, and a bad value
# shows up in the table's own ERROR column. Also measured:
# NAME             DESCRIPTION                               DOCKER ENDPOINT               ERROR
# default          Current DOCKER_HOST based configuration   unix:///var/run/docker.sock
# doesnotexist *                                                              context "doesnotexist": context not found

# With DOCKER_HOST unset the default row resolves to the local socket:
# default *   Current DOCKER_HOST based configuration   unix:///var/run/docker.sock

# 2. Does the socket exist, and what are its permissions?
ls -l /var/run/docker.sock
# srw-rw---- 1 root docker 0 Sep 16 21:30 /var/run/docker.sock
#  ^ s = socket, mode 0660: root and members of group docker may use it

# 3. Talk to the socket yourself. This is the step the docker CLI does for you, silently.
#    --max-time is not optional: curl has no default transfer timeout, so against a
#    wedged daemon this command would otherwise wait forever.
curl -sS -i --max-time 4 --unix-socket /var/run/docker.sock http://localhost/_ping
# HTTP/1.1 200 OK
# Api-Version: 1.47
# Builder-Version: 2
# Cache-Control: no-cache, no-store, must-revalidate
# Content-Length: 2
# Content-Type: text/plain; charset=utf-8
# Date: Wed, 16 Sep 2026 21:30:04 GMT
# Docker-Experimental: false
# Ostype: linux
# Pragma: no-cache
# Server: Docker/27.5.1 (linux)
# Swarm: inactive
#
# OK

Read that header block as a whole, because the five-line version of it that circulates in blog posts is a mock. A real dockerd sends the lot: Server, Api-Version and Ostype come from its version middleware, and the ping handler adds Builder-Version, Swarm, Cache-Control: no-cache, no-store, must-revalidate and Pragma: no-cache, with Date supplied by net/http. Note the value: an Engine 27.x daemon advertises Api-Version: 1.47, not 1.45. The 1.45 you will see in client error strings belongs to the CLI side, the API version a 26.x client negotiates by default, so if you are diffing your own output against a sample that says 1.45 you are comparing a client against a server.

That ping answer is worth reading closely, because everything the client needs is in the headers: Api-Version tells it which versioned path to use, and Ostype is what makes docker version able to fill in a Server block at all. The body is two bytes — the Docker Engine API defines GET /_ping as producing text/plain with the example body OK, and all the metadata as response headers. A "ping" that returns JSON is not dockerd.

The curl line is the one worth memorising, because it separates the cases the docker CLI collapses into one sentence. Against three different sockets in this rig it printed three different things — and one of them you have to read carefully:

$ curl -sS --max-time 4 --unix-socket /var/run/docker.sock http://localhost/_ping
curl: (7) Failed to connect to localhost port 80 after 0 ms: Could not connect to server

$ curl -sS --max-time 4 --unix-socket /path/to/silent.sock http://localhost/_ping
curl: (28) Operation timed out after 4002 milliseconds with 0 bytes received

$ curl -sS -i --max-time 4 --unix-socket /path/to/healthy.sock http://localhost/_ping
HTTP/1.1 200 OK
Api-Version: 1.47
Builder-Version: 2
Cache-Control: no-cache, no-store, must-revalidate
Content-Length: 2
Content-Type: text/plain; charset=utf-8
Date: Wed, 16 Sep 2026 21:30:04 GMT
Docker-Experimental: false
Ostype: linux
Pragma: no-cache
Server: Docker/27.5.1 (linux)
Swarm: inactive

OK

Note what curl does not separate: a missing socket file and a permission-denied socket both produce (7) Failed to connect. That is why the middle command — ls -l on the socket — is not optional. Socket present plus (7) is a permission problem; socket absent plus (7) is a daemon problem. And (28) Operation timed out is the branch no "add yourself to the docker group" answer will help you with: the connection was made, and the other side went quiet. It also only appears because the command is bounded — without --max-time, curl has no default transfer timeout and hangs against a daemon that never answers, exactly like the docker client does.

A classifier that names the branch

These branches are distinguishable from the outside, so I wrote the classification once as a small tool instead of leaving it as a checklist. docker_sock_triage.py takes an endpoint, inspects the socket, speaks one HEAD /_ping to it, and prints the branch plus the fix that belongs to it. It needs no root, no daemon and no docker binary, and every branch is a distinct exit code, so it can be dropped into a deploy script or a health check as-is.

Its --selftest mode builds one fixture per branch in a temporary directory and classifies each of them — which is also the evidence that this taxonomy is measured rather than guessed. Save the file, run it on your own host, and use the verdict as the index into the sections below. Malformed input is a branch too: a tcp:// URL with no port, or the %2F-encoded URL lifted straight out of the error message, are reported as BAD_USAGE with exit code 7 instead of raising a traceback, and the same code marks a usage error from the option parser.

#!/usr/bin/env python3
"""docker_sock_triage.py — WHY can the docker CLI not reach the daemon?

The docker client collapses several very different failures into one sentence.
This script separates them: it inspects the endpoint, talks to it directly, and
prints the branch you are actually in plus the fix that belongs to it.

    ./docker_sock_triage.py                         # $DOCKER_HOST or /var/run/docker.sock
    ./docker_sock_triage.py unix:///run/user/1000/docker.sock
    ./docker_sock_triage.py tcp://127.0.0.1:2375
    ./docker_sock_triage.py --selftest              # classify one fixture per branch

No root, no running daemon and no docker binary are required.

Exit codes
    0  HEALTHY           a daemon answered /_ping
    1  NOT_DOCKER        something answered, and it is not dockerd
    2  STALE_SOCKET      socket file present, nothing listening on it
    3  EACCES            the kernel refused connect(): group or mode
    4  ENDPOINT_MISSING  no socket file, nothing listening, or unreachable
    5  NOT_A_SOCKET      that path exists but is not a unix socket
    6  WEDGED            connected, but the peer never answered
    7  BAD_USAGE         malformed endpoint or invalid options
"""
import argparse
import os
import socket
import stat
import sys
import tempfile
import threading
import time
from urllib.parse import urlsplit

PROBE = (b"HEAD /_ping HTTP/1.1\r\nHost: api.moby.localhost\r\n"
         b"User-Agent: docker-sock-triage/1.0\r\n\r\n")

# A real dockerd answers /_ping with these; anything else on the socket is not dockerd.
DOCKER_MARKERS = (b"docker-experimental", b"ostype")

BRANCH_CODES = {
    "HEALTHY": 0,
    "NOT_DOCKER": 1,
    "STALE_SOCKET": 2,
    "EACCES": 3,
    "ENDPOINT_MISSING": 4,
    "NOT_A_SOCKET": 5,
    "WEDGED": 6,
    "BAD_USAGE": 7,
}

FIXES = {
    "HEALTHY": "nothing to fix — the endpoint answered /_ping as dockerd",
    "NOT_DOCKER": "another process owns the socket; find it (sudo ss -xlp | grep docker.sock) "
                  "and stop it, or unset DOCKER_HOST / switch docker context",
    "STALE_SOCKET": "rebind the socket: sudo systemctl restart docker.socket docker.service "
                    "(remove the leftover file only while the daemon is stopped)",
    "EACCES": "if you are already in the docker group but this shell predates the change, refresh "
              "the session: sg docker -c 'docker ps'. If you are not a member yet, add yourself "
              "(sudo usermod -aG docker \"$USER\", then log back in). Permanent membership is "
              "root-equivalent, so prefer a socket proxy or rootless Docker",
    "ENDPOINT_MISSING": "start the daemon (sudo systemctl enable --now docker) or point the "
                        "client at the right endpoint (DOCKER_HOST, docker context)",
    "NOT_A_SOCKET": "your endpoint points at a file, not a unix socket; check DOCKER_HOST",
    "WEDGED": "the daemon accepted the connection and stopped answering: restart it and read "
              "sudo journalctl -u docker.service -n 200",
    "BAD_USAGE": "pass unix:///path/to/docker.sock or tcp://host:port",
}


class UsageParser(argparse.ArgumentParser):
    """argparse exits 2 on a usage error; this tool reserves 7 for it."""

    def error(self, message):
        self.print_usage(sys.stderr)
        self.exit(BRANCH_CODES["BAD_USAGE"], "%s: error: %s\n" % (self.prog, message))


def classify(endpoint, timeout=3.0):
    """Return (branch, detail) for a unix:// or tcp:// endpoint. Never raises."""
    if timeout <= 0:
        return "BAD_USAGE", "timeout must be positive, got %r" % timeout

    try:
        parsed = urlsplit(endpoint if "://" in endpoint else "unix://" + endpoint)
    except ValueError as exc:                       # e.g. a malformed IPv6 literal
        return "BAD_USAGE", "%r is not a valid endpoint: %s" % (endpoint, exc)

    if parsed.scheme == "tcp":
        if not parsed.hostname:
            return "BAD_USAGE", "%r has no host" % endpoint
        try:
            port = parsed.port                     # raises ValueError on a bad port
        except ValueError as exc:
            return "BAD_USAGE", "%r: %s" % (endpoint, exc)
        if port is None:
            return "BAD_USAGE", "%r has no port (expected tcp://host:port)" % endpoint
        target = "%s:%d" % (parsed.hostname, port)
        try:
            conn = socket.create_connection((parsed.hostname, port), timeout=timeout)
        except ConnectionRefusedError:
            return "ENDPOINT_MISSING", "nothing is listening on %s" % target
        except socket.gaierror as exc:
            return "ENDPOINT_MISSING", "%s does not resolve (%s)" % (target, exc)
        except socket.timeout:
            return "ENDPOINT_MISSING", ("%s completed no handshake in %.1fs: nothing is "
                                        "listening on that port, or the packets are dropped"
                                        % (target, timeout))
        except OSError as exc:
            return "ENDPOINT_MISSING", "%s: %s" % (target, exc.strerror or exc)
        return _speak_to(conn, target, timeout)

    if parsed.scheme != "unix":
        return "BAD_USAGE", "unsupported scheme %r (use unix:///path or tcp://host:port)" % parsed.scheme

    path = parsed.path
    if not path:
        return "BAD_USAGE", "%r has no socket path" % endpoint

    try:
        st = os.stat(path)
    except FileNotFoundError:
        return "ENDPOINT_MISSING", "no socket file at %s" % path
    except PermissionError:
        return "EACCES", "cannot even stat %s (search permission denied on a parent directory)" % path
    except OSError as exc:
        return "ENDPOINT_MISSING", "%s: %s" % (path, exc.strerror or exc)

    if not stat.S_ISSOCK(st.st_mode):
        return "NOT_A_SOCKET", "%s is mode %s, not a socket" % (path, oct(st.st_mode & 0o7777))

    conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    conn.settimeout(timeout)
    try:
        conn.connect(path)
    except PermissionError:
        return "EACCES", "connect(%s) -> EACCES (mode %s, uid %d gid %d)" % (
            path, oct(st.st_mode & 0o7777), st.st_uid, st.st_gid)
    except ConnectionRefusedError:
        return "STALE_SOCKET", "%s exists but nothing is listening" % path
    except FileNotFoundError:
        return "ENDPOINT_MISSING", "%s disappeared between stat and connect" % path
    except socket.timeout:
        return "WEDGED", "connect(%s) timed out after %.1fs" % (path, timeout)
    except OSError as exc:
        return "ENDPOINT_MISSING", "connect(%s): %s" % (path, exc.strerror or exc)
    return _speak_to(conn, path, timeout)


def _speak_to(conn, label, timeout):
    """Send /_ping and decide whether the peer is a Docker daemon."""
    try:
        conn.sendall(PROBE)
        data = b""
        while b"\r\n\r\n" not in data and len(data) < 65536:
            chunk = conn.recv(2048)             # a header block is not one segment
            if not chunk:                       # EOF: take whatever arrived
                break
            data += chunk
    except socket.timeout:
        return "WEDGED", "%s accepted the connection but sent nothing in %.1fs" % (label, timeout)
    except OSError as exc:
        return "NOT_DOCKER", "%s closed the connection: %s" % (label, exc.strerror or exc)
    finally:
        conn.close()

    if not data:
        return "NOT_DOCKER", "%s closed the connection without a response" % label
    status_line = data.split(b"\r\n", 1)[0].decode("latin-1", "replace")
    if b" 200 " not in data.split(b"\r\n", 1)[0]:
        return "NOT_DOCKER", "%s answered %r to HEAD /_ping" % (label, status_line)
    lowered = data.lower()
    if any(marker in lowered for marker in DOCKER_MARKERS):
        return "HEALTHY", "%s answered %s with dockerd's own headers" % (label, status_line)
    return "NOT_DOCKER", "%s answered %s but without dockerd headers" % (label, status_line)


# --------------------------------------------------------------------- fixtures
def _mock_dockerd(path, reply, hold=False):
    srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    srv.bind(path)
    srv.listen(8)
    srv.settimeout(30)
    keep = []
    try:
        while True:
            conn, _ = srv.accept()
            conn.settimeout(5)
            try:
                conn.recv(2048)
            except OSError:
                pass
            if reply:
                conn.sendall(reply)
            if hold:
                keep.append(conn)
            else:
                conn.close()
    except OSError:
        pass


def selftest():
    root = tempfile.mkdtemp(prefix="dockersock-")
    fixtures = []

    absent = os.path.join(root, "absent.sock")
    fixtures.append(("no socket file", absent, "ENDPOINT_MISSING"))

    notsock = os.path.join(root, "docker.sock")
    open(notsock, "w").close()
    fixtures.append(("regular file at the path", notsock, "NOT_A_SOCKET"))

    stale = os.path.join(root, "stale.sock")
    s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    s.bind(stale)
    s.close()                                     # the inode survives, the listener does not
    fixtures.append(("stale socket inode", stale, "STALE_SOCKET"))

    lockdir = os.path.join(root, "locked")
    os.makedirs(lockdir)
    locked = os.path.join(lockdir, "docker.sock")
    threading.Thread(target=_mock_dockerd, args=(locked, None), daemon=True).start()
    time.sleep(0.3)
    os.chmod(lockdir, 0o000)
    fixtures.append(("socket in a directory you cannot search", locked, "EACCES"))

    wedged = os.path.join(root, "wedged.sock")
    threading.Thread(target=_mock_dockerd, args=(wedged, None, True), daemon=True).start()
    time.sleep(0.3)
    fixtures.append(("daemon accepts, never answers", wedged, "WEDGED"))

    notdocker = os.path.join(root, "notdocker.sock")
    html = b"<html><body>hello from something else</body></html>"
    reply = (b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: "
             + str(len(html)).encode() + b"\r\n\r\n" + html)
    threading.Thread(target=_mock_dockerd, args=(notdocker, reply), daemon=True).start()
    time.sleep(0.3)
    fixtures.append(("something else owns the socket", notdocker, "NOT_DOCKER"))

    healthy = os.path.join(root, "healthy.sock")
    # A daemon answers GET /_ping with a 2-byte text/plain "OK" body (moby swagger:
    # produces text/plain, example "OK"), but this probe is a HEAD, and moby's ping
    # handler short-circuits HEAD with Content-Length: 0 and no body. The fixture
    # therefore answers HEAD the way a daemon does, with the metadata headers the
    # version middleware and the ping handler add, Date included.
    ok = (b"HTTP/1.1 200 OK\r\nApi-Version: 1.47\r\nBuilder-Version: 2\r\n"
          b"Cache-Control: no-cache, no-store, must-revalidate\r\nContent-Length: 0\r\n"
          b"Content-Type: text/plain; charset=utf-8\r\n"
          b"Date: Wed, 16 Sep 2026 21:30:00 GMT\r\nDocker-Experimental: false\r\n"
          b"Ostype: linux\r\nPragma: no-cache\r\nServer: Docker/27.5.1 (linux)\r\n"
          b"Swarm: inactive\r\n\r\n")
    threading.Thread(target=_mock_dockerd, args=(healthy, ok), daemon=True).start()
    time.sleep(0.3)
    fixtures.append(("dockerd answering on the socket", healthy, "HEALTHY"))

    # malformed input: the three shapes people actually paste
    fixtures.append(("tcp endpoint with no port", "tcp://127.0.0.1", "BAD_USAGE"))
    fixtures.append(("the URL lifted from the error message",
                     "http://%2Fvar%2Frun%2Fdocker.sock/v1.45/containers/json", "BAD_USAGE"))
    fixtures.append(("IPv6 endpoint, nothing listening", "tcp://[::1]:23750", "ENDPOINT_MISSING"))

    print("fixture                                  branch              exit")
    print("-" * 66)
    failures = 0
    for label, endpoint, expected in fixtures:
        branch, detail = classify(endpoint, timeout=2.0)
        flag = "" if branch == expected else "  <-- EXPECTED %s" % expected
        failures += branch != expected
        print("%-40s %-16s %4d%s" % (label, branch, BRANCH_CODES[branch], flag))
        if flag:
            print("    detail: %s" % detail)
    os.chmod(lockdir, 0o755)
    print("-" * 66)
    print("%d/%d fixtures classified as expected" % (len(fixtures) - failures, len(fixtures)))
    return 0 if not failures else 1


def main(argv=None):
    ap = UsageParser(description=__doc__.splitlines()[0])
    ap.add_argument("endpoint", nargs="?", default=os.environ.get("DOCKER_HOST", "unix:///var/run/docker.sock"),
                    help="unix:///path or tcp://host:port (default: $DOCKER_HOST or /var/run/docker.sock)")
    ap.add_argument("--timeout", type=float, default=3.0, help="per-operation timeout in seconds")
    ap.add_argument("--selftest", action="store_true", help="build one fixture per branch and classify it")
    args = ap.parse_args(argv)

    if args.selftest:
        return selftest()

    t0 = time.time()
    branch, detail = classify(args.endpoint, timeout=args.timeout)
    elapsed = time.time() - t0
    print("endpoint : %s" % args.endpoint)
    print("branch   : %s (exit %d) after %.3f s" % (branch, BRANCH_CODES[branch], elapsed))
    print("detail   : %s" % detail)
    print("fix      : %s" % FIXES[branch])
    return BRANCH_CODES[branch]


if __name__ == "__main__":
    sys.exit(main())

The selftest builds ten fixtures: no socket file, a regular file where the socket should be, a stale socket inode, a socket inside a directory you cannot search, a daemon that accepts and never answers, a process that answers HTTP but is not dockerd, a mock daemon that answers the way the API documents /_ping, two malformed endpoints, and an IPv6 endpoint with nothing behind it. Run on 2026-09-16, the classifier got all ten:

$ python3 docker_sock_triage.py --selftest
fixture                                  branch              exit
------------------------------------------------------------------
no socket file                           ENDPOINT_MISSING    4
regular file at the path                 NOT_A_SOCKET        5
stale socket inode                       STALE_SOCKET        2
socket in a directory you cannot search  EACCES              3
daemon accepts, never answers            WEDGED              6
something else owns the socket           NOT_DOCKER          1
dockerd answering on the socket          HEALTHY             0
tcp endpoint with no port                BAD_USAGE           7
the URL lifted from the error message    BAD_USAGE           7
IPv6 endpoint, nothing listening         ENDPOINT_MISSING    4
------------------------------------------------------------------
10/10 fixtures classified as expected

Against this host's real endpoint it reports the branch you would hit here — the client is installed and the daemon is not:

$ python3 docker_sock_triage.py
endpoint : unix:///var/run/docker.sock
branch   : ENDPOINT_MISSING (exit 4) after 0.000 s
detail   : no socket file at /var/run/docker.sock
fix      : start the daemon (sudo systemctl enable --now docker) or point the client at the right endpoint (DOCKER_HOST, docker context)

$ python3 docker_sock_triage.py tcp://127.0.0.1:23750
endpoint : tcp://127.0.0.1:23750
branch   : ENDPOINT_MISSING (exit 4) after 0.001 s
detail   : nothing is listening on 127.0.0.1:23750

$ python3 docker_sock_triage.py tcp://127.0.0.1
endpoint : tcp://127.0.0.1
branch   : BAD_USAGE (exit 7) after 0.000 s
detail   : 'tcp://127.0.0.1' has no port (expected tcp://host:port)
fix      : pass unix:///path/to/docker.sock or tcp://host:port

The tool does not replace the docker CLI; it answers the question the CLI declines to answer. When it says HEALTHY and docker ps still fails, you are looking at a context or authorisation problem rather than a transport one, and the message will not be from this family.

Branch 1: nothing is listening on the endpoint

This is the branch everybody assumes, and it is only correct when ls -l finds nothing at the path or curl is refused. Two sub-cases hide inside it, and they need different actions.

The first is a daemon that is not running. Docker's post-installation documentation covers the systemd lifecycle: the service is docker.service, the socket unit is docker.socket, and on Debian and Ubuntu the service starts at boot by default. If you installed from a static archive, from a snap, or as rootless Docker, the daemon may never have been enabled at all — and rootless is the case that wastes the most time, because its socket lives under $XDG_RUNTIME_DIR rather than in /var/run, so the client's default endpoint points at a daemon that was never there.

# On a systemd host (Docker's documented post-installation steps)
sudo systemctl status docker.service docker.socket
sudo systemctl enable --now docker
journalctl -u docker.service -n 50 --no-pager

# Rootless installs listen on a per-user socket and never touch /var/run
export DOCKER_HOST=unix:///run/user/1000/docker.sock
systemctl --user status docker

The second sub-case is subtler: the socket file exists, nothing is listening on it, and the client's connect() returns ECONNREFUSED. That is exactly what my "stale socket inode" fixture does — bound once, listener closed, file left behind — and the client reported it with the same sentence and the same real 0m0.013s as the missing-file case, byte-identical output. The two look the same in a terminal, so look at the file.

A leftover file is not by itself what stops a daemon from restarting; the useful check is who holds the path, because binding it twice is refused by the kernel. Measured with a listener holding the path, a second bind returned OSError: [Errno 98] Address already in use — the same check a starting dockerd hits when a live process already owns the socket, which is where its bind: address already in use log line comes from. Docker's rootless setup page removes the file explicitly once the rootful units are disabled (sudo systemctl disable --now docker.service docker.socket, then sudo rm /var/run/docker.sock), which is the documented and safe sequence for clearing a socket a stopped daemon left behind.

One more detail worth knowing on modern Debian: /var/run is a symlink to /run, verified on this host, so the two paths that appear in every message and every unit file are the same file. That is why the client prints /var/run/docker.sock while anything that prints the resolved path — ls -l /run/docker.sock, or the socket's own listing in ss — shows /run/docker.sock. If you have seen both during one incident, nothing was wrong with your configuration.

If the endpoint is a TCP one, the same branch applies with a different failure underneath: measured against tcp://127.0.0.1:23750 with nothing listening, the client printed the identical sentence with the TCP address substituted, also in real 0m0.013s. That "everything except the endpoint string is the same" behaviour is the whole personality of this error class.

Branch 2: permission denied on the socket

Here is the measured string, from a socket whose parent directory this process could not search. It is the same message your host will print with /var/run/docker.sock in place of the fixture path:

permission denied while trying to connect to the Docker daemon socket at unix:///tmp/pair-r6s4_ly_/locked/docker.sock: Get "http://%2Ftmp%2Fpair-r6s4_ly_%2Flocked%2Fdocker.sock/v1.45%2Fcontainers%2Fjson": dial unix /tmp/pair-r6s4_ly_/locked/docker.sock: connect: permission denied

Read the tail: connect: permission denied. That is EACCES from the kernel, and it can arrive for two different reasons. The socket's own mode can deny you write access — the default after a package install is srw-rw---- root docker, which is read/write for root and the docker group and nothing for anyone else. Or a directory above the socket can deny you search permission, in which case the client cannot even reach the inode; my fixture made the parent directory mode 000, and the client produced the message above for that reason alone. Both produce one sentence, so check the socket and its parent.

Docker's documentation is explicit about the intended fix and its price. The daemon binds a unix socket, root owns it, and the documented way to run docker without sudo is to create a docker group and add users to it — with two warnings that matter here: the group is root-equivalent, and you must "log out and log back in so that your group membership is re-evaluated".

# Who are you, and are you in the socket's group?
id -nG                        # hermes
ls -l /var/run/docker.sock    # srw-rw---- 1 root docker ...  -> you must be in group docker
getent group docker           # no output and exit 2 = that group does not exist here

# Add your user, then refresh the session (Docker's documented procedure)
sudo usermod -aG docker "$USER"
newgrp docker                 # or, for a single command:
sg docker -c 'docker version'

That last step is where most people lose the hour. usermod -aG writes /etc/group immediately, but every process already running keeps the group set it was started with, so your current shell stays unprivileged until it is replaced. Both tools that refresh it were exercised on this rig: sg ran a command with the group set read from /etc/group and returned the expected group for an existing one (exit 0), and both sg and newgrp refused a group that does not exist, with sg: no such group and newgrp: no such group. If you see either of those lines, stop: on that host the docker package never created the group, and no amount of session refreshing will fix it. That is what getent group docker is for — empty output with exit code 2 means "no such group", not "the command failed".

⚠️ Two ways this fix goes wrong. sudo chmod 666 /var/run/docker.sock removes the symptom and also lets every user on the box start a container with host-level privileges; the mode is recreated by the daemon on its next start, so you have bought nothing. And adding a service account to docker is a privilege escalation by design, not a permission tweak — if that account is reachable from your application, you have just given the application root. Reach for a socket proxy or rootless Docker when least privilege is the requirement.

Branch 3: something answered, and it was not dockerd

This is the branch that makes people question their sanity, because the socket exists, the permissions are right, the daemon is running — and the client still fails. It happens when a second process owns the path: a leftover socat or SSH tunnel from an earlier debugging session, a socket-aware proxy, a container started with the socket mounted in a way you forgot about, or a script that recreated the socket itself.

Two measured variants, from two impostors. A peer that accepts the connection and replies with something which is not HTTP at all breaks the transport before the request completes:

error during connect: Get "http://%2Fopt%2Fdata%2Fc2cz%2Fscratch%2Flab0916%2Fgarb.sock/v1.45/containers/json": net/http: HTTP/1.x transport connection broken: malformed HTTP status code "nonsense"

A peer that is a perfectly valid HTTP server, but not a Docker API, gets further and fails at the JSON layer instead — a log line plus a parser error rather than a connection error:

2026/09/16 21:34:39 Unsolicited response received on idle HTTP channel starting with "<html><body>hello from something else</body></html>"; err=<nil> | invalid character '<' looking for beginning of value

Both mean the same thing operationally: whatever is on that path is not your daemon. The fastest proof is to ask the socket a question by hand and to look at which process holds it.

# Ask the socket a question and read the answer, not the exit code
curl -sS -i --max-time 4 --unix-socket /var/run/docker.sock http://localhost/_ping | head -8

# Who owns the socket file? (iproute2; needs root to see other users' processes)
sudo ss -xlp | grep -F docker.sock

# Stop pointing the client at it
unset DOCKER_HOST
docker context use default

A healthy daemon answers HEAD /_ping with 200 OK and its own headers — Api-Version, Docker-Experimental, Ostype, Server, Date and the rest. The body is the one thing HEAD changes: moby's ping handler short-circuits a HEAD with Content-Length: 0 and no body at all, while a GET /_ping returns the two-byte OK shown in the transcript above. The mock daemon inside the classifier's selftest answers the tool's HEAD probe the way a daemon does, which is why the tool accepts it as HEALTHY; a proxy that forwards the socket but rewrites or drops headers is the case that fools a hand-rolled check, and it is also the case that produces the version-negotiation confusion covered next.

Branch 4: the daemon is alive and not answering

Here is the measurement that should change how you read this error. I built a socket that accepts connections and never writes a byte — the kernel-level equivalent of a dockerd that is alive but stuck — and ran docker ps against it with a 90-second outer limit. The client sent its request, got nothing, printed nothing, and was still running when the harness killed it: no stdout, no stderr, no exit code. The docker client applies no request timeout of its own, so a daemon that has stopped answering does not fail the way a stopped daemon fails. It hangs.

The flip side is the part that matters for anyone reading logs. When the listening socket in that rig closed on its own, the pending request failed, and the client printed the same sentence it prints when nothing is there at all:

$ time docker ps        # socket accepts the connection and never answers
# no stdout, no stderr, no exit code: the harness killed the process at 90 s

$ time docker ps        # same rig, but this time the listening socket closed on its own
Cannot connect to the Docker daemon at unix:///opt/data/c2cz/scratch/lab0916/wedged.sock. Is the docker daemon running?
real    1m2.069s
exit=1

62.069 seconds in one run, 32.044 s in another where the socket closed sooner, both ending in "Is the docker daemon running?" — about a daemon the client had connected to successfully. So the sentence is not a statement about the daemon; it is a statement about the client's attempt to hold a conversation. Between "not there" and "not answering", the only signal you get is timing: 13 ms, or never.

That makes time docker ps a genuinely diagnostic command rather than a nervous habit. Milliseconds means branch one: nothing is listening, and the fix is to start something. No return at all means the connection was accepted and the daemon is not serving — a worse problem, and one where restarting without reading the log first throws away the evidence of why it wedged.

# A stopped daemon fails in 13 ms. A wedged one never fails at all.
time docker ps
# real    0m0.013s                      <- branch 1: nothing is listening
# (no output, still blocked after 90 s) <- branch 4: connected, and not answering

# Read the daemon's own log before restarting it
sudo journalctl -u docker.service --since "10 min ago" --no-pager | tail -50
sudo systemctl restart docker

What wedges a daemon is not something this rig can reproduce honestly, and I am not going to guess at it here. The answer is in the log line above the restart, whether that is a storage driver blocked on a disconnected mount, containerd not answering, or the daemon's own goroutines stopped making progress. The habit worth adopting is that the log read comes first, because the restart is usually the thing that deletes the evidence.

What the client sends before it gives up

One more measured detail, because it explains a whole class of misleading failures. With a listener on the socket that answers the way the API documents, the docker client's very first exchange is two requests, and the second one carries the API version it negotiated:

HEAD /_ping HTTP/1.1
Host: api.moby.localhost
User-Agent: Docker-Client/26.1.5+dfsg1 (linux)

GET /v1.45/version HTTP/1.1
Host: api.moby.localhost
User-Agent: Docker-Client/26.1.5+dfsg1 (linux)

Two things to notice. The host header is api.moby.localhost, not a real hostname — over a unix socket there is no DNS, and that placeholder is how the Go HTTP client is told to route the request. And the version in the path is negotiated, not fixed: the client pings first, reads Api-Version from that reply, and then asks for /v1.45/version when the answer advertises 1.45. My first mock advertised nothing, and the same client fell back visibly — its own output said API version: 1.24 (downgraded from 1.45) and the second request went to /v1.24/version. So a proxy in front of the socket that drops response headers does not just add latency: it silently pins your client to an older API version.

That is also why the URL-encoded form in the error message matters. http://%2Fvar%2Frun%2Fdocker.sock/v1.45/containers/json is a literal HTTP URL whose "host" is the encoded socket path; the %2F sequences are not a corrupted string, they are the only way to put a path into a URL's authority component. When you see that URL in an error, the client did reach the HTTP layer and failed there — which is why those messages carry so much more information than the plain "is the daemon running?" line.

Verifying the fix

However you fixed it, three checks confirm the fix rather than the symptom. The first is the only one that proves the client and the daemon are talking, because the Server block is populated from the daemon's own answer:

# 1. The client can reach a daemon: this block only appears if it did
docker version
# Client: Docker Engine ...
#  Version:           26.1.5+dfsg1
#  API version:       1.45
# Server:
#  Engine:
#   Version:          27.x.y

# 2. Nothing is overriding the endpoint you configured
printenv DOCKER_HOST DOCKER_CONTEXT
docker context ls

# 3. The group really is applied to this shell, not just to /etc/group
id -nG | tr ' ' '\n' | grep -x docker && echo "group docker is active in this shell"

If you were in branch two, the third check is the one to run before declaring victory: id -nG reads the groups of the current process, so it answers the question the error was actually about. A new terminal is not always a new login session — tmux, screen and long-lived SSH multiplexing all keep an old environment alive and can make a correct fix look broken. When in doubt, sg docker -c 'id -nG' tells you immediately whether the group exists and whether a fresh process picks it up.

All outputs quoted above were produced by execution on 2026-09-16 on a Debian 13 (trixie) aarch64 host, kernel 6.17.0-1019-oracle, Docker CLI 26.1.5+dfsg1 (client API 1.45), with no Docker daemon and no root: the client messages, the timings (0m0.013s for a missing file, a stale inode, a refused socket and a dead TCP port; 90 s with no output against a silent socket; 62.069 s and 32.044 s when the listener closed), the curl results and the healthy ping bytes, the errno 98 bind conflict, the DOCKER_HOST override warning, the group checks and the ten selftest classifications all come from those runs. The daemon lifecycle commands (systemctl, journalctl, the rootless DOCKER_HOST socket path, and the removal of a leftover socket file) are quoted from Docker's official Linux post-installation and rootless-mode documentation, because this container has no systemd to execute them on; the shape of the /_ping answer (text/plain, example body OK, metadata carried in the response headers) is from the official Engine API specification, and the header set the mock answers with, Server and Api-Version included, follows the daemon's own version middleware and ping handler; that mock advertises 1.47, the value an Engine 27.x daemon reports, where the 26.1.5 client negotiates 1.45, and the ss -xlp ownership check comes from iproute2. The classifier source printed above is the exact file that produced the ten-fixture table.

C2CZ

Hyper-specific engineering guides: Cloudflare Workers, Turso, self-hosting, network security, offensive security, independent operations.