2026-08-13 20:44:20 +00:00
2024-02-02 16:40:38 +01:00
2026-08-13 20:44:20 +00:00
2016-06-14 08:49:13 +02:00
2026-08-13 20:44:20 +00:00
2026-08-13 20:44:20 +00:00
2024-06-10 00:15:01 +02:00

@apiclient.xyz/docker

A typed TypeScript client for the Docker Engine API in Node.js and Deno. DockerHost is the public entry point for exact image pulls, standalone containers, networks, named volumes, Swarm services, secrets, configs, events, and optional image storage.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Install

pnpm add @apiclient.xyz/docker

Connect to Docker

import { DockerHost } from '@apiclient.xyz/docker';

const docker = new DockerHost({
  socketPath: 'http://unix:/var/run/docker.sock:',
  enableImageStore: false,
});

await docker.start();
await docker.ping();

const version = await docker.getVersion();
console.log(`${version.Version} (${version.ApiVersion})`);

await docker.stop();

When socketPath is omitted, the host uses DOCKER_HOST, then the CI Docker service at http://docker:2375/, then /var/run/docker.sock. Set enableImageStore: false when the process must not initialize the optional local image-store filesystem. Image-store methods reject explicitly while it is disabled.

Exact Image Pulls

pullImage() accepts one complete reference. It sends registry credentials only on that pull request, waits without the ordinary socket idle timeout, checks Docker's pull stream for embedded errors, directly inspects the requested reference, and verifies the expected repository digest before returning the immutable local image ID. An optional caller-owned AbortSignal cancels both the pull body and verification inspection; include any required whole-operation deadline in that signal. Without a signal, the pull remains unlimited and the verification inspection keeps its ordinary bounded request timeout.

const expectedRepoDigest =
  'registry.example.com:5443/team/api@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';

const image = await docker.pullImage({
  reference: 'registry.example.com:5443/team/api:2026-08-01',
  expectedRepoDigest,
  registryAuth: {
    serveraddress: 'registry.example.com:5443',
    username: 'deploy-user',
    password: process.env.REGISTRY_PASSWORD!,
  },
});

console.log(image.Id);                 // immutable local sha256 ID
console.log(image.Reference);          // requested complete reference
console.log(image.VerifiedRepoDigest); // verified repository digest
console.log(image.Os);                 // inspected operating system
console.log(image.Architecture);       // inspected CPU architecture

Identity-token authentication is also supported:

const image = await docker.pullImage({
  reference: expectedRepoDigest,
  expectedRepoDigest,
  registryAuth: {
    serveraddress: 'registry.example.com:5443',
    identitytoken: process.env.REGISTRY_IDENTITY_TOKEN!,
  },
});

A pull never falls back to an older cached tag. A tag pull whose inspected RepoDigests does not contain expectedRepoDigest rejects. A digest reference must equal expectedRepoDigest.

Application update flows that intentionally follow a mutable tag use the separately named pullMutableImage() API. It requires an explicit tag (including an explicit :latest when desired), rejects digest and implicit-latest references, applies optional registry credentials only to the pull request, rejects registry and progress-stream errors without a cached fallback, and returns a directly inspected DockerImage whose Id is the immutable local image ID observed after the pull. Its optional caller-owned AbortSignal covers both pull and inspection, so callers needing a whole-operation deadline must include it in that signal. Without a signal, the pull remains unlimited and verification inspection keeps its ordinary bounded request timeout. It does not provide repository-digest proof and should not be used for pinned platform infrastructure.

const observedApplicationImage = await docker.pullMutableImage({
  reference: 'registry.example.com/team/app:latest',
});
console.log(observedApplicationImage.Id); // sha256:...

Images can also be inspected directly:

const byReference = await docker.getImageByReference(
  'registry.example.com:5443/team/api:2026-08-01',
);
const byId = await docker.getImageById(image.Id);
const images = await docker.listImages({
  all: true,
  filters: { label: ['team=platform'] },
});

