Docker & ContainersXXXII Β· Docker InternalsEngine API
The Engine API β what the CLI is really doing
What you'll learn
- Reproduce CLI operations as raw HTTP requests against the socket
- Explain API version negotiation and pin it with DOCKER_API_VERSION
- Predict which clients an engine upgrade will break
- Recognise streaming endpoints and why they behave differently
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11
docker ps is an HTTP GET. The CLI holds no state, performs no
container logic, and knows nothing the daemon does not tell it. It
opens /var/run/docker.sock, issues a request, and formats the JSON
that comes back.
Once you have seen that, three things stop being mysterious: why a
new CLI works against an old daemon, why docker attach behaves
unlike every other subcommand, and why an engine upgrade breaks a
monitoring integration nobody touched.
The socket is an HTTP server
$ curl -s -i --unix-socket /var/run/docker.sock http://localhost/_pingHTTP/1.1 200 OK
Api-Version: 1.55
Builder-Version: 2
Cache-Control: no-cache, no-store, must-revalidate
Docker-Experimental: false
Ostype: linux
Pragma: no-cache
Server: Docker/29.7.1 (linux)
Swarm: inactive
Date: Tue, 11 Aug 2026 15:31:20 GMT
Content-Length: 2
Content-Type: text/plain; charset=utf-8/_ping is the endpoint the CLI hits first, every time, and the
whole point of it is those headers. Api-Version is what the daemon
speaks. Swarm and Ostype tell the client which subcommands make
sense.
The rest of the API follows the same pattern:
SOCK=/var/run/docker.sock
# docker version
curl -s --unix-socket "$SOCK" http://localhost/version
# docker ps
curl -s --unix-socket "$SOCK" http://localhost/containers/json
# docker ps -a
curl -s --unix-socket "$SOCK" 'http://localhost/containers/json?all=true'
# docker inspect
curl -s --unix-socket "$SOCK" http://localhost/containers/grafana/json
# docker info
curl -s --unix-socket "$SOCK" http://localhost/infoVersioned paths and negotiation
Every endpoint also exists under a version prefix:
curl -s --unix-socket /var/run/docker.sock \
'http://localhost/v1.44/containers/json?limit=1'
The daemon serves the request according to the semantics of that API version β field names, defaults and response shapes as they were at 1.44 β even though it is a 1.55 daemon.
That is what makes a newer CLI work against an older daemon. On
startup the CLI pings, reads Api-Version, and issues subsequent
requests at the lower of its own maximum and the daemonβs. This is
version negotiation, and it happens silently.
$ docker version --format '{{.Client.APIVersion}} / {{.Server.APIVersion}}'
DOCKER_API_VERSION=1.44 docker version --format '{{.Client.APIVersion}} / {{.Server.APIVersion}}'1.55 / 1.55
1.44 / 1.55Pinning is occasionally the right answer β reproducing a bug, or working around a response-shape change β and it is a footgun the rest of the time, because it also disables the automatic downgrade. Set it in a script and that script will fail against a daemon older than the pin, with an error about the version rather than about the problem.
The compatibility floor
Negotiation only works downward to a point. Every daemon publishes a minimum:
$ curl -s --unix-socket /var/run/docker.sock http://localhost/version | \
python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["Version"], d["ApiVersion"], d["MinAPIVersion"])'
curl -s --unix-socket /var/run/docker.sock http://localhost/v1.20/containers/json29.7.1 1.55 1.40
client version 1.20 is too old. Minimum supported API version is 1.40, please upgrade your client to a newer versionMinAPIVersion moves when the engine drops support for old clients,
and that is where upgrades bite.
Streaming endpoints
Most endpoints return a JSON document and close. A few hold the connection open and stream, which is why they behave differently from everything else:
| Endpoint | CLI | Behaviour |
|---|---|---|
/events | docker events | Newline-delimited JSON, indefinitely |
/containers/{id}/stats | docker stats | One JSON object per sample |
/containers/{id}/logs?follow=true | docker logs -f | Multiplexed stdout/stderr frames |
/containers/{id}/attach | docker attach | HTTP connection upgrade to a raw bidirectional stream |
$ timeout 2 curl -sN --unix-socket /var/run/docker.sock \
'http://localhost/events?since=0&until=0' | head -c 200{"Type":"container","Action":"exec_start: /bin/sh -c wget --quiet --spider http://127.0.0.1:9090/-/healthy || exit 1","Actor":{"ID":"b8b6efd9481d82e9a0449ee1a1e4862b7fbc2fea8a62f0f688591590704c9d81","Attributes":{"com.docker.compose.config-hash":"043Sanity check
Knowledge check Β· 4 questions
Q1. How does a 1.55 CLI work correctly against an older daemon?
Q2. A daemon reports ApiVersion 1.55 and MinAPIVersion 1.40. What happens to a client pinned to 1.24?
Q3. Which of these can break after an engine upgrade that raises MinAPIVersion? Select all that apply.
Q4. Piping /containers/{id}/logs straight to a file on a container without a TTY produces plain text.
Passing score: 75%. Answers are checked in this browser.