Docker daemon and Docker client: how do they interact?
The Docker daemon and Docker client make up a classic client-server pair. The daemon does the actual work; the client is a frontend that speaks REST. Understanding this split is what lets you debug "why is the CLI hanging" or "why is my Mac talking to dockerd inside a VM".
Theory
TL;DR
- Daemon (
dockerd) = long-running root process. Manages images, containers, networks, volumes. Owns ALL the state. - Client (
dockerCLI) = stateless frontend. Translates your commands into REST API calls. - Transport = Unix socket (
/var/run/docker.sock) by default; TCP socket if you setDOCKER_HOST=tcp://.... - Wire format = HTTP + JSON. The Engine API is versioned (
/v1.46/...); the daemon negotiates the version with the client. - One-to-many = you can point one CLI at multiple daemons via Docker contexts (
docker context use ...).
Quick example
# What `docker ps` actually does:
$ curl --unix-socket /var/run/docker.sock \
http://docker/v1.46/containers/json | jq '.[0]'
{
"Id": "a3f9d2b8c1e4...",
"Names": ["/web"],
"Image": "nginx:1.27-alpine",
"Status": "Up 5 minutes",
"Ports": [{ "PrivatePort": 80, "PublicPort": 8080, "Type": "tcp" }]
}That is exactly what the CLI did under the hood when you typed docker ps. A GET against the socket; the daemon returns JSON; the CLI formats it into a table.
Daemon (dockerd)
A Linux service, usually started by systemd as root. Responsibilities:
- Image management: pulls from registries, builds via BuildKit, stores under
/var/lib/docker/. - Container lifecycle: delegates to
containerdandrunc, but owns the user-facing container API. - Networking: creates virtual bridges, manages iptables rules, allocates IP addresses for containers.
- Volumes: named volumes, bind mounts, plugin-based storage drivers.
- API endpoint: listens on the configured socket(s).
Configuration lives in /etc/docker/daemon.json and command-line flags. Typical settings: data-root (where to store images), storage-driver (overlay2 by default), log-driver, dns, registry-mirrors.
Client (docker CLI)
A single binary, no daemon, no state of its own. When you type a command:
- Parses arguments (
docker run -p 8080:80 nginx). - Looks up the active context (which daemon to talk to).
- Builds an HTTP request with appropriate headers (
Content-Type: application/json, API version). - Sends it over the socket; reads the response.
- Formats the response (table, JSON, custom template via
--format).
The CLI is so thin that you can replace it with curl for any operation, and many docs do exactly that.
Transport: Unix socket vs TCP
By default, dockerd listens on a Unix socket: /var/run/docker.sock. Local-only, fast, secured by file permissions.
$ ls -l /var/run/docker.sock
srw-rw---- 1 root docker 0 Apr 30 10:00 /var/run/docker.sock
# Mode 660: owner=root, group=docker. To use docker without sudo,
# add yourself to the `docker` group.For remote management, the daemon can also listen on TCP. Configure in daemon.json:
{
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
"tls": true,
"tlsverify": true,
"tlscacert": "/etc/docker/ca.pem",
"tlscert": "/etc/docker/server-cert.pem",
"tlskey": "/etc/docker/server-key.pem"
}Then point a remote client at it:
$ DOCKER_HOST=tcp://docker.example.com:2376 docker psAlways use TLS for TCP exposure. An unprotected tcp://0.0.0.0:2376 is a remote root shell waiting to happen - the Docker API can run any container with --privileged, mount host paths, and effectively own the box.
Docker contexts
One CLI, multiple daemons. Useful when you manage local dev plus a remote prod or staging.
$ docker context create staging \
--docker "host=ssh://user@staging.example.com"
$ docker context ls
NAME DESCRIPTION DOCKER ENDPOINT
default Local Docker unix:///var/run/docker.sock
staging ssh://user@staging.example.com
$ docker context use staging
$ docker ps # now lists containers on staging, over SSHNo more DOCKER_HOST=... env-var juggling. Switch with one command.
API versioning
The Engine API is versioned. The CLI sends a version like /v1.46/...; the daemon either accepts it or, if too old, returns a downgrade hint. Forward compatibility means a v25 client can usually talk to a v23 daemon (and vice versa within reason). Mismatch:
$ docker ps
Error response from daemon: client version 1.47 is too new.
Maximum supported API version is 1.45.Fix: upgrade the daemon, or pin the client with DOCKER_API_VERSION=1.45.
Common mistakes
Adding yourself to the docker group as a security "convenience"
$ sudo usermod -aG docker $USER
# Now you can run docker without sudo. Convenient. Also dangerous.Being in the docker group is equivalent to root. The daemon API can mount host paths into a container and chmod files - any unprivileged user with socket access has a path to root. For a developer machine, fine. For a multi-user server, no.
Exposing TCP without TLS
Found on countless internet-facing servers. dockerd -H tcp://0.0.0.0:2375 (note the unencrypted port) gives any internet user the ability to run privileged containers and root the host. Always TLS, always client cert auth.
Confusing the CLI being broken with the daemon being broken
$ docker ps
Cannot connect to the Docker daemon at unix:///var/run/docker.sock.
Is the docker daemon running?The error is honest: it could be the daemon stopped (systemctl status docker), the socket file missing, or a permissions issue (you are not in the docker group). The CLI itself is rarely broken; the message points at the daemon.
Restarting dockerd to fix something and accidentally killing all containers
With default settings on older Docker, systemctl restart docker killed containers. Modern Docker has live-restore:
// /etc/docker/daemon.json
{ "live-restore": true }With this on, systemctl restart docker keeps containers running across daemon restarts. The containerd-shim per container is what makes this work.
Real-world usage
- Docker Desktop on Mac/Windows: the daemon lives inside a small Linux VM. Your
dockerCLI on the host talks to it via a forwarded socket. You see this in Docker Desktop > Settings > Resources where you allocate the VM's RAM and CPU. - CI/CD runners: each runner has its own dockerd. Jobs use the local daemon to build and push images. "Docker-in-Docker" is a pattern where a job runs
dockerditself inside a privileged container, but it has security tradeoffs. - Remote-host management: ops teams use
docker contextover SSH to admin a remote staging or prod host without giving every admin a TCP-exposed daemon. - Rootless Docker:
dockerdrunning as a non-root user. Each user gets their own daemon and socket. Used in HPC clusters and security-sensitive environments.
Follow-up questions
Q: What happens if the daemon dies but containers are running?
A: With live-restore enabled (default in modern Docker), containers keep running. The containerd-shim per container holds them. The CLI cannot interact with the daemon during this window, but the containers themselves and their network keep working. When dockerd restarts, it reattaches.
Q: Can two clients talk to the same daemon at once?
A: Yes. The daemon serializes operations on shared state but happily handles many concurrent clients. CI runs and your local docker ps against the same daemon work fine in parallel.
Q: Why does my command line hang on docker ps?
A: Almost always a daemon issue. Either it is overloaded (many concurrent operations), deadlocked on a storage driver issue, or stuck waiting on a hung containerd. journalctl -u docker and journalctl -u containerd are your first stops.
Q: Can I write my own Docker client?
A: Yes - the Engine API is documented and stable. The official Go SDK (github.com/docker/docker/client) is what the docker CLI itself uses. Plenty of third-party tools (Lazydocker, Portainer, Dockge) are essentially custom clients on top of the same API.
Q: (Senior) What is the security exposure of mounting /var/run/docker.sock into a container?
A: Mounting the socket gives that container full control over the host's Docker daemon. From inside, it can launch any container with --privileged, bind-mount /, and effectively become root on the host. Common in CI tools that need to spawn build containers (DinD alternative), but a known supply-chain risk: a malicious image with socket access escalates to host root. Use rootless Docker, or sysbox, or per-runner dockerds to limit the blast radius.
Examples
Hitting the Engine API directly
# List all images
$ curl --unix-socket /var/run/docker.sock \
http://docker/v1.46/images/json | jq '.[].RepoTags'
["nginx:1.27-alpine"]
["postgres:16", "postgres:latest"]
# Create and start a container in two API calls
$ CID=$(curl --unix-socket /var/run/docker.sock \
-H 'Content-Type: application/json' \
-X POST "http://docker/v1.46/containers/create?name=test" \
-d '{"Image":"alpine","Cmd":["echo","hi"]}' | jq -r .Id)
$ curl --unix-socket /var/run/docker.sock \
-X POST http://docker/v1.46/containers/$CID/startNo docker CLI involved. The CLI is convenience; the API is authoritative.
Switching between local and remote
$ docker context create prod-eu --docker "host=ssh://ops@prod-eu.example.com"
$ docker context create prod-us --docker "host=ssh://ops@prod-us.example.com"
$ docker context use prod-eu
$ docker ps # containers in EU
$ docker --context prod-us ps # one-off command against USOne CLI, three daemons (local, EU, US). The contexts file at ~/.docker/contexts/ holds the routing.
Tracing a CLI call to confirm the wire protocol
$ DOCKER_DEBUG=true docker ps 2>&1 | head -10
GET /v1.46/containers/json HTTP/1.1
Host: docker
User-Agent: Docker-Client/26.1.0 (linux)
Content-Type: application/json
HTTP/1.1 200 OK
Api-Version: 1.46
Content-Type: application/json
Docker-Experimental: false
Server: Docker/26.1.0 (linux)GET request, JSON response, version negotiation in headers. That is all there is to the protocol.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.
Comments
No comments yet