Both getters return undefined only for Docker status 404; other unsuccessful responses throw with the Docker response body.

Exact Standalone Containers

Standalone creation requires the immutable local image ID returned by pullImage() or getImageById(). The registry reference is optional evidence and is never sent to Docker for mutable resolution. Containers require an explicit non-root user and an argv command; shell command strings are intentionally unsupported.

const network = await docker.createNetwork({
  Name: 'api-internal',
  Driver: 'bridge',
  Internal: true,
  Labels: { owner: 'platform' },
});
const volume = await docker.createVolume({
  name: 'api-data',
  labels: { owner: 'platform' },
});

const container = await docker.createContainer({
  name: 'api-worker',
  imageId: image.Id,
  imageReference: image.VerifiedRepoDigest,
  user: '10001:10001',
  command: ['/app/server', '--listen', '8080'],
  env: {
    NODE_ENV: 'production',
  },
  labels: {
    owner: 'platform',
    workload: 'api',
  },
  bindMounts: [
    {
      source: '/srv/api-data',
      target: '/data',
      readOnly: true,
    },
  ],
  namedVolumeMounts: [
    {
      source: volume.Name,
      target: '/var/lib/api',
      readOnly: false,
    },
  ],
  tmpfsMounts: [
    {
      target: '/tmp',
      sizeBytes: 64 * 1024 * 1024,
      mode: 0o1777,
    },
  ],
  readOnlyRootFilesystem: true,
  networkEndpoints: [
    {
      networkId: network.Id,
      aliases: ['api'],
    },
  ],
  portBindings: [
    {
      containerPort: 8080,
      hostPort: 18080,
      hostIp: '127.0.0.1',
      protocol: 'tcp',
    },
  ],
  healthcheck: {
    test: ['CMD', '/app/healthcheck'],
    intervalMs: 10_000,
    timeoutMs: 2_000,
    startPeriodMs: 5_000,
    retries: 3,
  },
});

Bind sources and all mount targets must be absolute. Named-volume sources must be canonical Docker volume names; pass the exact DockerVolume.Name returned by the volume facade. Mount targets are unique across bind, named-volume, and tmpfs mounts, and network IDs must also be unique. Published ports are restricted to 127.0.0.1 or ::1; omit hostPort to let Docker allocate one on loopback. Healthcheck durations are milliseconds in TypeScript and are converted to Docker nanoseconds.

For a compatibility probe that must have no network access, pass networkMode: 'none' and omit both networkEndpoints and portBindings.

Creation accepts only Docker status 201 with a nonempty Id, then directly inspects that ID. It rejects if the inspected container does not use the requested image ID. Creation leaves the container stopped.

Listing and Direct Inspection

const containers = await docker.listContainers({
  // true by default, so stopped containers are included
  all: true,
  filters: {
    label: ['owner=platform'],
    status: ['created', 'exited'],
  },
});

const sameContainer = await docker.getContainerById(container.Id);
await container.refresh(); // always re-inspects the immutable ID
await container.rename('exact-api-next'); // verifies through the same immutable ID
const inspection = await container.inspect();

Idempotent Lifecycle

const startStatus = await container.start();
// 'started' | 'already-running'

const stopStatus = await container.stop({
  timeoutSeconds: 10,
  signal: 'SIGTERM',
});
// 'stopped' | 'already-stopped'

const removeStatus = await container.remove({
  force: true,
  removeAnonymousVolumes: true,
});
// 'removed' | 'already-removed'

The lifecycle methods accept only Docker's documented success or idempotent status and preserve the response body in thrown errors for every other status.

Bounded Exec

exec() is a collected, bounded operation. It accepts argv only, disables stdin and TTY, decodes Docker multiplexed stdout/stderr frames, enforces a whole-operation deadline and a combined output limit, waits for the exact exit code, and closes the hijacked transport on success and failure.

const result = await container.exec(
  ['/app/admin', 'check', '--format=json'],
  {
    env: { CHECK_MODE: 'deep' },
    workingDirectory: '/app',
    user: '10001',
    timeoutMs: 15_000,
    maxOutputBytes: 256 * 1024,
  },
);

