docker-api-mx
v0.2.2
Published
A typed, class-based Docker Engine API client for Node.js.
Maintainers
Readme
docker-api-mx
A typed, class-based client for the Docker Engine API, written in TypeScript for Node.js.
- Promises and async iterators only. No callbacks. Every call accepts an
AbortSignal. - Small, predictable classes.
Docker,Container,Image,Network,Volume,Exec,Service,Node,Task,Secret,ConfigandPlugin. - Streams done right. Pull, push and build progress are async iterables, logs and attach streams can be demultiplexed, events and stats are async generators.
- Full coverage. Containers, images, networks, volumes, exec, archives, events, swarm (services, nodes, tasks, secrets, configs), plugins, checkpoints and BuildKit builds with registry credentials.
- Own HTTP transport. Unix sockets, Windows named pipes, TCP, TLS and SSH, with
DOCKER_HOSTsupport. No runtime dependency on another Docker client. - ESM and CommonJS, with type declarations for both.
Contents
- Installation
- Quick start
- Connecting to a daemon
- Conventions
- Containers
- Images
- Networks
- Volumes
- Disk usage
- Events
- Swarm
- Plugins
- Checkpoints
- Streams and helpers
- Error handling
- Cancellation and timeouts
- TypeScript
- Development and testing
- License
Installation
npm install docker-api-mxRequires Node.js 20 or later. The package ships both ESM and CommonJS builds:
import { Docker } from 'docker-api-mx'; // ESM / TypeScriptconst { Docker } = require('docker-api-mx'); // CommonJSQuick start
import { Docker } from 'docker-api-mx';
const docker = new Docker();
// Check that the daemon is reachable.
console.log(await docker.ping(), (await docker.version()).Version);
// Pull an image and print the progress.
for await (const line of docker.pull('alpine:3').lines()) {
console.log(line);
}
// Run a one-off container and stream its output to your terminal.
const { statusCode } = await docker.run({
Image: 'alpine:3',
Cmd: ['echo', 'hello from a container'],
stdout: process.stdout,
remove: true,
});
console.log('exit code:', statusCode);Create a long-running container and manage it:
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const container = await docker.createContainer({
Image: 'nginx:alpine',
name: 'web',
ExposedPorts: { '80/tcp': {} },
HostConfig: { PortBindings: { '80/tcp': [{ HostPort: '8080' }] } },
});
await container.start();
console.log((await container.inspect()).State.Status); // "running"
await container.stop();
await container.remove();Connecting to a daemon
new Docker() with no options connects to the local daemon:
- If
DOCKER_HOSTis set, it is used (see the table below). - Otherwise on Windows the named pipe
//./pipe/docker_engine. - Otherwise on Linux and macOS
~/.docker/run/docker.sockwhen it exists (Docker Desktop), else/var/run/docker.sock.
| DOCKER_HOST | Connects to |
| -------------------------------- | --------------------------------------- |
| unix:///var/run/docker.sock | A Unix socket |
| npipe:////./pipe/docker_engine | A Windows named pipe |
| tcp://host:2375 | Plain HTTP |
| tcp://host:2376 | HTTPS (also when DOCKER_TLS_VERIFY=1) |
| host:2375 | Same as tcp://host:2375 |
| ssh://user@host[:port] | SSH, see Connecting over SSH |
Other environment variables that are read: DOCKER_TLS_VERIFY, DOCKER_CERT_PATH (a directory with ca.pem, cert.pem and key.pem), DOCKER_PATH_PREFIX and DOCKER_CLIENT_TIMEOUT.
Explicit options
Passing socketPath or host replaces the environment completely:
import { Docker } from 'docker-api-mx';
// Unix socket
const local = new Docker({ socketPath: '/var/run/docker.sock' });
// Windows named pipe
const pipe = new Docker({ socketPath: '//./pipe/docker_engine' });
// TCP without TLS
const remote = new Docker({ host: '192.168.1.20', port: 2375 });
// A daemon in WSL from Windows
const wsl = new Docker({ host: '127.0.0.1', port: 2375 });TLS
import { readFileSync } from 'node:fs';
import { Docker } from 'docker-api-mx';
// Certificates from a directory (ca.pem, cert.pem, key.pem)
const fromDir = new Docker({ host: 'docker.example.com', port: 2376, protocol: 'https', certPath: '/home/me/.docker/machine/certs' });
// Or pass the material yourself
const explicit = new Docker({
host: 'docker.example.com',
port: 2376,
protocol: 'https',
ca: readFileSync('ca.pem'),
cert: readFileSync('cert.pem'),
key: readFileSync('key.pem'),
});More options
import { Docker } from 'docker-api-mx';
const docker = new Docker({
host: 'proxy.example.com',
port: 443,
protocol: 'https',
pathPrefix: '/docker', // a reverse proxy that serves the API under /docker
version: '1.47', // pin the API version (default: the daemon decides)
headers: { 'X-Api-Key': 'secret' }, // extra headers on every request
connectionTimeout: 5_000, // ms until the daemon must start responding
timeout: 60_000, // ms of socket inactivity before the request is destroyed
});Use Docker.fromEnvironment(env) to read the connection from an environment object other than process.env:
import { Docker } from 'docker-api-mx';
const docker = Docker.fromEnvironment({ DOCKER_HOST: 'tcp://10.0.0.5:2375' });Connecting over SSH
Reach a daemon on another machine without exposing its API: the library logs in over SSH and runs docker system dial-stdio on that machine, which connects to its Docker socket. The remote machine needs the Docker CLI (18.09 or later) and a user that may use the Docker socket. Nothing else has to be opened or configured.
import { Docker } from 'docker-api-mx';
// From a DOCKER_HOST-style address...
const docker = Docker.fromEnvironment({ DOCKER_HOST: 'ssh://[email protected]' });
// ...or with options.
const explicit = new Docker({ protocol: 'ssh', host: 'server.example.com', port: 22, username: 'deploy' });
console.log(await docker.ping(), (await docker.version()).Version);
await docker.close(); // optional, see "Connection lifetime" belowEverything works the same as on a local daemon: streams, logs, exec, attach, builds and BuildKit sessions.
import { Docker, MultiplexedStream } from 'docker-api-mx';
const docker = Docker.fromEnvironment({ DOCKER_HOST: 'ssh://[email protected]' });
for await (const line of docker.pull('alpine:3').lines()) {
console.log(line);
}
await docker.run({ Image: 'alpine:3', Cmd: ['uname', '-a'], stdout: process.stdout, remove: true });
const exec = await docker.getContainer('web').exec({ Cmd: ['ls', '/'], AttachStdout: true });
await MultiplexedStream.demux(await exec.start({ Tty: false }), process.stdout, process.stderr);Authentication. These are tried in this order:
ssh.privateKey(withssh.passphraseif the key has one).- Otherwise the first usable default key:
~/.ssh/id_ed25519,id_ecdsaorid_rsa. A key with a passphrase is skipped unless you passssh.passphrase. - An ssh-agent:
ssh.agent, orSSH_AUTH_SOCK, or on Windows the OpenSSH agent. ssh.password.
import { readFileSync } from 'node:fs';
import { Docker } from 'docker-api-mx';
const docker = new Docker({
protocol: 'ssh',
host: 'server.example.com',
username: 'deploy',
ssh: {
privateKey: readFileSync('/keys/deploy_ed25519'),
passphrase: process.env.DEPLOY_KEY_PASSPHRASE,
readyTimeout: 10_000, // ms to set up the SSH connection
idleTimeout: 10_000, // ms to keep the connection after the last request (default 2000)
maxChannels: 8, // requests at once per SSH connection (default 8); see below
command: 'docker system dial-stdio', // what runs on the server; see below
},
});Host keys are verified. The host key of the server must be in your ~/.ssh/known_hosts (plain or hashed entries, [host]:port, wildcards and @revoked are understood). A host that is not there is rejected, and so is a host whose key changed, with a message that includes the fingerprint. To trust a new host, connect to it once with ssh user@host. Use another file with ssh.knownHostsPath, or take over the decision with ssh.hostVerifier, for example to pin a fingerprint:
import { Docker, KnownHosts } from 'docker-api-mx';
const pinned = 'SHA256:...'; // the fingerprint from `ssh-keygen -lf` or `ssh-keyscan` output
const docker = new Docker({
protocol: 'ssh',
host: 'server.example.com',
username: 'deploy',
ssh: { hostVerifier: (key) => KnownHosts.fingerprint(key) === pinned },
});Connection lifetime. Requests share an SSH connection and each request gets its own channel on it, so parallel requests are cheap. sshd allows 10 channels per connection by default (MaxSessions) and refuses the rest, so from maxChannels requests at once (default 8) another connection is opened; long-lived streams such as events, stats and followed logs each hold a channel. A server that allows fewer is noticed on its first refusal, and the request moves to another connection. Each connection is closed idleTimeout milliseconds after its last request has finished, so a script ends by itself. Call docker.close() to close them right away. A request after that simply reconnects.
Servers without the Docker CLI on the path. command is what runs on the server to reach the socket. On a host that has a Docker socket but no docker command, connect to the socket directly:
import { Docker } from 'docker-api-mx';
const docker = new Docker({
protocol: 'ssh',
host: 'server.example.com',
username: 'deploy',
ssh: { command: 'nc -U /var/run/docker.sock' },
});Problems come back as ordinary Errors with the cause in the message:
import { Docker } from 'docker-api-mx';
const docker = Docker.fromEnvironment({ DOCKER_HOST: 'ssh://[email protected]' });
try {
await docker.ping();
} catch (error) {
console.error((error as Error).message);
// "SSH host key of server.example.com:22 (SHA256:...) is not in ~/.ssh/known_hosts. Connect once with ssh to trust it, ..."
// "SSH connection to [email protected]:22 failed: All configured authentication methods failed"
// "Remote command "docker system dial-stdio" failed with exit code 127: docker: command not found"
}Conventions
- Handles are cheap.
docker.getContainer(id),getImage,getNetwork,getVolume,getService,getNode,getTask,getSecret,getConfig,getExecandgetPlugindo not make a request. They return an object you call methods on. create*methods return handles too.docker.createContainer()returns aContainer,docker.createNetwork()aNetwork, and so on.- Option names follow the Docker Engine API. Request bodies use the API's
PascalCase(Image,Cmd,HostConfig), query parameters use itslowercasenames (all,filters,tail). The library adds no aliases. abortSignalon every call. It is removed from the options before the rest goes into the query or body. See Cancellation and timeouts.- Filters are objects.
filters: { status: ['running'], label: ['env=prod'] }. - Streams are Node streams or async iterables. Progress and event streams are async iterables. Logs, exports and archives are
Readablestreams.
import { Docker } from 'docker-api-mx';
const docker = new Docker();
// A handle for a container that already exists: no request yet.
const container = docker.getContainer('web');
// The request happens here.
const info = await container.inspect();
console.log(info.Name, info.State.Status);Containers
Create and start
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const container = await docker.createContainer({
Image: 'postgres:16',
name: 'db', // query parameter, not part of the body
platform: 'linux/amd64', // query parameter as well
Env: ['POSTGRES_PASSWORD=secret', 'POSTGRES_DB=app'],
Labels: { project: 'demo' },
ExposedPorts: { '5432/tcp': {} },
HostConfig: {
PortBindings: { '5432/tcp': [{ HostIp: '127.0.0.1', HostPort: '5432' }] },
Binds: ['pgdata:/var/lib/postgresql/data'], // named volume
RestartPolicy: { Name: 'unless-stopped' },
Memory: 512 * 1024 * 1024,
},
});
await container.start();start() and stop() are idempotent: starting a running container or stopping a stopped one is not an error (the daemon answers 304).
Run a one-off command
docker.run() creates a container, attaches to it, starts it, waits until it exits and returns the exit code. With remove: true the container is deleted afterwards.
import { PassThrough } from 'node:stream';
import { Docker } from 'docker-api-mx';
const docker = new Docker();
// Print straight to your terminal.
await docker.run({ Image: 'alpine:3', Cmd: ['ls', '-la', '/'], stdout: process.stdout, remove: true });
// Separate stdout and stderr, and capture them.
const stdout = new PassThrough();
const stderr = new PassThrough();
const output: string[] = [];
stdout.on('data', (chunk: Buffer) => output.push(chunk.toString()));
const { statusCode } = await docker.run({
Image: 'alpine:3',
Cmd: ['sh', '-c', 'echo out; echo err 1>&2; exit 3'],
stdout,
stderr,
remove: true,
});
console.log(statusCode, output.join('')); // 3 "out\n"Pipe data into the container with stdin, or use a TTY:
import { Readable } from 'node:stream';
import { Docker } from 'docker-api-mx';
const docker = new Docker();
// stdin
await docker.run({
Image: 'alpine:3',
Cmd: ['cat'],
stdin: Readable.from(['piped into the container\n']),
stdout: process.stdout,
remove: true,
});
// A TTY has a single combined stream, so only `stdout` is used.
await docker.run({ Image: 'alpine:3', Cmd: ['ls', '--color=always'], Tty: true, stdout: process.stdout, remove: true });List and inspect
import { Docker } from 'docker-api-mx';
const docker = new Docker();
// Running containers only.
const running = await docker.listContainers();
console.log(running.length);
// All containers, filtered.
const filtered = await docker.listContainers({
all: true,
filters: { status: ['exited'], label: ['project=demo'] },
});
for (const summary of filtered) {
console.log(summary.Id.slice(0, 12), summary.Names[0], summary.State, summary.Status);
}
const info = await docker.getContainer('db').inspect();
console.log(info.State.Running, info.NetworkSettings.Networks, info.Mounts);Logs
logs() returns the raw stream. Without a TTY the daemon multiplexes stdout and stderr into one stream, and MultiplexedStream splits them again.
import { Docker, MultiplexedStream } from 'docker-api-mx';
const docker = new Docker();
const container = docker.getContainer('web');
// Send stdout and stderr to your own stdout and stderr.
const logs = await container.logs({ tail: 100, timestamps: true });
await MultiplexedStream.demux(logs, process.stdout, process.stderr);Or read the frames yourself:
import { Docker, MultiplexedStream } from 'docker-api-mx';
const docker = new Docker();
const logs = await docker.getContainer('web').logs({ tail: 20 });
for await (const frame of MultiplexedStream.frames(logs)) {
console.log(`[${frame.stream}]`, frame.data.toString().trimEnd());
}Follow the logs until you stop. Cancel with an AbortSignal:
import { Docker, MultiplexedStream } from 'docker-api-mx';
const docker = new Docker();
const controller = new AbortController();
setTimeout(() => controller.abort(), 10_000); // stop after 10 seconds
const logs = await docker.getContainer('web').logs({ follow: true, since: Math.floor(Date.now() / 1000) - 60, abortSignal: controller.signal });
try {
await MultiplexedStream.demux(logs, process.stdout, process.stderr);
} catch (error) {
if ((error as Error).name !== 'AbortError') {
throw error;
}
}For a container created with Tty: true there is no multiplexing. Use the stream directly:
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const logs = await docker.getContainer('tty-container').logs({ tail: 50 });
logs.pipe(process.stdout);Exec
import { Docker, MultiplexedStream } from 'docker-api-mx';
const docker = new Docker();
const container = docker.getContainer('web');
const exec = await container.exec({
Cmd: ['sh', '-c', 'echo listing; ls /etc/nginx; exit 2'],
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({ Tty: false });
await MultiplexedStream.demux(stream, process.stdout, process.stderr);
const { ExitCode } = await exec.inspect();
console.log('exit code:', ExitCode); // 2Write to the command's stdin:
import { Docker, MultiplexedStream } from 'docker-api-mx';
const docker = new Docker();
const container = docker.getContainer('web');
const exec = await container.exec({ Cmd: ['cat'], AttachStdin: true, AttachStdout: true, AttachStderr: true });
const socket = await exec.start({ Tty: false });
socket.write('hello via stdin\n');
socket.end();
await MultiplexedStream.demux(socket, process.stdout, process.stderr);Start a command without waiting for it:
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const exec = await docker.getContainer('web').exec({ Cmd: ['touch', '/tmp/ready'] });
await exec.start({ Detach: true });With a TTY you can set the size of the console, as [height, width]:
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const exec = await docker.getContainer('web').exec({ Cmd: ['stty', 'size'], Tty: true, AttachStdout: true });
const socket = await exec.start({ Tty: true, ConsoleSize: [24, 80] });
socket.pipe(process.stdout); // 24 80Attach interactively
Create the container with Tty and OpenStdin, then connect your terminal to it. attach() returns a duplex socket.
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const container = await docker.createContainer({ Image: 'alpine:3', Cmd: ['sh'], Tty: true, OpenStdin: true });
const socket = await container.attach({ stream: true, stdin: true, stdout: true, stderr: true });
await container.start();
await container.resize({ h: process.stdout.rows, w: process.stdout.columns });
process.stdin.setRawMode(true);
process.stdin.pipe(socket);
socket.pipe(process.stdout);
await container.wait();
process.stdin.setRawMode(false);
process.stdin.unpipe(socket);
await container.remove();Copy files in and out
Archives are tar streams. This example uses tar-fs, but any tar producer works.
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import tar from 'tar-fs';
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const container = docker.getContainer('web');
// Upload: extracts the tar into /tmp in the container.
await container.putArchive(tar.pack('./local-dir', { entries: ['index.html'] }), { path: '/tmp' });
// Metadata of a path in the container.
const stat = await container.infoArchive({ path: '/tmp/index.html' });
console.log(stat.name, stat.size, stat.mode);
// Download a path as a tar stream.
await pipeline(await container.getArchive({ path: '/etc/nginx' }), createWriteStream('nginx-config.tar'));Stats
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const container = docker.getContainer('web');
// One measurement.
const stats = await container.stats();
console.log(stats.memory_stats);
// A continuous stream until you break out of the loop or abort.
const controller = new AbortController();
let samples = 0;
for await (const sample of container.watchStats({ abortSignal: controller.signal })) {
console.log(sample.read, sample.cpu_stats);
samples += 1;
if (samples === 5) {
controller.abort();
break;
}
}Lifecycle and maintenance
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const container = docker.getContainer('web');
await container.pause();
await container.unpause();
await container.restart({ t: 5 }); // wait up to 5 seconds before killing
await container.stop({ t: 10, signal: 'SIGINT' });
await container.kill({ signal: 'SIGKILL' });
await container.rename('web-old');
await container.update({ Memory: 256 * 1024 * 1024, MemorySwap: 512 * 1024 * 1024 });
const { StatusCode } = await container.wait(); // blocks until the container exits
console.log('exited with', StatusCode);
console.log(await container.top()); // processes
console.log(await container.changes()); // filesystem changes
await container.remove({ force: true, v: true }); // also delete anonymous volumesCommit and export
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const container = docker.getContainer('web');
// Turn the container into an image.
const { Id } = await container.commit({ repo: 'myorg/web-snapshot', tag: 'v1', comment: 'before upgrade' });
console.log('new image:', Id);
// Export the container's filesystem as a tar.
await pipeline(await container.export(), createWriteStream('web-rootfs.tar'));Prune
import { Docker } from 'docker-api-mx';
const docker = new Docker();
// Remove stopped containers older than a day. Always scope prunes with a filter you control.
const result = await docker.pruneContainers({ filters: { until: ['24h'], label: ['project=demo'] } });
console.log(result.ContainersDeleted, result.SpaceReclaimed);Images
Pull
pull() returns a ProgressStream. Consume it in whichever way suits you:
import { Docker } from 'docker-api-mx';
const docker = new Docker();
// 1. Human-readable lines.
for await (const line of docker.pull('alpine:3').lines()) {
console.log(line);
}
// 2. Raw progress events (id, status, progressDetail, ...).
for await (const event of docker.pull('alpine:3')) {
if (event.progressDetail?.total) {
console.log(event.id, `${event.progressDetail.current}/${event.progressDetail.total}`);
}
}
// 3. Wait until it is done, optionally with a callback.
const events = await docker.pull('alpine:3').wait(() => process.stdout.write('.'));
console.log(`\n${events.length} events`);repo:tag, repo@sha256:... and registry hosts with ports (localhost:5000/team/app:1.2) are parsed for you.
import { Docker } from 'docker-api-mx';
const docker = new Docker();
// A specific platform.
await docker.pull('alpine:3', { platform: 'linux/arm64' }).wait();
// A private registry.
await docker.pull('registry.example.com/team/app:1.2', {
auth: { username: 'ci-bot', password: process.env.REGISTRY_PASSWORD ?? '', serveraddress: 'registry.example.com' },
}).wait();
// An identity token instead of a password.
await docker.pull('registry.example.com/team/app:1.2', { auth: { identitytoken: 'token' } }).wait();A repository that does not exist, or that needs credentials you did not provide, fails right away with a DockerError (status 404 or 401). An error that happens after the transfer has started is reported by the daemon inside the already open 200 response; the library turns it into a DockerStreamError while you iterate or wait(). Handle both:
import { Docker, DockerError, DockerStreamError } from 'docker-api-mx';
const docker = new Docker();
try {
await docker.pull('private/does-not-exist').wait();
} catch (error) {
if (error instanceof DockerError) {
console.error(`the registry refused the pull (${error.statusCode}):`, error.message);
} else if (error instanceof DockerStreamError) {
console.error('the pull failed halfway:', error.message);
} else {
throw error;
}
}List, inspect, tag, remove
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const images = await docker.listImages({ filters: { dangling: ['false'] } });
for (const image of images) {
console.log(image.Id.slice(7, 19), image.RepoTags, image.Size);
}
const image = docker.getImage('alpine:3');
const info = await image.inspect();
console.log(info.Os, info.Architecture, info.Config);
console.log(await image.history());
// On a multi-platform image, pick a variant with an OCI platform.
const arm = { architecture: 'arm64', os: 'linux', variant: 'v8' };
console.log((await image.inspect({ platform: arm })).Architecture);
console.log(await image.history({ platform: arm }));
await image.tag({ repo: 'myorg/alpine', tag: 'stable' });
await docker.getImage('myorg/alpine:stable').remove(); // removes the tag
await docker.getImage('alpine:3').remove({ force: true }); // removes the imagePush
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const image = docker.getImage('registry.example.com/team/app:1.2');
for await (const line of image.push({ auth: { username: 'ci-bot', password: 'secret' } }).lines()) {
console.log(line);
}Build
buildImage() accepts a build context in several forms and returns a ProgressStream.
import { createReadStream } from 'node:fs';
import tar from 'tar-fs';
import { Docker } from 'docker-api-mx';
const docker = new Docker();
// 1. A directory and the files in it. A `.dockerignore` in the directory is respected.
await docker.buildImage({ context: './app', src: ['Dockerfile', 'package.json', 'src'] }, { t: 'myapp:1' }).wait();
// 2. A tar file on disk (plain or gzip).
await docker.buildImage('./context.tar', { t: 'myapp:1' }).wait();
// 3. Any tar stream, for example from tar-fs.
await docker.buildImage(tar.pack('./app'), { t: 'myapp:1' }).wait();
await docker.buildImage(createReadStream('./context.tar.gz'), { t: 'myapp:1' }).wait();Build options mirror the docker build flags:
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const stream = docker.buildImage({ context: './app', src: ['Dockerfile', 'src'] }, {
t: ['myapp:1.4.0', 'myapp:latest'], // several tags
dockerfile: 'Dockerfile.prod',
buildargs: { NODE_ENV: 'production' },
labels: { 'org.opencontainers.image.source': 'https://example.com/repo' },
target: 'runtime', // multi-stage target
platform: 'linux/amd64',
nocache: true,
pull: true, // always check for a newer base image
cachefrom: ['myapp:cache'],
rm: true,
memory: 1024 * 1024 * 1024, // resource limits for the build containers
memswap: -1, // -1 turns swap off
cpusetcpus: '0-1',
shmsize: 128 * 1024 * 1024, // size of /dev/shm
});
for await (const line of stream.lines()) {
process.stdout.write(line.endsWith('\n') ? line : `${line}\n`);
}A failing build step raises a DockerStreamError with the daemon's message:
import { Docker, DockerStreamError } from 'docker-api-mx';
const docker = new Docker();
try {
await docker.buildImage({ context: './app', src: ['Dockerfile'] }, { t: 'broken:1' }).wait();
} catch (error) {
if (error instanceof DockerStreamError) {
console.error('build failed:', error.message);
}
}BuildKit builds and private base images
Set version: '2' to build with BuildKit. The library opens a build session so the daemon can ask for registry credentials while it resolves FROM images from a private registry.
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const context = { context: './app', src: ['Dockerfile', 'src'] };
// One set of credentials for every registry that is not listed in registryconfig.
await docker.buildImage(context, {
t: 'myapp:1',
version: '2',
auth: { username: 'ci-bot', password: 'secret' },
}).wait();
// Credentials per registry host.
for await (const line of docker.buildImage(context, {
t: 'myapp:1',
version: '2',
registryconfig: {
'registry.example.com': { username: 'ci-bot', password: 'secret' },
'ghcr.io': { username: 'me', password: 'token' },
},
}).lines()) {
console.log(line);
}lines() decodes the BuildKit status messages into readable lines: started steps, cache hits, command output, progress and warnings. For structured access, decode the raw events yourself:
import { BuildKitStatus, Docker } from 'docker-api-mx';
const docker = new Docker();
for await (const event of docker.buildImage({ context: './app', src: ['Dockerfile'] }, { t: 'myapp:1', version: '2' })) {
const status = BuildKitStatus.fromEvent(event);
if (status) {
for (const vertex of status.vertexes) {
console.log(vertex.name, vertex.cached ? '(cached)' : '', vertex.error);
}
}
const imageId = BuildKitStatus.imageId(event);
if (imageId) {
console.log('built', imageId);
}
}Save, load, import
import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { Docker } from 'docker-api-mx';
const docker = new Docker();
// docker save: one image, or several in one tar.
await pipeline(await docker.getImage('alpine:3').get(), createWriteStream('alpine.tar'));
await pipeline(await docker.exportImages(['alpine:3', 'nginx:alpine']), createWriteStream('images.tar'));
// docker load
await docker.loadImage(createReadStream('alpine.tar')).wait();
// docker import: a root filesystem tar becomes an image. `changes` are Dockerfile instructions.
await docker.importImage(createReadStream('rootfs.tar'), { repo: 'myorg/rootfs', tag: 'v1', changes: ['ENV DEBUG=true', 'CMD ["sh"]'] }).wait();
// A multi-platform image can be saved, loaded or removed per platform.
const amd64 = { architecture: 'amd64', os: 'linux' };
await pipeline(await docker.getImage('alpine:3').get({ platform: [amd64] }), createWriteStream('alpine-amd64.tar'));
await docker.loadImage(createReadStream('alpine-amd64.tar'), { platform: [amd64] }).wait();
await docker.getImage('alpine:3').remove({ platforms: [amd64] });Search and clean up
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const results = await docker.searchImages('nginx', { limit: 5, filters: { 'is-official': ['true'] } });
for (const result of results) {
console.log(result.name, result.star_count);
}
await docker.pruneImages({ filters: { dangling: ['true'] } });
await docker.pruneBuilder();
// Keep at most 10 GB of build cache, and leave at least 20 GB of the disk free.
await docker.pruneBuilder({ all: true, 'max-used-space': 10e9, 'min-free-space': 20e9 });
// `shared-size` adds the size shared with other images; `manifests` adds the manifests.
const images = await docker.listImages({ 'shared-size': true, manifests: true });
const details = await docker.getImage('alpine:3').inspect({ manifests: true });Networks
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const network = await docker.createNetwork({
Name: 'backend',
Driver: 'bridge',
Labels: { project: 'demo' },
IPAM: { Config: [{ Subnet: '172.28.0.0/16' }] },
});
// Start a container on the network from the start...
const api = await docker.createContainer({
Image: 'nginx:alpine',
name: 'api',
HostConfig: { NetworkMode: 'backend' },
});
await api.start();
// ...or connect a running container later, with an alias.
const worker = docker.getContainer('worker');
await network.connect({ Container: worker.id, EndpointConfig: { Aliases: ['jobs'] } });
console.log(Object.keys((await network.inspect()).Containers ?? {}));
await network.disconnect({ Container: worker.id });
await network.remove();
console.log(await docker.listNetworks({ filters: { label: ['project=demo'] } }));Volumes
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const volume = await docker.createVolume({ Name: 'pgdata', Labels: { project: 'demo' } });
console.log(await volume.inspect());
const { Volumes } = await docker.listVolumes({ filters: { label: ['project=demo'] } });
console.log(Volumes?.map((v) => v.Name));
await volume.remove();
await docker.pruneVolumes({ filters: { label: ['project=demo'] } });Cluster volumes
On a swarm with a CSI plugin, volumes are cluster volumes: they carry a ClusterVolume with a swarm ID, a version and
a spec. update changes one; the daemon only accepts a different AccessMode.Availability, everything else must stay
as it is, so start from the current spec. Without version, it is read from ClusterVolume.Version.Index first, like
the other swarm objects.
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const volume = await docker.createVolume({
Name: 'shared-data',
Driver: 'my-csi-plugin',
ClusterVolumeSpec: { Group: 'demo', AccessMode: { Scope: 'multi', Sharing: 'all', MountVolume: {} } },
});
const { ClusterVolume } = await volume.inspect();
if (ClusterVolume) {
const { AccessMode, ...rest } = ClusterVolume.Spec;
// Drain it: no new tasks are scheduled on it.
await volume.update({ ...rest, AccessMode: { ...AccessMode, Availability: 'drain' } });
}Calling update on a volume that is not a cluster volume throws before anything is sent.
Disk usage
import { Docker } from 'docker-api-mx';
const docker = new Docker();
// Everything, or only some kinds. `verbose` adds the detailed items.
const usage = await docker.df({ type: ['image', 'build-cache'], verbose: true });
console.log(usage.ImageUsage?.TotalSize, usage.ImageUsage?.Reclaimable);
console.log(usage.BuildCacheUsage?.TotalCount);Daemons with API 1.52 or newer return ImageUsage, ContainerUsage, VolumeUsage and BuildCacheUsage, each with
ActiveCount, TotalCount, Reclaimable and TotalSize. The older Images, Containers, Volumes, BuildCache
and LayersSize are still returned, so existing code keeps working.
Events
events() is an async generator. Stop with break or an AbortSignal.
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const controller = new AbortController();
process.on('SIGINT', () => controller.abort());
try {
for await (const event of docker.events({
filters: { type: ['container'], event: ['start', 'die'] },
abortSignal: controller.signal,
})) {
console.log(event.Action, event.Actor.Attributes.name);
}
} catch (error) {
if ((error as Error).name !== 'AbortError') {
throw error;
}
}Replay history with since and until. The generator ends when the history has been replayed:
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const now = Math.floor(Date.now() / 1000);
// The last hour, ending safely in the past (see the note below).
for await (const event of docker.events({ since: now - 3600, until: now - 5 })) {
console.log(new Date(event.time * 1000).toISOString(), event.Type, event.Action);
}since and until are evaluated by the daemon, with the daemon's clock. When until is in the past, the stream ends as soon as the history has been replayed. When until is in the future, or so close to now that a small clock difference puts it there, the daemon keeps the stream open. In our tests it sometimes stayed open for a long time past until, until the next event arrived. Keep until a few seconds in the past, and add an abortSignal when you cannot rely on it:
import { Docker } from 'docker-api-mx';
const docker = new Docker();
try {
for await (const event of docker.events({ since: 1_700_000_000, abortSignal: AbortSignal.timeout(10_000) })) {
console.log(event.Action);
}
} catch (error) {
if ((error as Error).name !== 'TimeoutError' && (error as Error).name !== 'AbortError') {
throw error;
}
}Swarm
Initialize and inspect
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const nodeId = await docker.swarmInit({ ListenAddr: '0.0.0.0:2377', AdvertiseAddr: '10.0.0.1:2377' });
console.log('manager node:', nodeId);
const swarm = await docker.swarmInspect();
console.log(swarm.JoinTokens.Worker); // hand this to workersOn another machine:
import { Docker } from 'docker-api-mx';
const worker = new Docker({ host: 'worker-1.example.com', port: 2375 });
await worker.swarmJoin({ RemoteAddrs: ['10.0.0.1:2377'], JoinToken: 'SWMTKN-1-...' });Services
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const service = await docker.createService({
Name: 'web',
Labels: { project: 'demo' },
TaskTemplate: {
ContainerSpec: { Image: 'nginx:alpine', Env: ['MODE=prod'] },
RestartPolicy: { Condition: 'on-failure', MaxAttempts: 3 },
Resources: { Limits: { MemoryBytes: 128 * 1024 * 1024 } },
Placement: { Constraints: ['node.role == worker'] },
},
Mode: { Replicated: { Replicas: 3 } },
EndpointSpec: { Ports: [{ Protocol: 'tcp', TargetPort: 80, PublishedPort: 8080 }] },
UpdateConfig: { Parallelism: 1, Delay: 10_000_000_000, FailureAction: 'rollback' }, // delay in nanoseconds
});
const services = await docker.listServices({ status: true });
for (const item of services) {
console.log(item.Spec.Name, item.ServiceStatus?.RunningTasks, '/', item.ServiceStatus?.DesiredTasks);
}
console.log(service.id);Updating. Swarm uses optimistic locking: an update must name the object's current Version.Index. If you do not pass version, the library reads it right before the update. Pass it yourself when you want the update to fail if someone else changed the service in the meantime.
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const service = docker.getService('web');
const { Spec, Version } = await service.inspect();
// Scale to 5 replicas. The version is fetched for you.
await service.update({ ...Spec, Mode: { Replicated: { Replicas: 5 } } });
// Or with a version you read earlier. Rejected with "update out of sequence" if it is stale.
await service.update({ ...Spec, Mode: { Replicated: { Replicas: 5 } } }, { version: Version.Index });
// Rolling update to a new image.
await service.update({
...Spec,
TaskTemplate: { ...Spec.TaskTemplate, ContainerSpec: { ...Spec.TaskTemplate?.ContainerSpec, Image: 'nginx:1.27-alpine' } },
});
// Roll back to the previous spec.
await service.update(Spec, { rollback: 'previous' });
await service.remove();Logs and tasks.
import { Docker, MultiplexedStream } from 'docker-api-mx';
const docker = new Docker();
const service = docker.getService('web');
await MultiplexedStream.demux(await service.logs({ tail: 50, timestamps: true }), process.stdout, process.stderr);
const tasks = await docker.listTasks({ filters: { service: ['web'], 'desired-state': ['running'] } });
for (const task of tasks) {
console.log(task.ID.slice(0, 8), task.Status.State, task.NodeID);
}
const task = docker.getTask(tasks[0]!.ID);
console.log(await task.inspect());
await MultiplexedStream.demux(await task.logs({ tail: 20 }), process.stdout, process.stderr);Secrets and configs
Data can be a string or a Buffer; the library encodes it to base64. A secret's data cannot be read back, a config's can (as base64).
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const secret = await docker.createSecret({ Name: 'db-password', Data: 'correct horse battery staple', Labels: { project: 'demo' } });
const config = await docker.createConfig({ Name: 'nginx-conf', Data: 'worker_processes auto;' });
console.log(Buffer.from((await config.inspect()).Spec.Data ?? '', 'base64').toString());
// Only labels can be changed on an existing secret or config.
await secret.update({ Name: 'db-password', Labels: { project: 'demo', rotated: '2025' } });
console.log(await docker.listSecrets({ filters: { label: ['project=demo'] } }));Mount them into a service. UID and GID are required in the File reference: the daemon rejects the service or fails the task when they are missing.
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const secret = docker.getSecret('db-password');
const config = docker.getConfig('nginx-conf');
const [secretInfo, configInfo] = await Promise.all([secret.inspect(), config.inspect()]);
await docker.createService({
Name: 'app',
TaskTemplate: {
ContainerSpec: {
Image: 'alpine:3',
Command: ['sh', '-c', 'cat /run/secrets/db-password; cat /etc/app.conf; sleep 3600'],
Secrets: [{ SecretID: secretInfo.ID, SecretName: 'db-password', File: { Name: 'db-password', UID: '0', GID: '0', Mode: 0o400 } }],
Configs: [{ ConfigID: configInfo.ID, ConfigName: 'nginx-conf', File: { Name: '/etc/app.conf', UID: '0', GID: '0', Mode: 0o444 } }],
},
},
});Nodes
import { Docker } from 'docker-api-mx';
const docker = new Docker();
for (const node of await docker.listNodes()) {
console.log(node.ID.slice(0, 8), node.Spec.Role, node.Status?.State, node.Description?.Hostname);
}
const node = docker.getNode('node-id');
const { Spec } = await node.inspect();
await node.update({ ...Spec, Availability: 'drain' }); // move tasks away
await node.update({ ...Spec, Labels: { ...Spec.Labels, zone: 'eu-1' } });
await node.remove({ force: true });Leaving
import { Docker } from 'docker-api-mx';
const docker = new Docker();
await docker.swarmLeave({ force: true });Plugins
import { Docker } from 'docker-api-mx';
const docker = new Docker();
// `name` is what it is called locally, `remote` where it comes from.
const plugin = docker.getPlugin('local/sshfs:latest', 'vieux/sshfs:latest');
// Ask which privileges the plugin needs, then grant exactly those.
const privileges = await plugin.privileges();
console.log(privileges.map((p) => `${p.Name}=${p.Value.join(',')}`));
await plugin.pull({ privileges }).wait();
await plugin.configure(['DEBUG=1']); // only while disabled
await plugin.enable({ timeout: 30 });
console.log((await plugin.inspect()).Enabled); // true
await plugin.disable();
await plugin.upgrade({ privileges }).wait();
await plugin.remove({ force: true });
console.log(await docker.listPlugins({ filters: { enabled: ['true'] } }));Build your own plugin from a tar with config.json and a rootfs/ directory:
import tar from 'tar-fs';
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const created = await docker.createPlugin(tar.pack('./my-plugin'), { name: 'myorg/my-plugin:latest' });
console.log((await created.inspect()).Config.Description);Checkpoints
Checkpoints need an experimental daemon with CRIU installed. On a daemon without experimental mode every checkpoint call fails with a DockerError (status 501).
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const container = docker.getContainer('counter');
await container.createCheckpoint({ CheckpointID: 'before-upgrade', Exit: true }); // stops the container
console.log(await container.listCheckpoints());
await container.start({ checkpoint: 'before-upgrade' }); // restores it
await container.deleteCheckpoint('before-upgrade');Streams and helpers
ProgressStream
Returned by pull, push, buildImage, loadImage, importImage and the plugin pull, upgrade and push. The request starts immediately.
| Member | Description |
| ----------------------------------- | ------------------------------------------------------------------ |
| for await (const event of stream) | Raw progress events. Throws DockerStreamError on an error event. |
| stream.lines() | An async generator of readable lines. Decodes BuildKit output. |
| stream.wait(onProgress?) | Resolves with all events once the operation is done. |
| stream.raw() | The underlying response Readable, if you want to parse it yourself. |
MultiplexedStream
Without a TTY, logs, attach and exec output mix stdout and stderr in one stream of frames (an 8-byte header plus the payload).
import { PassThrough } from 'node:stream';
import { MultiplexedStream } from 'docker-api-mx';
// Split a stream into two strings.
async function split(source: AsyncIterable<Buffer>): Promise<{ out: string; err: string }> {
const stdout = new PassThrough();
const stderr = new PassThrough();
let out = '';
let err = '';
stdout.on('data', (chunk: Buffer) => (out += chunk.toString()));
stderr.on('data', (chunk: Buffer) => (err += chunk.toString()));
await MultiplexedStream.demux(source, stdout, stderr);
return { out, err };
}
// Build a frame, for example to feed tests.
const frame = MultiplexedStream.encode('stdout', 'hello');
console.log(frame.length); // 8 + 5If the source is not multiplexed (a TTY, or data that does not start with a valid header), frames() passes the bytes on unchanged as stdout.
JsonLines
Parses newline-delimited JSON, the format of progress, events and stats streams:
import { Docker, JsonLines } from 'docker-api-mx';
const docker = new Docker();
const raw = await docker.pull('alpine:3').raw();
for await (const event of JsonLines.parse<{ status?: string }>(raw)) {
console.log(event.status);
}RepositoryTag
import { RepositoryTag } from 'docker-api-mx';
RepositoryTag.parse('ubuntu'); // { repository: 'ubuntu' }
RepositoryTag.parse('ubuntu:22.04'); // { repository: 'ubuntu', tag: '22.04' }
RepositoryTag.parse('localhost:5000/team/app'); // { repository: 'localhost:5000/team/app' }
RepositoryTag.parse('repo@sha256:abc'); // { repository: 'repo', tag: 'sha256:abc' }Error handling
Failed requests throw a DockerError; failures that the daemon reports inside a streaming response throw a DockerStreamError.
import { Docker, DockerError } from 'docker-api-mx';
const docker = new Docker();
try {
await docker.getContainer('does-not-exist').inspect();
} catch (error) {
if (error instanceof DockerError) {
console.log(error.statusCode); // 404
console.log(error.isNotFound); // true
console.log(error.message); // "(HTTP code 404) No such container: does-not-exist"
console.log(error.json); // the parsed body of the daemon's response
}
}Handle expected conditions explicitly instead of parsing messages:
import { Docker, DockerError } from 'docker-api-mx';
const docker = new Docker();
// Create a network only if it does not exist yet.
async function ensureNetwork(name: string): Promise<void> {
try {
await docker.getNetwork(name).inspect();
} catch (error) {
if (error instanceof DockerError && error.isNotFound) {
await docker.createNetwork({ Name: name });
return;
}
throw error;
}
}
// A name that is already taken is a conflict (409).
try {
await docker.createContainer({ Image: 'alpine:3', name: 'taken' });
} catch (error) {
if (error instanceof DockerError && error.isConflict) {
console.log('a container with that name already exists');
}
}| Class | When | Useful members |
| ------------------- | -------------------------------------------------- | ----------------------------------------------------------- |
| DockerError | The daemon answered with a non-success status. | statusCode, isNotFound, isConflict, json, message |
| DockerStreamError | An error line inside an already started pull, push or build stream. | message, detail (the raw event) |
Network problems (connection refused, socket errors, timeouts) surface as the underlying Node.js Error.
Cancellation and timeouts
Every call accepts abortSignal. Pair it with AbortSignal.timeout() for a per-call deadline:
import { Docker } from 'docker-api-mx';
const docker = new Docker();
// Give up on a slow call after 5 seconds.
const info = await docker.info({ abortSignal: AbortSignal.timeout(5_000) });
console.log(info.ServerVersion);
// Cancel a long pull from elsewhere in your program.
const controller = new AbortController();
const pulling = docker.pull('ubuntu:24.04', { abortSignal: controller.signal }).wait();
setTimeout(() => controller.abort(), 2_000);
try {
await pulling;
} catch (error) {
console.log((error as Error).name); // "AbortError"
}Connection-level limits apply to every request of a client:
import { Docker } from 'docker-api-mx';
const docker = new Docker({ connectionTimeout: 3_000, timeout: 30_000 });timeout is a socket inactivity timeout, so it also applies to streams that are quiet for a long time, such as follow logs. Leave it unset for those.
TypeScript
All option and response types are exported:
import type {
ContainerCreateOptions,
ContainerInspectInfo,
ImageSummary,
ProgressEvent,
ServiceSpec,
DockerOptions,
} from 'docker-api-mx';
const spec: ContainerCreateOptions = { Image: 'alpine:3', Cmd: ['true'] };Responses type the fields most people use and keep an index signature for the rest, so a field this library does not list is still reachable:
import { Docker } from 'docker-api-mx';
const docker = new Docker();
const info = await docker.getContainer('web').inspect();
console.log(info.State.Status); // typed
console.log(info['ResolvConfPath']); // not listed, still accessible as unknownDevelopment and testing
npm install
npm run lint # ESLint
npm run typecheck # tsc --noEmit
npm test # unit tests against an in-process fake daemon, no Docker needed
npm run build # ESM and CommonJS builds in dist/Integration tests run against a real daemon. They only run when you name one, and they create and remove temporary objects prefixed with dockerapimx-:
DOCKER_INTEGRATION_HOST=tcp://127.0.0.1:2375 npm run test:integrationPoint them at a daemon where creating containers, images, networks, volumes and a temporary swarm is acceptable. The swarm suite refuses to run on a daemon that is already part of a swarm.
The SSH transport has its own opt-in suite. It logs in with your normal SSH key and ~/.ssh/known_hosts, and runs against a daemon reached over SSH:
DOCKER_INTEGRATION_SSH_HOST=ssh://user@host npm run test:integration