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:
| Field | Type | Effect |
|---|---|---|
image | string | (required) Image reference, e.g. "alpine:3.19". |
name | string | Explicit container name. Defaults to {md5(image)[0..8]}-{random_hex8} when unset. |
cmd | string[] | Command-line override (overrides the image’s CMD). |
entrypoint | string[] | Entrypoint override. |
env | Record<string, string> | Environment variables. |
ports | string[] | Port maps in "host:container" form, e.g. ["8080:80"]. |
volumes | string[] | Volume mounts in "host:container[:ro]" form, e.g. ["./data:/data:ro"]. |
network | string | Network name to attach to. |
rm | boolean | Auto-remove on exit (docker run --rm). |
labels | Record<string, string> | Container labels. |
read_only | boolean | Mount the root filesystem read-only. |
privileged | boolean | Run privileged. Use sparingly. |
user | string | UID, username, or "UID:GID". |
workdir | string | Working directory inside the container. |
cap_add | string[] | Linux capabilities to add (e.g. ["NET_BIND_SERVICE"]). |
cap_drop | string[] | Linux capabilities to drop (e.g. ["ALL"]). |
seccomp | string | Seccomp 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);
}
| Function | Signature | Notes |
|---|---|---|
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
logsandexecreturn shape: today the FFI returns a registry-id handle into aVec<ContainerLogs>rather than a JS object. Treat the returned value as opaque — a future ergonomics task will expose.stdout/.stderrdirectly on the JS side. TheContainerLogsshape 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);
}
| Function | Signature |
|---|---|
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
- Compose orchestration — multi-service stacks.
- Networking — port maps, networks, the cross-service DNS gotcha.
- Security — capability isolation patterns.