Philipp Lehmann @PhilippTheServer

Running BuildKit as a Remote Builder Without a Docker Daemon

buildx's default driver needs a docker.sock that a containerd-based CI worker does not have

The problem

docker buildx build looks like a single command, but by default it needs somewhere to run BuildKit itself. The docker-container driver, which is what you get out of the box, creates that somewhere by asking the local Docker daemon to start a BuildKit container:

docker buildx create --use
docker buildx build -t demo:local .
error during connect: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.24/...":
open /var/run/docker.sock: no such file or directory

On a CI worker scheduled as a plain container under containerd — a Kubernetes job without Docker-in-Docker, a Buildkite or GitLab agent running as a pod rather than a VM — there is no docker.sock to ask. There is a container runtime, but it belongs to the node, is not exposed to the workload, and mounting it in is exactly the “give CI a socket that grants root on the node” problem most of these platforms are configured to prevent.

The instinctive fixes make it worse. Docker-in-Docker means running a privileged container to get a daemon you then throw away at the end of the job — real image-cache loss between runs, plus the container runtime policy exception you were trying to avoid in the first place. Falling back to a plain docker build does not exist as an option: under containerd there is no docker at all, only a build tool that can talk to a builder.

The part that is easy to miss: BuildKit itself does not need a Docker daemon. It is a standalone build engine — docker-container is only one way to reach it, and it happens to be the one that requires a daemon. buildx has a remote driver that connects straight to an already-running BuildKit instance over gRPC, and that instance can live anywhere that can be reached over the network.

Working through it

Separating “the tool that builds” from “the tool that talks to it”

buildx is a client. It sends a build request and streams back progress; it does not itself compile anything. docker-container bundles “start a builder” and “talk to it” into one driver because that is convenient on a workstation with Docker installed. The remote driver splits those apart: something else is responsible for having BuildKit running, and buildx just needs an address to connect to.

That split is what makes a containerd-based CI worker workable: BuildKit runs once, as a long-lived service, wherever it is convenient to run a normal (non-privileged-from-the- worker’s-perspective) container — a dedicated node, a small VM, a Kubernetes deployment outside the CI runners’ own restricted namespace. The CI job’s only requirement becomes network reachability and a certificate.

Exposing BuildKit safely

buildkitd can listen on a TCP address, but an unauthenticated BuildKit listener is equivalent to a root shell on whatever runs it — a build can mount and write anything the container can. It ships with an --addr flag plus TLS flags, so treat this exactly like any other remotely reachable privileged service: client-certificate authentication, not just server TLS.

Pointing buildx at it

Once a certificate-authenticated buildkitd is running somewhere reachable, the client side is one buildx create call, and it needs no daemon at all:

docker buildx create \
  --name remote-builder --driver remote \
  --driver-opt servername=buildkitd \
  --driver-opt cacert=./certs/ca.pem \
  --driver-opt cert=./certs/client.pem \
  --driver-opt key=./certs/client-key.pem \
  tcp://buildkitd:1234
docker buildx use remote-builder

Everything downstream — docker buildx build, cache import/export, multi-platform builds — behaves exactly as it does with docker-container, because from buildx’s point of view it is still just talking to a BuildKit gRPC endpoint. Only how it got there changed.

The solution

A complete, laptop-runnable setup: certificates, a standalone buildkitd service, and a build run against it with no Docker daemon involved in the build itself (the buildkitd container is the only place BuildKit runs; docker compose is only used here to stand the demo up).

#!/usr/bin/env bash
# gen-certs.sh — a minimal CA plus server and client certs for buildkitd
set -euo pipefail
mkdir -p certs && cd certs

openssl req -x509 -newkey rsa:2048 -days 365 -nodes \
  -keyout ca-key.pem -out ca.pem -subj "/CN=buildkit-demo-ca"

openssl req -newkey rsa:2048 -nodes -keyout server-key.pem -out server.csr \
  -subj "/CN=buildkitd"
openssl x509 -req -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
  -days 365 -out server.pem \
  -extfile <(printf "subjectAltName=DNS:buildkitd")

openssl req -newkey rsa:2048 -nodes -keyout client-key.pem -out client.csr \
  -subj "/CN=buildkit-client"
openssl x509 -req -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
  -days 365 -out client.pem
# docker-compose.yml
services:
  buildkitd:
    image: moby/buildkit:v0.13.2
    privileged: true
    command:
      - --addr=tcp://0.0.0.0:1234
      - --tlscacert=/certs/ca.pem
      - --tlscert=/certs/server.pem
      - --tlskey=/certs/server-key.pem
    volumes:
      - ./certs:/certs:ro
    ports:
      - "1234:1234"
# app/Dockerfile — a trivial image to build against the remote builder
FROM alpine:3.20
RUN echo "built by a remote BuildKit, no docker.sock involved" > /note.txt
CMD ["cat", "/note.txt"]
./gen-certs.sh
docker compose up -d
sleep 2

docker buildx create \
  --name remote-builder --driver remote \
  --driver-opt servername=buildkitd \
  --driver-opt cacert=./certs/ca.pem \
  --driver-opt cert=./certs/client.pem \
  --driver-opt key=./certs/client-key.pem \
  tcp://localhost:1234
docker buildx use remote-builder

docker buildx build --load -t buildkit-remote-demo:local ./app
docker run --rm buildkit-remote-demo:local
[+] Building 1.2s (5/5) FINISHED
 => [internal] load build definition from Dockerfile
 => [1/1] RUN echo "built by a remote BuildKit, no docker.sock involved" > /note.txt
 => exporting to docker image format
built by a remote BuildKit, no docker.sock involved

docker buildx create never touched a local docker.sock for the build itself — it only used one here, on the laptop, to stand up the demo’s buildkitd container via docker compose. In a containerd-based CI worker, buildkitd would already be running as a standing deployment (on a node or namespace that is allowed a privileged workload), and the job would run only the buildx create / buildx build block above against that address.

Conclusion

A CLI’s default driver is a convenience, not the tool’s actual requirement. buildx needing a Docker daemon is an artefact of docker-container being the default driver, not a property of BuildKit. Read past the default before concluding a platform cannot do something.

Run privileged, long-lived infrastructure once, and give many short-lived workers network access to it, rather than giving every worker the privilege itself. A single buildkitd under a policy exception is a much smaller surface than granting every CI job a socket that behaves like root on its node.

Authenticate a shared build daemon like you would any other privileged service. An open BuildKit TCP listener is a bigger problem than the daemon you removed by no longer using Docker-in-Docker; client-certificate authentication is not optional here.