NAME

API::Docker - Perl client for the Docker Engine API

VERSION

version 0.004

SYNOPSIS

use API::Docker;

# Connect to local Docker daemon via Unix socket
my $docker = API::Docker->new;

# Or connect to remote Docker daemon
my $docker = API::Docker->new(
    host => 'tcp://192.168.1.100:2375',
);

# System information
my $info = $docker->system->info;
my $version = $docker->system->version;

# Container management -- list/inspect return generated
# API::Docker::Type::* objects with snake_case accessors, not hashrefs
my $containers = $docker->containers->list(all => 1);
for my $container (@$containers) {
    say $container->id;
    say $container->status;
}

my $result = $docker->containers->create(
    Image => 'nginx:latest',
    name  => 'my-nginx',
);
$docker->containers->start($result->{Id});

my $inspected = $docker->containers->inspect($result->{Id});
say $inspected->state->running ? 'running' : 'not running';

# Image operations
$docker->images->pull(fromImage => 'nginx', tag => 'latest');
my $images = $docker->images->list;

# Network and volume management
my $networks = $docker->networks->list;
my $volumes = $docker->volumes->list;

DESCRIPTION

API::Docker is a Perl client for the Docker Engine API. It provides a clean object-oriented interface to manage Docker containers, images, networks, and volumes.

Key features:

  • Pure Perl implementation with minimal dependencies

  • Unix socket and TCP transport, the latter in the clear or over TLS with client certificates ("tls", "cert_path")

  • Automatic API version negotiation

  • A typed object model generated from Docker's own swagger (API::Docker::Type) -- complete across all seven resources: list and inspect return these generated classes, not hashrefs; see "Architecture" below

  • Comprehensive logging via Log::Any

Architecture

The distribution is organized into several layers:

Swarm orchestration is out of scope

/swarm, /nodes, /services and /tasks are deliberately absent, and staying absent is the plan rather than a gap waiting to be closed. That is a scope decision: Swarm sees too little practical use to be worth the surface, Podman -- the engine this distribution is actually tested against -- implements none of the Swarm family at all, and no consumer of this distribution has asked for it. Docker has not withdrawn Swarm, and nothing here claims it has; this distribution simply chooses not to follow it.

What already works without Swarm keeps working. API::Docker::API::Secrets and API::Docker::API::Configs are covered despite belonging to that same Engine API family, because both stand on their own rather than on an orchestrator -- see "Swarm, and what Podman serves instead" in API::Docker::API::Secrets for what that looks like against each engine, Docker's single-node 503 included. Anyone who actually needs Swarm orchestration should reach for a client built around it; extending this one to cover it is not on the roadmap.

host

Docker daemon connection URL. Defaults to $ENV{DOCKER_HOST} or unix:///var/run/docker.sock.

No other source is consulted; see "Socket discovery".

Supported formats:

  • unix:///path/to/socket - Unix socket (default)

  • tcp://host:port - TCP connection

api_version

Docker API version to use (e.g., 1.41). If not set, the client will automatically negotiate the highest API version supported by the daemon.

This attribute is set automatically by "negotiate_version".

tls

Speak TLS on a tcp:// connection. Defaults to 1 when $ENV{DOCKER_TLS_VERIFY} holds any non-empty value and "host" is a tcp:// one, and to 0 -- plaintext -- otherwise.

my $docker = API::Docker->new(
  host      => 'tcp://dockerhost:2376',
  tls       => 1,
  cert_path => '/home/me/.docker',
);

The default follows the docker CLI rather than the Go SDK's FromEnv: the CLI reads the variable as != "", so every non-empty value turns TLS on -- DOCKER_TLS_VERIFY=0 included, and so are false, no and off. Only unset, or the empty string, is off. That is deliberately not Perl truthiness: '0' is the value most likely to be typed for "off" and is precisely where the two rules would part company. An explicit tls => ... passed to the constructor outranks the variable in both directions.

