Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Single-Container Lifecycle (perry/container)

perry/container exposes the OCI primitives that operate on one container at a time: create, start, run, stop, remove, exec, logs, inspect, plus image management. For multi-service stacks, see perry/compose — but you can mix the two modules in the same program (a long-running compose stack plus one-off run() helpers against it is a normal pattern).

Every async function returns a Promise. The runtime backend (docker, podman, apple/container, …) is auto-detected on first use; see Overview for the probe order and override knobs.

Running a container

run() creates and starts a container in one shot, returning a handle:

import { run, remove } from "perry/container";

async function runAlpine(): Promise<void> {
    const handle = await run({
        image: "alpine:3.19",
        cmd: ["echo", "hello from perry"],
        rm: false,
        // Production-friendly defaults: drop every Linux capability and
        // run as a non-root user. Add `cap_add` only for the specific
        // capabilities a workload actually needs.
        user: "nobody",
        cap_drop: ["ALL"],
    });
    console.log(`container handle: ${String(handle)}`);

    // `force: true` removes the container even if still running (the
    // FFI calls `docker rm -f` / `podman rm -f`).
    await remove(handle as unknown as string, true);
}

The full ContainerSpec accepts:

FieldTypeEffect
imagestring(required) Image reference, e.g. "alpine:3.19".
namestringExplicit container name. Defaults to {md5(image)[0..8]}-{random_hex8} when unset.
cmdstring[]Command-line override (overrides the image’s CMD).
entrypointstring[]Entrypoint override.
envRecord<string, string>Environment variables.
portsstring[]Port maps in "host:container" form, e.g. ["8080:80"].
volumesstring[]Volume mounts in "host:container[:ro]" form, e.g. ["./data:/data:ro"].
networkstringNetwork name to attach to.
rmbooleanAuto-remove on exit (docker run --rm).
labelsRecord<string, string>Container labels.
read_onlybooleanMount the root filesystem read-only.
privilegedbooleanRun privileged. Use sparingly.
userstringUID, username, or "UID:GID".
workdirstringWorking directory inside the container.
cap_addstring[]Linux capabilities to add (e.g. ["NET_BIND_SERVICE"]).
cap_dropstring[]Linux capabilities to drop (e.g. ["ALL"]).
seccompstringSeccomp profile path or "default".

See Security for the security knobs in depth.

Hardened single-container run

For an untrusted workload (e.g. running user-supplied code, executing a build script from an untrusted source) the recommended starting point is “drop everything, add back what you need”:

import { run as runSecure } from "perry/container";

// Maximum-isolation single-container run for an untrusted workload:
//   - read-only root filesystem
//   - no Linux capabilities at all
//   - non-root user
//   - working directory pinned
//   - default seccomp profile
async function runUntrustedWorkload(): Promise<void> {
    await runSecure({
        image: "alpine:3.19",
        cmd: ["sh", "-c", "echo isolated && exit 0"],
        read_only: true,
        cap_drop: ["ALL"],
        user: "nobody",
        workdir: "/tmp",
        seccomp: "default",
    });
}

Inspect, list, logs, exec

import {
    list,
    inspect,
    logs,
    exec,
} from "perry/container";

async function inspectAll(): Promise<void> {
    const containers = await list(true); // all=true → include stopped
    console.log(containers);

    const id = "my-container-id";
    const info = await inspect(id);
    console.log(info.status); // "running" | "exited" | …

    // Tail the last 50 stdout/stderr lines.
    const tailed = await logs(id, { tail: 50 });
    console.log(tailed.stdout);

    // Run a command inside the container; returns a ContainerLogs
    // handle whose stdout/stderr you can read.
    const r = await exec(id, ["ls", "-la"]);
    console.log(r.stdout);
}
FunctionSignatureNotes
list(all?)(all: boolean) → Promise<ContainerInfo[]>all=true includes stopped containers.
inspect(id)(id: string) → Promise<ContainerInfo>Throws if the container doesn’t exist.
logs(id, opts?)(id, { tail?: number }) → Promise<ContainerLogs>Returns a registry handle to a { stdout, stderr } pair.
exec(id, cmd, opts?)(id, cmd[], { env?, workdir? })Runs a command in the container. Returns a ContainerLogs handle.
stop(id, timeout?)(id, seconds: number)Sends SIGTERM, then SIGKILL after timeout seconds.
start(id)(id)Re-starts a stopped container.
remove(id, force?)(id, force: boolean)force=true is docker rm -f.

Note on the logs and exec return shape: today the FFI returns a registry-id handle into a Vec<ContainerLogs> rather than a JS object. Treat the returned value as opaque — a future ergonomics task will expose .stdout / .stderr directly on the JS side. The ContainerLogs shape over the wire is { stdout: string, stderr: string }.

Image management

import { pullImage, listImages, removeImage } from "perry/container";

async function manageImages(): Promise<void> {
    await pullImage("postgres:16-alpine");
    const images = await listImages();
    console.log(`${images.length} images`);
    await removeImage("postgres:16-alpine", false);
}
FunctionSignature
pullImage(reference)(reference: string) → Promise<void>
listImages()() → Promise<ImageInfo[]>
removeImage(reference, force?)(reference: string, force: boolean) → Promise<void>

When PERRY_CONTAINER_VERIFY_IMAGES=1 is set, every run(), create(), and pullImage() call routes through cosign keyless verification against the Chainguard identity. See Security → Image verification.

Container naming

The default name is {md5(image)[0..8]}-{random_hex8} — a stable 8-character hash of the image plus a per-call random suffix. This is fine for one-off run() calls but makes containers hard to find later unless you set name: explicitly. For anything you’ll re-target later (with inspect, logs, exec, etc.), set name: upfront.

const handle = await run({
  image: "alpine:3.19",
  name: "build-helper",   // ← stable handle
  cmd: ["sh", "-c", "echo 'hi from build-helper'"],
  rm: true,
});

Backend introspection

import { getBackend, detectBackend } from "perry/container";

async function pickBackend(): Promise<void> {
    // Synchronous: returns the canonical name of the active backend
    // (`"docker"`, `"podman"`, `"apple/container"`, `"orbstack"`,
    // `"colima"`, `"lima"`, `"nerdctl"`, …). When called before any
    // async FFI has triggered detection, getBackend() performs a
    // synchronous in-place probe with the same 2 s timeout per
    // candidate that detectBackend() uses, so the result is live.
    console.log(`backend: ${getBackend()}`);

    // Async + verbose: returns a JSON array of every probed backend
    // with availability + version + reason for unavailable ones. Use
    // this when you want to surface a "diagnostics" panel to the user.
    const probed = await detectBackend();
    console.log(probed);
}

getBackend() is synchronous and returns the canonical backend name ("docker", "podman", "apple/container", etc.). It will perform a synchronous in-place probe on first call so the result is always the live name; calls after the first hit a cached OnceLock and return instantly.

detectBackend() is async and returns a JSON array of every probed candidate with { name, available, reason, version, mode, isolationLevel } per entry. Use it to surface a “diagnostics” view in your CLI / dashboard.

See also