How to Use Go With Docker SDK for Container Tools Safely
Use the Go Docker SDK to list, start and inspect containers: create a client from the environment, negotiate the API version, stream logs and pull images.

The docker CLI is itself a client of the Engine API, and the same API is open to your programs. A Go tool that talks to it directly avoids parsing CLI output and can stream events. The SDK's surface is large and has been reorganized more than once, so this post sticks to the stable core and says where to check. It is a tooling step after calling the Docker API with curl. Examples target the github.com/docker/docker module at the 27.x line and Go 1.24.
List containers
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
)
func main() {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
log.Fatal(err)
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
list, err := cli.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
log.Fatal(err)
}
for _, c := range list {
fmt.Printf("%.12s %-20s %s\n", c.ID, c.Names[0], c.Status)
}
}go mod init dockerls && go get github.com/docker/docker@v27 && go mod tidy
go run .3f2a9c1d4b7e /web Up 2 hours
a81c0e5f92d3 /db Exited (0) 3 days ago
FromEnv reads DOCKER_HOST, DOCKER_TLS_VERIFY and related variables, and falls back to the default Unix socket, so the tool works wherever the CLI works. Without WithAPIVersionNegotiation, a client compiled against a newer API can fail against an older daemon with "client version 1.47 is too new". Names start with a slash, as returned by the API.
Start, stop and read logs
if err := cli.ContainerStart(ctx, "web", container.StartOptions{}); err != nil {
log.Fatal(err)
}
rc, err := cli.ContainerLogs(ctx, "web", container.LogsOptions{
ShowStdout: true, ShowStderr: true, Tail: "20",
})
if err != nil { log.Fatal(err) }
defer rc.Close()
stdcopy.StdCopy(os.Stdout, os.Stderr, rc)Containers started without a TTY multiplex stdout and stderr into one stream with an 8-byte header on every frame. stdcopy.StdCopy from github.com/docker/docker/pkg/stdcopy demultiplexes it; skipping it prints garbage bytes around each line. With Follow: true the call streams until the context is canceled.
Pull an image
rc, err := cli.ImagePull(ctx, "docker.io/library/alpine:3.20", image.PullOptions{})
if err != nil { log.Fatal(err) }
defer rc.Close()
io.Copy(io.Discard, rc)The pull returns a progress stream, and the pull does not complete until you read it to the end. Discarding it is the simplest way to wait.
Security and compatibility
Access to the Docker socket is access to root on the host: anyone who can create a container can mount the host filesystem. A tool that needs only to read should talk to a socket proxy that exposes read-only endpoints. See hardening Docker containers. The Moby project has been moving client code to new module paths; when go get reports that a symbol or package moved, read the release notes of the version you pinned, and keep the dependency version fixed in go.mod.
Where this does not apply
For a one-off action, docker with --format and jq is quicker than a program. Reach for the SDK when you need events, streaming, concurrency or a tool you ship as a binary. For Kubernetes, a different client applies.
What the client talks to
The Docker SDK for Go is a typed client for the daemon's HTTP API, which listens on a Unix socket, normally /var/run/docker.sock. Everything the command line does goes through the same API, so a program using the SDK can list, create and remove containers, pull images and stream logs. Create the client with options that read the standard environment variables, DOCKER_HOST and the TLS settings, and enable API version negotiation so the client and daemon agree on a version and a newer SDK does not fail against an older daemon. Without negotiation, an error about a client version being too new is the usual result of running a program built against a recent SDK on a host with an old Docker. The module paths and package layout of the SDK have changed across releases, so follow the current documentation for the import path to use.
Always close the client when done, pass a context.Context to every call, and set deadlines, since a call to a stuck daemon would otherwise block forever.
Streams, logs and events
Several calls return streams, not values, and they need careful handling. Container logs come back as a reader. For a container started without a TTY, the stream is multiplexed, with a small header before each chunk that says whether it is standard output or error, and the stdcopy.StdCopy helper splits it correctly. Reading it as plain text yields stray bytes at the start of each line. For a container with a TTY, the stream is raw. Pulling an image returns a stream of JSON progress messages, and the pull has not finished until you read it to the end, so ignoring the reader and closing it cancels the pull. The events API streams daemon events such as container starts and dies, and it is the right basis for tools that react to changes instead of polling the list every few seconds.
Handle errors by type. The SDK has helpers such as client.IsErrNotFound to tell a missing container from a failure, and a tool that removes containers should treat "not found" as success.
Access to the socket is root access
Anything that can talk to the Docker socket can start a privileged container that mounts the host's root file system, which makes access to the socket equivalent to root on the machine. Treat the program accordingly. Do not expose the socket over the network without mutual TLS, do not mount it into containers that do not need it, and be careful with web applications that call the API on behalf of users, since a flaw in them becomes a host compromise. A socket proxy that allows only specific API calls limits the damage for tools that need only to list containers. Rootless Docker moves the daemon into a user account, and the socket then lives under /run/user/UID/, so the default path in your program may be wrong there and DOCKER_HOST should be honored.
Weigh the effort against the alternatives. For a one-time job, a shell script calling the CLI with --format and jq is shorter. A Go program pays off when you need typed results, concurrency, long-running event handling or a single static binary to ship.
A realistic small tool
A useful first project is a cleanup tool: list containers with a label, check their age and state, and remove those that exited more than a day ago. The program builds a filter with the label and the status, calls the list endpoint, loops over the results and calls remove with the force flag off, so a running container is never touched by accident. It prints each action and supports a dry-run flag that lists what it would remove. Such a tool is short, runs from a timer and replaces a fragile shell pipeline over docker ps output. Using labels to mark what the tool may touch is the safe design, because it keeps the tool away from containers it did not create.
Test against a real daemon in a throwaway environment, not against production, and write the tests to create their own containers with a label and remove them afterwards. A fake client behind an interface, with the few methods the tool needs, makes the logic testable without Docker at all.
See the Docker Engine SDK documentation and the client package reference.