console.log(result.execId);
console.log(result.stdout);
console.error(result.stderr);
console.log(result.exitCode);
console.log(result.inspect.Running); // false

The defaults are 30 seconds and 1 MiB of combined stdout and stderr. A command that exits nonzero still returns normally with its exact exitCode.

Interactive administration is a separate caller-owned contract. It keeps stdin and a TTY available until the caller closes the returned session; aborting the provided signal also closes the hijacked transport.

const controller = new AbortController();
const session = await container.execInteractive(['/bin/sh', '-i'], {
  timeoutMs: 30_000,
  signal: controller.signal,
});

session.stream.write('id\n');
await session.close();
const finalState = await session.inspect();

Logs, Stats, and Attach

const logs = await container.logs({ tail: 100, timestamps: true });
const stats = await container.stats({ stream: false });

const logStream = await container.streamLogs({
  stdout: true,
  stderr: true,
  demux: true,
});

const controller = new AbortController();
const attachment = await container.attach({
  stdin: false,
  stdout: true,
  stderr: true,
  stream: true,
  timeoutMs: 30_000,
  signal: controller.signal,
});
await attachment.close();

Exact container lookup, inspection, and attach handshakes accept caller-owned cancellation. An attach signal also closes an already-open hijacked transport, while close() remains idempotent. getContainerById() and inspect() use a 30-second request deadline by default; pass timeoutMs: 0 to disable it for an explicitly caller-bounded request. Attach uses timeoutMs only for opening the hijacked handshake. The active session remains open until its stream ends, its signal aborts, or close() is called.

Networks

const network = await docker.createNetwork({
  Name: 'api-internal',
  Driver: 'bridge',
  Internal: true,
  Attachable: true,
  Labels: { owner: 'platform' },
  Options: {
    'com.docker.network.driver.mtu': '1400',
  },
  IPAM: {
    Config: [{ Subnet: '172.30.0.0/24', Gateway: '172.30.0.1' }],
  },
});

const networks = await docker.listNetworks({
  filters: { label: ['owner=platform'] },
});
const byId = await docker.getNetworkById(network.Id);
const byName = await docker.getNetworkByName(network.Name);

console.log(network.hasLabels({ owner: 'platform' }));
await network.refresh();
const removeStatus = await network.remove();
// 'removed' | 'already-removed'

Network creation defaults to the bridge driver, non-internal, non-attachable, and IPv6 disabled. It requires status 201 with a nonempty ID and directly inspects that ID. Network instances keep their immutable ID across refreshes. Direct-ID operations accept Docker's two complete immutable network ID forms: 64-character hexadecimal IDs for local networks and 25-character base-36 IDs for Swarm networks; shortened prefixes and names are not accepted by ID-specific methods.

Named Volumes

const volume = await docker.createVolume({
  name: 'api-data',
  driver: 'local',
  labels: { owner: 'platform' },
  options: {
    type: 'none',
    device: '/srv/api-data',
    o: 'bind',
  },
});

const volumes = await docker.listVolumes({
  filters: { label: ['owner=platform'] },
});
const sameVolume = await docker.getVolumeByName('api-data');

console.log(volume.hasLabels({ owner: 'platform' }));
await volume.refresh();
const removeStatus = await volume.remove({ force: true });
// 'removed' | 'already-removed'

Volume creation requires status 201, verifies the returned name, and directly inspects that name. The name remains stable across refreshes.

Attach an owned named volume through IContainerCreationDescriptor.namedVolumeMounts; Docker receives an exact Type: 'volume' mount using that name. Removing a container does not remove the named volume, so a later container can attach the same name and read the persisted data. Remove the volume explicitly after its last container owner is gone.

Swarm Resources

The package retains its Swarm facades:

  • services: listServices(), getServiceByName(), getServiceById(), createService()
  • secrets: listSecrets(), getSecretByName(), getSecretById(), createSecret()
  • configs: listConfigs(), getConfigByName(), getConfigById(), createConfig()

