# @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/](https://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/](https://code.foss.global/) account to submit Pull Requests directly.

## Install

```shell
pnpm add @apiclient.xyz/docker
```

## Connect to Docker

```typescript
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 does not use archive storage or its temporary processing directory. 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.

```typescript
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:

```typescript
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.

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

Images can also be inspected directly:

```typescript
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 by default and an argv command; shell command strings are intentionally unsupported.

```typescript
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.

### Rootless Development Containers

Container root can be requested only with `allowRootOnRootless: true`. The client reads `/info` immediately before creation and requires the exact `name=rootless` entry in `SecurityOptions`; an absent or malformed field, a rootful daemon, or a root user without the opt-in is refused before creation. The normal non-root default remains in force for other containers.

```typescript
const rootlessDocker = new DockerHost({
  socketPath: 'unix:///run/user/1000/docker.sock',
  enableImageStore: false,
});
const info = await rootlessDocker.info();
if (!info.SecurityOptions.includes('name=rootless')) throw new Error('Rootless Docker required');

const environment = await rootlessDocker.createContainer({
  name: 'project-environment', imageId: image.Id,
  user: 'root', allowRootOnRootless: true,
  entrypoint: ['/bin/sh', '-c'], command: ['exec /usr/local/bin/lifeline'],
  workingDirectory: '/workspace', tty: false, openStdin: true, stdinOnce: true,
  init: true, stopSignal: 'SIGTERM', stopTimeout: 10,
  memoryBytes: 4 * 1024 ** 3, memorySwapBytes: 4 * 1024 ** 3,
  nanoCpus: 8_000_000_000, pidsLimit: 8192, shmSize: 512 * 1024 ** 2,
  logDriver: 'none',
  bindMounts: [{ source: '/srv/workspace', target: '/workspace', createMountpoint: true }],
});
```

`memorySwapBytes` is Docker's combined memory and swap limit; setting it equal to `memoryBytes` disallows extra swap. `createMountpoint` defaults to false, so a missing bind source is not created unless requested. `logDriver: 'none'` disables Docker's container logs. The caller owns the container and must stop and remove it when finished.

### Listing and Direct Inspection

```typescript
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();
const state = await container.inspectState();
if (state.OOMKilled) {
  console.log(`Container ${container.Id} was killed by the memory limit`);
}
```

`inspectState()` validates Docker's `Status`, `Running`, `OOMKilled`, `Pid`, and `ExitCode` fields on a fresh exact-ID inspection. It throws if any field is absent or malformed, so recovery code does not treat missing OOM evidence as `false`. The original `inspect()` remains available for the full Engine response.

### Idempotent Lifecycle

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

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

const completion = await container.wait('not-running', {
  signal: AbortSignal.timeout(60_000),
});
console.log(completion.exitCode, completion.errorMessage);

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

For a separate running container with a TTY, resize it before sending a signal; `SIGTERM` may stop it:

```typescript
await ttyContainer.start();
await ttyContainer.resize(40, 120); // TTY rows and columns
await ttyContainer.kill('SIGTERM');
```

The lifecycle methods accept only Docker's documented success or idempotent status and preserve the response body in thrown errors for every other status. `wait()` also accepts `next-exit` and `removed`; it has no default request deadline because a container may run indefinitely, so pass a signal or `timeoutMs` when the caller has a deadline.

### 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.

```typescript
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.

```typescript
const controller = new AbortController();
const session = await container.execInteractive(['/bin/sh', '-i'], {
  timeoutMs: 30_000,
  signal: controller.signal,
  detachKeys: 'ctrl-@,ctrl-_',
  consoleSize: [40, 120],
});

session.stream.write('id\n');
await container.resizeExec(session.execId, 45, 140);
await session.close();
const finalState = await session.inspect();
```

### Logs, Stats, and Attach

```typescript
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,
  detachKeys: 'ctrl-@,ctrl-_',
});
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

```typescript
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

