dockerode
A Node.js client for the Docker Engine HTTP API. It models the API as entities rather than loose functions: docker.getContainer(id) gives you a Container with start, stop, exec, logs and stats, docker.getImage(name) gives you an Image, and the same holds for networks, volumes, services, tasks, secrets and configs. Every method takes the options Docker documents and returns what Docker returns, unmodified. Streams from attach, logs, pull and build are handed to you intact instead of being buffered, and helper functions exist for the two things that always trip people up: following progress and demultiplexing a combined stdout and stderr stream.
The default Node client for the Docker Engine API, and it has stayed that way by being a thin, complete and honest pass-through rather than an abstraction. Accept that you will read Docker's API reference alongside it and that TypeScript support is a third-party package running behind the releases.
Use it if
- You are automating Docker from Node and want direct API access rather than shelling out to the docker binary and parsing its output
- You need the raw streams from logs, attach, exec or stats, since dockerode passes them through and does not buffer them for you
- You are building test infrastructure or a CI runner that starts throwaway containers, which is what docker.run plus HostConfig.AutoRemove covers in one call
- You need parts of the API that the CLI exposes awkwardly or not at all, including Swarm services, tasks, nodes, secrets and configs
- You want TypeScript types in the box. There are none: types come from the separate @types/dockerode package, whose latest release is 4.0.1 from January 2026, so it lags the 5.x line you would be installing
- You expect the library to describe the API for you. Options are passed straight to Docker with the same PascalCase field names, so you are reading the Docker Engine API reference either way and a typo becomes a 500 from the daemon rather than a type error
- You need this in a browser, an edge runtime or anywhere without a Unix socket or a reachable daemon over TCP; it needs a real Docker endpoint and Node 14.17 or newer
- Small install size matters: the tree pulls @grpc/grpc-js, @grpc/proto-loader and protobufjs for BuildKit progress support, which is around 224.8 KB gzipped (bundlephobia) across 6 direct dependencies for what is fundamentally an HTTP client
- You want a promise-first API. The dual callback and promise interface means every method has two shapes, and mixing them produces the classic bug where a callback is passed and the returned EventEmitter is also awaited
- You need managed container lifecycles for integration tests, where testcontainers already wraps this library with waiting strategies, port mapping and cleanup you would otherwise write yourself
Setup reality
npm install dockerode gets you going against /var/run/docker.sock with no configuration, which is also where the first real problem starts: whatever runs your code needs permission on that socket, and mounting it into a container hands that container root-equivalent access to the host. For remote daemons you pass host, port and the ca, cert and key buffers yourself; there is no DOCKER_HOST parsing beyond the defaults, and protocol is inferred unless you set it. Pin the API version explicitly with the version option once your daemon is newer than 1.13, otherwise a daemon upgrade can change response shapes underneath you. The streaming methods are where the design shows: buildImage and pull return a stream that has not finished when the promise resolves, so you must run it through modem.followProgress to know the work completed, and a build that fails still emits a stream that ends normally with the error inside the JSON events. When Tty is false, stdout and stderr arrive multiplexed in a single framed stream and printing it directly produces binary noise; modem.demuxStream is the fix. buildImage needs either a tar archive or an explicit src file list, and files not listed simply are not there during the build. Errors from the daemon come back as objects with statusCode and a json body rather than as typed errors, so error handling means checking numbers. TypeScript users need @types/dockerode separately and should expect gaps against the current release.
Patterns
Create a clientconnect-to-daemon
const Docker = require('dockerode');
const local = new Docker({ socketPath: '/var/run/docker.sock' });
const remote = new Docker({
host: '10.0.0.5',
port: 2376,
ca: fs.readFileSync('ca.pem'),
cert: fs.readFileSync('cert.pem'),
key: fs.readFileSync('key.pem'),
version: 'v1.44',
});Pin version once your daemon is newer than 1.13. Without it the client uses the daemon default, so a host upgrade can change response shapes under working code.
Run a command and get the exit coderun-throwaway-container
const [result, container] = await docker.run(
'alpine:3.20',
['sh', '-c', 'echo hello'],
process.stdout,
{ HostConfig: { AutoRemove: true } }
);
console.log(result.StatusCode);With AutoRemove the container is gone by the time the promise resolves, so do not call container.remove() afterwards. Without a callback, run resolves to a [result, container] pair.
Create, start, stop and remove explicitlycreate-and-start-container
const container = await docker.createContainer({
Image: 'redis:7',
name: 'test-redis',
HostConfig: { PortBindings: { '6379/tcp': [{ HostPort: '0' }] } },
});
await container.start();
const info = await container.inspect();
console.log(info.NetworkSettings.Ports['6379/tcp'][0].HostPort);
await container.stop();
await container.remove();HostPort '0' asks Docker for a free port, and inspect after start is the only way to learn which one you got.
Separate stdout from stderrdemux-output-streams
const stream = await container.attach({
stream: true, stdout: true, stderr: true,
});
container.modem.demuxStream(stream, process.stdout, process.stderr);Needed whenever Tty is false, because Docker frames both streams into one connection with 8-byte headers. Piping that stream directly prints control bytes.
Wait for an image build to finishfollow-build-progress
const stream = await docker.buildImage(
{ context: __dirname, src: ['Dockerfile', 'package.json', 'src'] },
{ t: 'myapp:latest' }
);
const output = await new Promise((resolve, reject) => {
docker.modem.followProgress(stream, (err, res) => (err ? reject(err) : resolve(res)));
});Every file the Dockerfile touches must appear in src or it will not exist in the build context. followProgress is the only way to know the build ended, and a failed build resolves with the error inside the events.
Pull an image and wait for itpull-image
const stream = await docker.pull('postgres:16');
await new Promise((resolve, reject) => {
docker.modem.followProgress(stream, (err) => (err ? reject(err) : resolve()));
});The promise from pull resolves as soon as the stream exists, not when the layers are down. Starting a container before followProgress finishes fails with 'no such image'.
Run a command inside a running containerexec-in-container
const exec = await container.exec({
Cmd: ['sh', '-c', 'ls /app'],
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({ hijack: true, stdin: false });
container.modem.demuxStream(stream, process.stdout, process.stderr);
const detail = await exec.inspect();
console.log(detail.ExitCode);exec.start does not report the exit status. Call exec.inspect() after the stream ends and read ExitCode, otherwise a failing command looks successful.
Follow container logsstream-logs
const logs = await container.logs({
follow: true, stdout: true, stderr: true, tail: 100,
});
logs.on('data', (chunk) => process.stdout.write(chunk.toString()));With follow: true you get a stream; without it you get a Buffer. Same multiplexing rule applies, so demux unless the container was created with Tty: true.
Read resource usage oncecollect-stats
const stats = await container.stats({ stream: false });
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage
- stats.precpu_stats.cpu_usage.total_usage;
console.log(stats.memory_stats.usage, cpuDelta);stream: false returns a single sample. CPU percentage is not provided; you compute it from the delta against precpu_stats yourself, as the CLI does.
List containers with filterslist-and-filter
const containers = await docker.listContainers({
all: true,
filters: JSON.stringify({ label: ['app=web'], status: ['exited'] }),
});filters must be a JSON string, not an object, because it is passed through as a query parameter. Passing an object gives you an unfiltered list with no error.
Authenticate against a private registrypull-private-image
const authconfig = {
username: process.env.REGISTRY_USER,
password: process.env.REGISTRY_TOKEN,
serveraddress: 'https://index.docker.io/v1',
};
const stream = await docker.pull('acme/private:1.2.3', { authconfig });docker-modem base64 encodes this for you, so do not pre-encode. Auth failures surface as a stream event rather than a rejected promise.
Read the status code from a failurehandle-daemon-errors
try {
await docker.getContainer('missing').inspect();
} catch (err) {
if (err.statusCode === 404) {
console.log('not there');
} else {
throw err;
}
}Errors carry statusCode and json from the daemon rather than being typed classes, so branching on the number is the intended pattern. 409 means a conflicting state such as already started.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| testcontainers | npm | The goal is disposable containers for integration tests and you want wait strategies, port allocation and cleanup handled |
| docker-compose | npm | You just need to drive existing compose files from Node and do not want to model the API at all |
| execa | npm | Shelling out to the docker CLI is genuinely enough, and you would rather depend on a binary you already trust than on an API client |