The variable is ignored on a socket host, as the CLI ignores it -- a unix://, npipe:// or fd:// connection carries nothing to encrypt. Without that exception a shell exporting DOCKER_TLS_VERIFY would make a bare API::Docker->new croak on every machine talking to a local socket, since tls => 1 on a non-tcp:// host is a construction error (below).

DOCKER_TLS_VERIFY with no "cert_path" and no DOCKER_CERT_PATH beside it is TLS against the system trust store, not an error; the CLI asks for no certificates either, and non-empty there means encrypt and verify.

With tls => 1 the transport opens an IO::Socket::SSL connection instead of an IO::Socket::INET one and nothing above the socket changes. The daemon's certificate is verified, and so is its hostname; "cert_path" supplies the trust anchor and this client's own certificate.

With no certificates at all it still means encrypt and verify, against the system trust store -- see "TLS with no certificates at all" in API::Docker::Role::HTTP for why that rather than an error. To switch verification off, and to read what that gives away, see "tls_insecure".

tls => 1 on a unix:// host croaks at construction. A Unix socket is a file, not a wire; there is nothing on it to encrypt, and accepting the option would mean answering a request for an encrypted transport with an unencrypted one -- which is the failure this attribute previously had.

IO::Socket::SSL is a recommended rather than a required dependency, loaded when the first TLS connection is opened; tls => 1 without it installed croaks naming it. See "TLS on a tcp:// connection" in API::Docker::Role::HTTP for the whole of the policy.

cert_path

Directory holding the TLS certificates, in the layout the docker CLI writes: ca.pem as the trust anchor, cert.pem and key.pem as this client's certificate and key. Defaults to $ENV{DOCKER_CERT_PATH}.

Each file is used if it is there. ca.pem alone is a daemon this client verifies but does not authenticate to; cert.pem without key.pem or the reverse is a croak, since half a client certificate is an accident rather than a mode. A cert_path naming something that is not a directory croaks too.

Read only when "tls" is set. The default comes from the environment, and DOCKER_CERT_PATH is exported on plenty of machines that run the docker CLI, so a client that never asked for TLS is unaffected by having it set. A TLS client that wants the system trust store rather than the CLI's private one on such a machine passes cert_path => undef explicitly.

tls_insecure

Turn certificate verification off. Default 0. Only read when "tls" is set, and named for what it does.

tls_insecure => 1 sets SSL_VERIFY_NONE and drops the hostname check, which leaves a connection encrypted against a passive listener and against nothing else: whoever answers it chooses the certificate, so anyone able to redirect the connection reads and rewrites everything on it -- registry credentials, image contents, the commands containers are started with.

It exists for a self-signed daemon certificate whose CA is not to hand. The better answer is nearly always "cert_path": a self-signed certificate is its own CA and works as ca.pem directly.

Setting it without "tls" croaks, rather than being accepted and doing nothing.

system

Returns API::Docker::API::System instance for system operations like info, version, ping, and events.

containers

Returns API::Docker::API::Containers instance for container operations like list, create, start, stop, and remove.

images

Returns API::Docker::API::Images instance for image operations like list, pull, push, and remove.

networks

Returns API::Docker::API::Networks instance for network operations like list, create, connect, and disconnect.

volumes

Returns API::Docker::API::Volumes instance for volume operations like list, create, and remove.

exec

Returns API::Docker::API::Exec instance for executing commands in containers.

distribution

Returns API::Docker::API::Distribution instance for registry manifest lookups: inspect and exists.

secrets

Returns API::Docker::API::Secrets instance for secret operations: list, create, inspect, update and remove.

configs

Returns API::Docker::API::Configs instance for config operations: list, create, inspect, update and remove.

plugins

Returns API::Docker::API::Plugins instance for managed-plugin operations: list, privileges, install, inspect, remove, enable, disable, upgrade, push and configure.

negotiate_version

$docker->negotiate_version;
$docker->negotiate_version(read_timeout => 5, connect_timeout => 2);

Automatically negotiate the highest API version supported by the Docker daemon. This is called automatically before the first API request if "api_version" is not set.

After negotiation, "api_version" will contain the negotiated version (e.g., 1.41).