```typescript
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.

```typescript
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. Retained tasks from older iterations do not count when their desired state is terminal and their observed execution state is `complete`, `shutdown`, `failed`, or `rejected`. Historical desired `remove` and `orphaned` states are accepted only with that separate execution evidence. 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.

```typescript
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 has observed state `complete`, `shutdown`, `failed`, or `rejected`. Observed `remove` and `orphaned` states cannot prove stopped execution: SwarmKit uses them for tasks still shutting down or associated with an unresponsive node ([task-state definitions](https://github.com/moby/swarmkit/blob/master/api/types.proto#L459-L495)). 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

```typescript
import { parseDockerContainerEvent } from '@apiclient.xyz/docker';

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

const subscription = events.subscribe((event) => {
  const containerEvent = parseDockerContainerEvent(event);
  if (containerEvent?.Action === 'oom') {
    console.log(`Container ${containerEvent.Actor.ID} ran out of memory`);
  }
});

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.

For a workload that must not run without an active OOM monitor, use `openEventMonitor()` instead. It resolves after Docker's event endpoint responds with HTTP 200 and listeners are installed, before the first event is required. Await it before launching work. HTTP readiness has a 30-second deadline by default (`openTimeoutMs` can override it); an active stream may remain idle indefinitely. The monitor does not reconnect automatically: stream end, error, or malformed event data calls `onLost` once so the owner can stop the current workload. `close()` intentionally tears down the stream without reporting a loss. Pass an `AbortSignal` to cancel a pending open or close the active monitor.

```typescript
const monitor = await docker.openEventMonitor({
  filters: { type: ['container'], event: ['die', 'oom'] },
  onEvent: (event) => { /* handle the event for the current run */ },
  onLost: (error) => { /* stop the current run before opening a new monitor */ },
});
try {
  // Start supervised workload only after the monitor is ready.
} finally {
  monitor.close();
}
```

`parseDockerContainerEvent()` returns `undefined` for another Docker object type and validates a container event's `Type`, `Action`, and `Actor.ID` against the Engine event shape. A malformed container event throws instead of disappearing as an unrelated event. Include `oom` in the event filter when monitoring memory failures.

Docker's `timeNano` exceeds JavaScript's safe integer range. The event stream keeps the original numeric `timeNano` field for existing consumers and adds `timeNanoExact`, a canonical decimal string parsed from the original JSON token. `parseDockerTimedContainerEvent()` requires that exact timestamp. `container.inspectRunState()` reads one exact-ID inspection and returns `StartedAt` plus `startedAtUnixNano` from Docker's full RFC3339Nano value. After each physical start, compare these strings as `BigInt` to reject an older incarnation's late or replayed events:

```typescript
import { parseDockerTimedContainerEvent } from '@apiclient.xyz/docker';

const run = await container.inspectRunState();
const monitor = await docker.openEventMonitor({
  onEvent: (rawEvent) => {
    const event = parseDockerTimedContainerEvent(rawEvent);
    if (!event || event.Actor.ID !== container.Id) return;
    if (BigInt(event.timeNanoExact) < BigInt(run.startedAtUnixNano)) return;
    // Inspect the current exact-ID state before acting on lifecycle events.
  },
  onLost: (error) => { /* stop this run; monitoring has ended */ },
});
try {
  // Awaiting openEventMonitor above is the workload startup barrier.
} finally {
  monitor.close();
}
```

Refresh run evidence after every stop/start of the same container ID and fence asynchronous callbacks from prior subscriptions. A missing or malformed exact event timestamp must not be treated as incarnation proof.

## Optional Image Store

Image archives are stored in SmartBucket. The optional store is enabled by default, but requires `addS3Storage()` before uploads or retrieval. `imageStoreDir` holds disposable extraction and repacking files; it is not persistent archive storage. Each upload owns a separate temporary directory, and `start()` never clears the shared directory.

