Skip to main content
RunBook Academy

Docker & ContainersXXXII Β· Docker InternalsEngine API

The Engine API β€” what the CLI is really doing

Advanced⏱ ~22 min

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

Not yet marked complete on this device.

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

Read-only / Safe/_ping
$ curl -s -i --unix-socket /var/run/docker.sock http://localhost/_ping
HTTP/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:

Read-only / Safethe CLI, by hand
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/info

Versioned 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.

Read-only / Safepinning the client
$ 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.55

Pinning 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:

Read-only / Safebelow the floor
$ 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/json
29.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 version

MinAPIVersion 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:

EndpointCLIBehaviour
/eventsdocker eventsNewline-delimited JSON, indefinitely
/containers/{id}/statsdocker statsOne JSON object per sample
/containers/{id}/logs?follow=truedocker logs -fMultiplexed stdout/stderr frames
/containers/{id}/attachdocker attachHTTP connection upgrade to a raw bidirectional stream
Read-only / Saferaw event 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":"043

Sanity check

Knowledge check Β· 4 questions

  1. Q1. How does a 1.55 CLI work correctly against an older daemon?

  2. Q2. A daemon reports ApiVersion 1.55 and MinAPIVersion 1.40. What happens to a client pinned to 1.24?

  3. Q3. Which of these can break after an engine upgrade that raises MinAPIVersion? Select all that apply.

  4. 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.