Docker 77 🐳 Custom Docker CLI Plugins: Extending Docker Functionality with Custom Binaries
Docker’s CLI plugin system is the modern, supported way to extend the docker command with new subcommands. Unlike the deprecated engine plugin system, which required registering a daemon-side plugin with a manifest and Unix socket, a CLI plugin is simply an executable binary that Docker discovers and invokes. The naming convention is the entire contract: a binary named docker-foo becomes the subcommand docker foo. Docker scans specific directories—~/.docker/cli-plugins/ for the current user, and /usr/local/libexec/docker/cli-plugins/ or /usr/libexec/docker/cli-plugins/ for system-wide installation on Linux—and any executable matching the docker- prefix is registered as a plugin . No daemon restart, no registration API, no manifest file. The binary is the plugin.
The plugin is a standalone process. Docker does not link it, does not share memory with it, and does not pass it a structured API object. It passes the command-line arguments, and the plugin does whatever it wants with them. This simplicity is the strength and the constraint. A plugin can be written in Go, Python, Rust, or a shell script, and it can call the Docker API, shell out to the docker CLI, or do something entirely unrelated to Docker. But it does not automatically inherit Docker’s context handling, its TLS configuration, or its credential store. Those must be read from the environment or the configuration file by the plugin itself .
Key point: A Docker CLI plugin is an executable named docker-<name> placed in ~/.docker/cli-plugins/ or a system plugin directory. Docker discovers it by filename and invokes it with the remaining arguments. The plugin can be written in any language, must be executable, and is responsible for reading Docker’s configuration and environment variables if it needs to connect to the daemon.
Why the CLI plugin system replaced engine plugins
The deprecation problem. Docker’s original plugin system, introduced before 20.10, allowed third parties to extend the engine itself—volume drivers, network drivers, authorization plugins, and log drivers were all implemented as plugins that registered with the daemon and communicated over a Unix socket . This system was powerful but fragile: a plugin crash could affect the daemon, the plugin had to be installed on every node, and the registration process was opaque to users. Docker deprecated this system and replaced it with the CLI plugin, which is a separate process that extends the command line without touching the daemon .
The simplicity problem. An engine plugin required a config.json with an interface declaration, a Unix socket listener, and a registration handshake with the daemon. A CLI plugin requires a binary and a filename. The reduction in ceremony is the point: a developer who wants to add a docker logs-summary command can write a script, name it docker-logs-summary, make it executable, and drop it in the plugin directory. The command is available on the next docker invocation .
The language problem. An engine plugin had to be written in Go because it linked against the Docker engine’s plugin libraries. A CLI plugin can be written in any language that produces an executable. The docker-rollout plugin is a bash script . The docker-logql plugin is a Go binary . The docker-run-export plugin is a Go binary that also works as a direct CLI tool . The language is the plugin author’s choice because the interface is the process boundary, not a linked library.
The context problem. Docker’s CLI supports multiple contexts—local, remote, and cloud—and the --context flag switches between them. A CLI plugin does not automatically inherit the context. The Docker CLI sets the DOCKER_CONTEXT environment variable, and the plugin must read it or call docker context show to determine which daemon it should connect to . This is the most common bug in new plugins: the plugin connects to the default socket while the user expects it to connect to the active context.
a. The plugin contract: naming, location, and metadata
The contract has three parts: the filename, the location, and the optional metadata subcommand. The filename is the subcommand. docker-foo is invoked as docker foo. The name must be lowercase alphanumeric with no hyphens after the prefix; docker-my-tool is not a valid plugin name because the hyphen after docker- confuses the parser . The location is one of the plugin directories. Docker searches ~/.docker/cli-plugins/ first, then the system directories, and the first match wins .
The metadata subcommand is optional but recommended. When Docker invokes a plugin with the argument docker-cli-plugin-metadata, the plugin should print a JSON object with the schema version, vendor, version, short description, and URL . Docker uses this metadata to populate the docker --help output and the plugin listing. A plugin that does not implement the metadata subcommand still works, but it appears in the help output without a description.
// The metadata subcommand name that Docker invokes
const metadataSubcommand = "docker-cli-plugin-metadata"
// The metadata struct that the plugin prints as JSON
type Metadata struct {
// SchemaVersion is mandatory and must be "0.1.0"
SchemaVersion string `json:"SchemaVersion"`
// Vendor is the plugin author
Vendor string `json:"Vendor,omitempty"`
// Version is the plugin version
Version string `json:"Version,omitempty"`
// ShortDescription is the one-line help text
ShortDescription string `json:"ShortDescription,omitempty"`
// URL is the plugin homepage
URL string `json:"URL,omitempty"`
}
func main() {
// Check if Docker is asking for metadata
if len(os.Args) > 1 && os.Args[1] == metadataSubcommand {
// Print the metadata as JSON
meta := Metadata{
SchemaVersion: "0.1.0",
Vendor: "example",
Version: "1.0.0",
ShortDescription: "Summarize container logs",
URL: "https://example.com/docker-logs-summary",
}
// Encode and print to stdout
json.NewEncoder(os.Stdout).Encode(meta)
return
}
// The plugin's normal logic
run(os.Args[1:])
}
The metadata is not required for the plugin to function, but it is the difference between a plugin that appears in docker --help with a description and one that appears as a bare name. The SchemaVersion is mandatory and must be "0.1.0"; a different value causes Docker to reject the metadata .
b. Writing the plugin logic: arguments, configuration, and the daemon
The plugin receives the arguments after the subcommand. docker foo bar --baz invokes the plugin with ["bar", "--baz"]. The plugin parses them however it wants. It can use a CLI framework like Cobra, the standard library’s flag package, or manual parsing.
// A plugin that lists containers using the Docker API
// It reads DOCKER_HOST and DOCKER_CONTEXT from the environment
func run(args []string) {
// Parse the plugin's own flags
// The --all flag includes stopped containers
all := false
for _, arg := range args {
if arg == "--all" || arg == "-a" {
all = true
}
}
// Read the Docker host from the environment
// Docker sets DOCKER_HOST for the plugin
host := os.Getenv("DOCKER_HOST")
if host == "" {
// Fall back to the default socket
host = "unix:///var/run/docker.sock"
}
// Create a Docker client with the host
// The client.WithHost option sets the daemon address
cli, err := client.NewClientWithOpts(
client.WithHost(host),
client.WithAPIVersionNegotiation(),
)
if err != nil {
fmt.Fprintln(os.Stderr, "failed to create client:", err)
os.Exit(1)
}
// List containers through the API
containers, err := cli.ContainerList(context.Background(), container.ListOptions{All: all})
if err != nil {
fmt.Fprintln(os.Stderr, "failed to list containers:", err)
os.Exit(1)
}
// Print each container's ID and image
for _, c := range containers {
fmt.Printf("%s %s\n", c.ID[:12], c.Image)
}
}
The DOCKER_HOST environment variable is the primary configuration mechanism. Docker sets it for the plugin process, and the Go SDK’s client.WithHost option uses it. If the variable is not set, the plugin falls back to the default socket. The DOCKER_CONTEXT variable is set separately; a plugin that needs the context name can read it, or it can call docker context show as a subprocess .
The client.WithAPIVersionNegotiation() option is the same as in the Go SDK chapter: it negotiates the API version with the daemon, which prevents the client from using an API version the daemon does not support.
c. Installation, verification, and the docker --help listing
The installation is a file copy. The plugin directory must exist, the binary must be executable, and the name must match the convention.
# Create the plugin directory for the current user
mkdir -p ~/.docker/cli-plugins
# Copy the binary into the directory
# The name must be docker-<subcommand>
cp my-plugin ~/.docker/cli-plugins/docker-logs-summary
# Make it executable
chmod +x ~/.docker/cli-plugins/docker-logs-summary
# Verify that Docker sees the plugin
docker logs-summary --help
# Check the plugin listing in the help output
docker --help | grep -A2 "Plugin commands"
The verification is docker <plugin-name> --help. If the plugin responds, Docker found it. If Docker responds with “docker: ‘logs-summary’ is not a docker command”, the plugin is not in the right directory, is not executable, or the name does not match the convention .
The docker --help output includes a “Plugin commands” section that lists every discovered plugin with its short description. A plugin without the metadata subcommand appears as a bare name. A plugin with the metadata appears with the description .
The docker-cli-plugin tool, if installed, provides a docker-cli-plugin list command that shows the discovered plugins and their paths. It is not part of the standard Docker installation, but it is useful for debugging .
Complete Example Session
The following session builds a Go CLI plugin that summarizes container logs, installs it, and verifies that Docker discovers it.
// ============================================
// PART 1: THE PLUGIN MAIN
// ============================================
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
)
// The metadata subcommand name that Docker invokes
const metadataSubcommand = "docker-cli-plugin-metadata"
// The metadata struct
type Metadata struct {
SchemaVersion string `json:"SchemaVersion"`
Vendor string `json:"Vendor,omitempty"`
Version string `json:"Version,omitempty"`
ShortDescription string `json:"ShortDescription,omitempty"`
URL string `json:"URL,omitempty"`
}
// ============================================
// PART 2: THE METADATA HANDLER
// ============================================
func handleMetadata() {
// Build the metadata
meta := Metadata{
SchemaVersion: "0.1.0",
Vendor: "example",
Version: "1.0.0",
ShortDescription: "Summarize container logs",
URL: "https://example.com/docker-logs-summary",
}
// Print it as JSON
json.NewEncoder(os.Stdout).Encode(meta)
}
// ============================================
// PART 3: THE LOG SUMMARY LOGIC
// ============================================
func summarize(containerID string) {
// Read the Docker host from the environment
host := os.Getenv("DOCKER_HOST")
if host == "" {
host = "unix:///var/run/docker.sock"
}
// Create the client
cli, err := client.NewClientWithOpts(
client.WithHost(host),
client.WithAPIVersionNegotiation(),
)
if err != nil {
fmt.Fprintln(os.Stderr, "client error:", err)
os.Exit(1)
}
// Get the container's logs
logs, err := cli.ContainerLogs(context.Background(), containerID, container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Tail: "100",
})
if err != nil {
fmt.Fprintln(os.Stderr, "logs error:", err)
os.Exit(1)
}
defer logs.Close()
// Read the logs and print a summary
// In a real plugin, this would parse and aggregate
fmt.Printf("Logs for container %s (last 100 lines):\n", containerID[:12])
// Copy the logs to stdout
// The io.Copy call is omitted for brevity
}
// ============================================
// PART 4: THE MAIN ENTRY POINT
// ============================================
func main() {
// Check for the metadata subcommand first
if len(os.Args) > 1 && os.Args[1] == metadataSubcommand {
handleMetadata()
return
}
// Require a container ID
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: docker logs-summary <container>")
os.Exit(1)
}
// Run the summary
summarize(os.Args[1])
}
# ============================================
# PART 5: BUILD AND INSTALL
# ============================================
# Build the plugin binary
go build -o docker-logs-summary .
# Create the plugin directory
mkdir -p ~/.docker/cli-plugins
# Copy the binary into the directory
cp docker-logs-summary ~/.docker/cli-plugins/
# Make it executable
chmod +x ~/.docker/cli-plugins/docker-logs-summary
The five parts cover the plugin main with the metadata check, the metadata handler, the log summary logic with the Docker client, the main entry point, and the build and install commands. What the session demonstrates is that the plugin is a normal Go binary that checks for the metadata subcommand, reads DOCKER_HOST, and uses the Docker SDK to talk to the daemon.
Quick Reference
Plugin Naming and Location
| Item | Value |
|---|---|
| Prefix | docker- |
| Example | docker-logs-summary → docker logs-summary |
| User directory | ~/.docker/cli-plugins/ |
| System directory (Linux) | /usr/local/libexec/docker/cli-plugins/ or /usr/libexec/docker/cli-plugins/ |
| Permissions | Executable (chmod +x) |
Metadata Subcommand
| Field | Required | Value |
|---|---|---|
SchemaVersion | Yes | "0.1.0" |
Vendor | No | Plugin author |
Version | No | Plugin version |
ShortDescription | No | Help text |
URL | No | Homepage |
Environment Variables
| Variable | Purpose |
|---|---|
DOCKER_HOST | Daemon address |
DOCKER_CONTEXT | Active context name |
DOCKER_TLS_VERIFY | TLS verification |
DOCKER_CERT_PATH | TLS certificates |
Installation Commands
| Command | Purpose |
|---|---|
mkdir -p ~/.docker/cli-plugins | Create directory |
cp plugin ~/.docker/cli-plugins/docker-name | Install |
chmod +x ~/.docker/cli-plugins/docker-name | Make executable |
docker name --help | Verify |
Best Practices
✅ Do This:
// Check for the metadata subcommand first
if os.Args[1] == "docker-cli-plugin-metadata" {
json.NewEncoder(os.Stdout).Encode(meta)
return
}
// Read DOCKER_HOST for the daemon address
host := os.Getenv("DOCKER_HOST")
// Use API version negotiation
client.WithAPIVersionNegotiation()
❌ Don’t Do This:
// Ignore DOCKER_HOST
// The plugin connects to the wrong daemon
// Use hyphens in the plugin name
// docker-my-tool is not a valid plugin name
// Forget the executable bit
// Docker cannot run a non-executable file
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Plugin not found | Wrong directory or name | ~/.docker/cli-plugins/docker-name |
| Plugin not executable | Missing chmod +x | chmod +x |
| Wrong daemon | DOCKER_HOST not read | Read the environment variable |
| No help text | Metadata not implemented | Implement the metadata subcommand |
| Context mismatch | DOCKER_CONTEXT ignored | Read the context or use docker context show |
Real-World Examples
1. Plugin naming
# The binary name becomes the subcommand
docker-logs-summary → docker logs-summary
2. Install for current user
mkdir -p ~/.docker/cli-plugins
cp docker-logs-summary ~/.docker/cli-plugins/
chmod +x ~/.docker/cli-plugins/docker-logs-summary
3. Install system-wide
sudo cp docker-logs-summary /usr/local/libexec/docker/cli-plugins/
sudo chmod +x /usr/local/libexec/docker/cli-plugins/docker-logs-summary
4. Metadata JSON
{"SchemaVersion":"0.1.0","Vendor":"example","Version":"1.0.0","ShortDescription":"Summarize logs"}
5. Read DOCKER_HOST
host := os.Getenv("DOCKER_HOST")
6. Create client with host
cli, _ := client.NewClientWithOpts(client.WithHost(host), client.WithAPIVersionNegotiation())
7. Shell plugin
#!/bin/bash
echo "Hello from a shell plugin"
8. Direct invocation
./docker-logs-summary container-id
9. Verify listing
docker --help | grep -A5 "Plugin commands"
10. List plugins tool
docker-cli-plugin list
Visual
Plugin Discovery
┌──────────────────────────────────────────────┐
│ Docker CLI starts │
│ │ │
│ ▼ │
│ Scan ~/.docker/cli-plugins/ │
│ Scan /usr/local/libexec/docker/cli-plugins/ │
│ │ │
│ ▼ │
│ Find executable named docker-* │
│ │ │
│ ▼ │
│ Register as subcommand │
│ │ │
│ ▼ │
│ docker <name> invokes the binary │
└──────────────────────────────────────────────┘
Plugin Lifecycle
┌──────────────────────────────────────────────┐
│ docker logs-summary container-id │
│ │ │
│ ▼ │
│ Docker finds docker-logs-summary │
│ │ │
│ ▼ │
│ Executes binary with ["container-id"] │
│ │ │
│ ▼ │
│ Plugin reads DOCKER_HOST │
│ │ │
│ ▼ │
│ Plugin calls Docker API │
│ │ │
│ ▼ │
│ Plugin prints output │
└──────────────────────────────────────────────┘
Metadata Flow
┌──────────────────────────────────────────────┐
│ docker --help │
│ │ │
│ ▼ │
│ Docker invokes plugin with │
│ "docker-cli-plugin-metadata" │
│ │ │
│ ▼ │
│ Plugin prints JSON metadata │
│ │ │
│ ▼ │
│ Docker includes description in help │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Plugin type | Executable binary |
| Naming | docker-<subcommand> |
| User directory | ~/.docker/cli-plugins/ |
| System directory | /usr/local/libexec/docker/cli-plugins/ |
| Metadata subcommand | docker-cli-plugin-metadata |
| Schema version | "0.1.0" |
| Configuration | DOCKER_HOST, DOCKER_CONTEXT |
| Language | Any |
| Deprecated alternative | Engine plugins (pre-20.10) |
Key takeaways:
- A CLI plugin is an executable named
docker-<name>. Docker discovers it by filename in specific directories and invokes it as a subcommand. No registration, no daemon restart, no manifest . - The metadata subcommand is optional but recommended. When Docker invokes the plugin with
docker-cli-plugin-metadata, the plugin prints a JSON object that provides the description and version for the help output . - The plugin is responsible for reading Docker’s configuration.
DOCKER_HOSTandDOCKER_CONTEXTare environment variables that the plugin must read if it needs to connect to the daemon . - Any language works. The plugin is a process, not a linked library. The
docker-rolloutplugin is a bash script, and thedocker-logqlplugin is a Go binary . - The engine plugin system is deprecated. The CLI plugin system replaced it because it is simpler, safer, and does not require daemon-side registration .
Remember: A Docker CLI plugin is the simplest extension mechanism Docker has ever had. It is a binary with a specific name in a specific directory. The name determines the subcommand, the metadata determines the help text, and the binary does the rest. The plugin is a separate process, so it can be written in any language and can do anything, but it also means the plugin must read Docker’s environment variables if it wants to talk to the right daemon. The two things that go wrong most often are the filename (a hyphen after docker- breaks the convention) and the daemon address (the plugin connects to the default socket while the user is on a different context). Get those two right, and the plugin works.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!