```typescript
const dockerWithStore = new DockerHost({
  imageStoreDir: '/tmp/docker-image-processing',
});
await dockerWithStore.start();
await dockerWithStore.addS3Storage({
  endpoint: 'objects.example.com',
  useSsl: true,
  accessKey: process.env.S3_ACCESS_KEY!,
  accessSecret: process.env.S3_ACCESS_SECRET!,
  bucketName: 'image-archives',
  directoryPath: 'images',
});

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

const tarStream = await image.exportToTarStream();
const controller = new AbortController();
const receipt = await dockerWithStore.storeImageArchive('example/api:archive', tarStream, {
  signal: controller.signal,
});

// Persist this receipt only after the upload succeeds.
console.log(receipt.storagePath, receipt.archiveDigest, receipt.byteLength);
const storedStream = await dockerWithStore.retrieveImage(receipt.storagePath);
await dockerWithStore.createImageFromTarStream(storedStream, {
  imageUrl: 'example/api:archive',
});
await dockerWithStore.stop();
```

`storeImageArchive(imageName, source, { signal? })` adopts the input stream immediately. It extracts regular files and directories through SmartArchive's owned TAR API, rewrites image-name metadata, and repacks the archive. Links, traversal paths, special entries, malformed metadata, source failures, and cancellation reject the operation. Input, repacking, upload streams, and local cleanup are joined before settlement. No success is returned at input EOF: repacking and storage still have to finish. Cancellation remains effective through final local cleanup.

Every new archive uses a unique `archives/<uuid>` storage path and SmartBucket's probed, exact-length, create-only upload. The provider must pass the conditional-write capability probe; unsupported providers fail explicitly. Existing destination objects are never deleted or overwritten by this API. The frozen `IDockerImageArchiveReceipt` contains:

| Field | Meaning |
| --- | --- |
| `schemaVersion` | `1` |
| `imageName` | Name embedded in the repacked image metadata |
| `storagePath` | Path relative to the configured SmartBucket Directory, without `.tar` |
| `objectPath` | Complete bucket key including `.tar` |
| `archiveDigest` | `sha256:<hex>` of the actual repacked bytes consumed by storage |
| `byteLength` | Exact length of those stored bytes |

`DockerImageArchiveStoreError.cause` preserves the original failure. Its frozen `state` includes the exact destination, `publication` (`not-published`, `published`, or `ambiguous`), and any confirmed `storedArchive` receipt. Cleanup failure or late cancellation may reject after the object was stored. An ambiguous provider response does not prove absence; reconcile the reported key before changing metadata or removing an object. Docker never guesses that an uncertain upload should be deleted. SmartBucket retains ownership of deferred provider work and exposes its cleanup evidence through an underlying `ExactUploadError`; applications that construct `DockerImageStore` directly also own closing the parent SmartBucket client.

The existing `storeImage(imageName, source): Promise<void>` remains available. It uses the same owned extraction, repacking, and cleanup, then atomically replaces `<imageName>.tar` through SmartBucket's ordinary overwrite API. There is no pre-delete, so a rejected put cannot first erase the previous archive. This legacy method has no caller signal or immutable receipt; shutdown still waits for its provider call to settle. Retrieve it with `retrieveImage(imageName)`.

`DockerImageStore.getImage(storagePath, { signal? })` and `DockerHost.retrieveImage(storagePath)` download from SmartBucket. Ownership of a successfully returned stream transfers to the caller, who must consume or destroy it. The optional `getImage()` signal continues to cancel the download after return. Store shutdown joins pending download setup, closes a late stream, and permanently rejects new work; streams already handed to callers remain caller-owned. Failed temporary cleanup remains tracked and can be retried by calling `stop()` again.

`addS3Storage()` 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. An upload captures its storage binding before asynchronous processing begins. `DockerHost.stop()` cancels and joins store operations, then 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 operations should set `enableImageStore: false`.

## 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.

## License and Legal Information

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in [license.md](./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<br>
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.