GET /version must answer with a JSON object carrying an ApiVersion of the form N.N -- the value is placed directly into the path of every later request (/v1.44/...). A body that is not such an object croaks, naming the endpoint and the shape expected: a non-object body, an object with no ApiVersion, or an ApiVersion that is not two dot-separated numbers. This replaces three earlier failures on the same path -- a non-object body dying in strict refs, an object with no ApiVersion silently leaving the client sending every request unversioned, and a malformed ApiVersion being copied verbatim into the request path.

Options:

Called on its own, with no options, the negotiation is bounded by the "read_timeout" in API::Docker::Role::HTTP and "connect_timeout" in API::Docker::Role::HTTP attributes of the client, like any other request. Reached the way it normally is -- automatically, from the first request -- it inherits that request's own bounds instead; see "What a timeout covers".

TIMEOUTS

What a timeout covers

Two bounds, covering different halves of a request. "connect_timeout" in API::Docker::Role::HTTP bounds opening the connection; "read_timeout" in API::Docker::Role::HTTP bounds reading the answer. Both are attributes of the client, and they are set in two places -- which are two levels, not two spellings of one thing:

# the rule, for this client
my $docker = API::Docker->new(connect_timeout => 2, read_timeout => 30);

# the exception, for this run of calls
$docker->containers->using(read_timeout => 5)->list;
$docker->system->using(read_timeout => 0)->events;

"using" in API::Docker::Role::Using returns a clone of the resource class carrying the bounds, and every request made through that clone is given them. There is deliberately no third way: the individual methods take no timeout options, so their arguments are the request and nothing else.

Both are off by default, which is the behaviour this distribution has always had. 0 means the same as unset -- no bound -- and is how a client-wide default is turned off for a run of calls: what using carries is read with exists rather than for truth, so a 0 reaches the transport instead of vanishing into "no opinion".

Three things they do not do:

  • read_timeout is an idle timeout, not a deadline. The clock measures the time since the last byte arrived, not the time since the request started. A stream that keeps producing runs as long as it likes; one that stops producing is cut off. So it bounds a daemon that goes quiet -- it does not bound a long transfer, and it does not bound a stream that keeps sending without saying anything, which is what containers->stats degrades into on Docker after the container exits.

  • Neither of them bounds writing the request. Sending the bytes out is unbounded on every transport. In practice that matters for one thing: a large /build context or images->load archive being written to a daemon that has stopped reading.

  • Under TLS, read_timeout is not quite an idle timer on the plaintext. It is SO_RCVTIMEO on the socket, which bounds each blocking receive on the underlying connection, and one plaintext read can consume several of those while a TLS record arrives in pieces -- so a record dribbling in slowly enough resets the clock without a byte reaching the caller. It still bounds the hang, which is what it is for. connect_timeout over TLS bounds the TCP connect and not the handshake that follows it.

An expiry croaks with an API::Docker::Error::Timeout carrying what did arrive; it never returns a truncated response. "Bounding a request that never ends" in API::Docker::Role::HTTP and "Bounding the connection itself" in API::Docker::Role::HTTP have the per-transport measurements behind all of this.

Where a bound applies

Every public method of every resource class that reaches the daemon -- all of them, with no exception for the ones whose arguments are the request body -- makes its request with the bounds in force. That is what the clone buys: the method builds the request and the resource class it was called on says how long to wait for it, so there is no list of methods that forward a bound and no list of methods that cannot.

The requests a method makes on the caller's behalf without being asked are bounded too, and for the same reason -- they run on the same resource class:

  • "attach" in API::Docker::API::Containers asks whether the container is running before attaching. That check carries the bounds the attach carries.

  • "install" in API::Docker::API::Plugins and "upgrade" in API::Docker::API::Plugins with accept_privileges => 1 fetch the plugin's privileges first. That fetch carries them too.

  • "negotiate_version" runs before the first request of a client with no "api_version", and inherits the bounds of the request that triggered it: $docker->containers->using(read_timeout => 5)->list on a fresh client bounds the GET /version as well as the list. It is the one place the two options are still written out per call, because it can be called directly and is not reached through a resource class:

    $docker->negotiate_version(read_timeout => 5);