Service creation accepts DockerImage, DockerNetwork, DockerSecret, and DockerConfig instances. Secret and config file targets support explicit filename, uid, gid, and mode. Docker secret and config payloads are immutable: rotate them by creating a replacement, updating service references, and removing the old resource.

IServiceCreationDescriptor.mode supports exact replicated replica counts and Docker GlobalJob services. placement.constraints maps directly to Docker's ANDed task placement constraints. To create a digest-pinned service, pass a DockerImage returned by verified pullImage() and set immutableImageReference to that image's exact VerifiedRepoDigest. To preserve an explicitly pulled mutable tag instead of relying on RepoTags[0], pass the image returned by pullMutableImage() with the same mutableImageReference. Service creation rejects unproven or mismatched references before Docker I/O.

Service args are serialized as ContainerSpec.Args, preserving the image entrypoint without invoking a shell. The narrow restartPolicy: { condition: 'none' } contract emits a non-retrying Swarm task policy. Service bind mounts are writable by default; set readOnly: true on a volumeMounts entry when the service must not modify that host path. Bind-mount source directories must already exist on every eligible node before scheduling. For the WorkloadInit installer, the mounted runtime-assets root must also be owned by root:root and must not be group- or world-writable; the job must run as root with root group.

const workloadInitDigest =
  'registry.example.com/platform/workloadinit@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
const workloadInitImage = await docker.pullImage({
  reference: 'registry.example.com/platform/workloadinit:1.2.2',
  expectedRepoDigest: workloadInitDigest,
});
const service = await docker.createService({
  name: 'workloadinit-generation-7',
  image: workloadInitImage,
  immutableImageReference: workloadInitDigest,
  labels: {
    'serve.zone.workloadinitTargetGeneration': '7',
  },
  networks: [],
  networkAlias: 'workloadinit-generation-7',
  secrets: [],
  ports: [],
  args: ['install'],
  mode: { type: 'global-job' },
  restartPolicy: { condition: 'none' },
  placement: {
    constraints: ['node.labels.serve-zone-target==generation-7'],
  },
  resources: {
    volumeMounts: [{
      hostFsPath: '/opt/serve.zone/runtime-assets',
      containerFsPath: '/opt/serve.zone/runtime-assets',
    }],
  },
});

// Preserve the creation-time authority before a bounded caller-owned
// wait/retry loop indicates that this exact execution may be complete.
if (!service.JobStatus?.JobIteration) {
  throw new Error('Created GlobalJob has no iteration authority');
}
const proof = await service.proveGlobalJobCompletion({
  expectedServiceVersionIndex: service.Version.Index,
  expectedJobIterationIndex: service.JobStatus.JobIteration.Index ?? 0,
  expectedImageReference: workloadInitDigest,
  expectedArgs: ['install'],
  expectedWritableBindMounts: [{
    hostFsPath: '/opt/serve.zone/runtime-assets',
    containerFsPath: '/opt/serve.zone/runtime-assets',
  }],
  expectedPlacementConstraints: [
    'node.labels.serve-zone-target==generation-7',
  ],
  requiredServiceLabels: {
    'serve.zone.workloadinitTargetGeneration': '7',
  },
  expectedTargetNodeIds: authoritativeTargets.map((target) => target.swarmNodeId),
});

proveGlobalJobCompletion() is a single fail-closed observation, not a waiter. Call it only after a bounded caller-owned wait/retry policy indicates the job may be complete; a pending job is expected to reject proof. The method performs exact service-ID inspect, task list, and repeated inspect observations. It requires an unchanged GlobalJob version and current job iteration; the expected immutable image, entrypoint arguments, complete writable bind-mount set, placement constraints, non-retrying policy, and authority labels; and exactly one complete task with exit code 0 for every caller-authenticated target node. Terminal retained tasks from older iterations do not count. Missing, duplicate, extra, malformed, future-iteration, or mismatched current tasks fail closed. Docker represents the first job iteration as an existing empty version object {}; the proof interprets that object as iteration index 0, while missing iteration data remains unprovable.

const desiredImage = await docker.pullImage({
  reference: 'registry.example.com/team/api:2026-08-01',
  expectedRepoDigest:
    'registry.example.com/team/api@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
});

const service = await docker.getServiceByName('api');
const needsUpdate = await service.needsUpdate(desiredImage);
await service.stopAndProveStopped({
  timeoutMs: 30_000,
  expectedVersionIndex: service.Version.Index,
  requiredLabels: { managedBy: 'control-plane' },
});
await service.pinStoppedImage({
  expectedImageReference: 'registry.example.com/team/api:2026-08-01',
  imageReference:
    'registry.example.com/team/api@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
});

needsUpdate() requires the desired DockerImage; it does not perform an implicit mutable pull. getServiceById() requires a complete immutable 25-character Swarm service ID and returns undefined only when Docker reports that exact ID as absent. stopAndProveStopped() supports replicated services, updates from a fresh exact-ID specification, and returns only when the service is absent or its desired replica count is zero, and every exact-ID task is terminal. Optional expectedVersionIndex and service-level requiredLabels preconditions are checked on that fresh inspection before any update or already-stopped proof; Docker's version-index CAS fences a subsequent concurrent change. Preconditions are irrelevant only after the exact service ID is already absent, when no service mutation is possible. The method fails closed on unknown task state or an unproven timeout. pinStoppedImage() changes only TaskTemplate.ContainerSpec.Image on a fresh, exact-ID replicated service specification. It requires zero desired replicas, exact current-image evidence, a complete immutable target repository digest, and exact service ownership plus terminal state for every filtered task before and after the version-fenced update. A failed update response is accepted only when the exact requested image pin, version advancement, unchanged remainder of the service specification, and stopped task state can still be proven.

Events

const events = await docker.getEventObservable({
  filters: {
    type: ['container'],
    event: ['start', 'die'],
  },
  reconnect: true,
});

const subscription = events.subscribe((event) => {
  console.log(event.Type, event.Action);
});

subscription.unsubscribe();

The daemon stream opens lazily when a consumer subscribes. Every subscription owns an independent stream, and unsubscribe destroys that stream, cancels its upstream HTTP body and socket, and aborts a pending reconnect. Event lines are buffered across chunks. Reconnect mode uses bounded backoff and resumes from the last observed event time; consumers should still tolerate duplicates around a reconnect boundary.

Optional Image Store

The legacy image-tar store remains available when enableImageStore is enabled, which is the default.

const dockerWithStore = new DockerHost({
  imageStoreDir: '/srv/docker-image-store',
});
await dockerWithStore.start();

const image = await dockerWithStore.getImageByReference('example/api:archive');
if (!image) {
  throw new Error('Image was not found');
}

const tarStream = await image.exportToTarStream();
await dockerWithStore.storeImage('example/api:archive', tarStream);

const storedStream = await dockerWithStore.retrieveImage('example/api:archive');
await dockerWithStore.createImageFromTarStream(storedStream, {
  imageUrl: 'example/api:archive',
});

addS3Storage() can attach an S3-compatible SmartBucket backend. Concurrent calls are serialized. Setup completes before the new client replaces the prior client; failed setup closes the candidate and leaves no newly owned client behind. DockerHost.stop() closes the active or in-flight SmartBucket client and permanently ends S3 configuration for that host: once shutdown begins, no queued, in-flight, or later candidate can install. Applications that only use Docker Engine lifecycle operations should set enableImageStore: false when filesystem persistence is not wanted.

Error Semantics

Exact lifecycle operations validate Docker's documented status codes and response shapes. Direct getters return undefined only on 404. Unexpected statuses throw errors containing the operation, actual status, and response body so daemon failures remain diagnosable. Creation methods hydrate the created resource through a direct inspect before returning it.

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in license.md.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
No description provided
Readme
2.7 MiB
Languages
TypeScript 99.4%
JavaScript 0.6%