The entity classes have no using of their own; a bound for $container->logs goes on the resource class instead, see "What has no clone of its own" in API::Docker::Role::Using.

CONTAINER ENGINES

This client speaks the Docker Engine HTTP API over a socket. It never shells out to the docker binary, so any engine serving that API works, whether or not Docker itself is installed.

Installing Docker

Where the engine is Docker itself, prefer the official packages from https://docs.docker.com/engine/install/ over a distribution package such as Debian/Ubuntu's docker.io, which is typically a good deal older. The reason that matters here: "negotiate_version" only negotiates within whatever API version the daemon itself reports, so an older daemon still works, but endpoints and query parameters that need a newer API version are then simply not there. This is a recommendation about which Docker package to install, not Docker instead of Podman -- Podman remains fully supported, see "Podman" below.

Podman

Podman ships a Docker-compatible API service. Enable its rootless socket and point "host" at it:

systemctl --user enable --now podman.socket
export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/podman/podman.sock"

The socket announces API version 1.44, which "negotiate_version" picks up like any other daemon. Multi-stage builds are passed through unchanged, target included, down to skipping the stages the target does not depend on.

Engines and versions behind the measurements in this POD

Where this distribution's documentation says what an engine does rather than what the Engine API reference says it should do, that statement was measured against a real socket, not assumed. Three engines stand behind the measurements found throughout this POD:

  • Podman 5.4.2, API 1.41

  • Podman 5.8.4, API 1.44

  • Docker 29.7.2, API 1.55

Podman statements have been checked against both 5.4.2 and 5.8.4. Where an individual statement names no version, it holds for both. A version named at one particular measurement -- "Measured against Podman 5.4.2 (API 1.41): ...", for instance -- names the engine that measurement was taken on, not the only engine it is claimed to hold for; read it as provenance, not as a scope limit. Where a measurement genuinely is version-specific -- superseded by a later one, or not re-checked on the other version -- the text says so.

Socket discovery

"host" resolves in two steps and no more: $ENV{DOCKER_HOST}, then unix:///var/run/docker.sock. It deliberately does not read Docker contexts. currentContext in ~/.docker/config.json and the matching ~/.docker/contexts/meta/*/meta.json are ignored, so if you switch daemons with docker context use, that choice is not picked up here. Set DOCKER_HOST explicitly instead.

Other clients sit at different points on that scale. The docker CLI and docker-java resolve contexts, with DOCKER_HOST outranking them when set. docker-py's from_env() reads DOCKER_HOST and otherwise falls back to the default socket, leaving contexts to a separate API. Testcontainers layers its own ~/.testcontainers.properties and a rootless probe list ($XDG_RUNTIME_DIR/docker.sock, ~/.docker/run/docker.sock, ~/.docker/desktop/docker.sock, /run/user/$UID/docker.sock) on top.

What none of them do is guess Podman's socket path: that probe list is for rootless Docker, not for Podman. Every one of those projects documents DOCKER_HOST as the way to reach Podman, which is the same answer given above.

ENVIRONMENT VARIABLES

DOCKER_HOST

Docker daemon connection URL. Used as default for "host" if not explicitly set.

Examples: unix:///var/run/docker.sock, tcp://localhost:2375

Also the supported way to reach a non-Docker engine such as Podman: unix://$XDG_RUNTIME_DIR/podman/podman.sock. See "CONTAINER ENGINES".

DOCKER_CERT_PATH

Path to the TLS certificate directory (ca.pem, cert.pem, key.pem). Used as the default for "cert_path", which is read only when "tls" is set -- so having it exported, as machines running the docker CLI usually do, changes nothing for a client that speaks plaintext or over a Unix socket.

SEE ALSO

SUPPORT

Issues

Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-api-docker/issues.

CONTRIBUTING

Contributions are welcome! Please fork the repository and submit a pull request.

AUTHOR

Torsten Raudssus <getty@cpan.org>

COPYRIGHT AND LICENSE

This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.