Compose Orchestration (perry/compose)
perry/compose brings the docker compose up / down / ps / exec / logs
workflow into TypeScript. The spec is a TS object literal that mirrors
the Compose Specification,
the engine is in-process Rust (no shell-out to a docker-compose
binary), and dependency ordering / rollback / interpolation all run
natively.
Bringing up a single-service stack
import { up } from "perry/compose";
async function bringUpSimpleStack(): Promise<void> {
const stack = await up({
version: "3.8",
services: {
cache: {
image: "redis:7-alpine",
ports: ["6379:6379"],
networks: ["app-net"],
healthcheck: {
test: ["CMD", "redis-cli", "PING"],
interval: "5s",
timeout: "3s",
retries: 6,
},
},
},
networks: {
"app-net": { driver: "bridge" },
},
});
// `stack` is an opaque handle (NaN-boxed integer) — pass it as
// the first arg to `down` / `ps` / `logs` / `exec`.
console.log(`stack handle: ${String(stack)}`);
}
The handle returned from up() is an opaque integer (NaN-boxed with
POINTER_TAG); pass it as the first argument to
down / ps /
logs / exec. The
template-string interpolation ${stack} renders as [object Object]
because of the NaN-boxing tag; coerce explicitly with String(stack) if
you need to log it.
Multi-service stack with healthcheck-gated startup
import { up as upMulti } from "perry/compose";
async function bringUpMultiServiceStack(): Promise<void> {
// depends_on with `condition: 'service_healthy'` blocks the
// dependent service until the dependency's healthcheck reports
// healthy. Use the map form (not the bare-array form) to pass
// the condition.
await upMulti({
version: "3.8",
services: {
db: {
image: "postgres:16-alpine",
container_name: "app-db", // stable DNS target for siblings
environment: {
POSTGRES_USER: "app",
POSTGRES_PASSWORD: "${APP_DB_PASSWORD:-changeme}",
POSTGRES_DB: "app",
},
volumes: ["app-pgdata:/var/lib/postgresql/data"],
networks: ["app-db-net"],
healthcheck: {
test: ["CMD-SHELL", "pg_isready -U app -d app"],
interval: "5s",
timeout: "3s",
retries: 10,
start_period: "30s",
},
},
api: {
image: "myorg/api:1.0",
depends_on: { db: { condition: "service_healthy" } },
environment: {
DATABASE_URL: "postgres://app:changeme@app-db:5432/app",
},
ports: ["8080:8080"],
networks: ["app-db-net", "app-web-net"],
restart: "unless-stopped",
},
},
networks: {
"app-db-net": { driver: "bridge", internal: true }, // db unreachable from host
"app-web-net": { driver: "bridge" },
},
volumes: {
"app-pgdata": { driver: "local" },
},
});
}
This pattern combines several production-grade primitives:
| Primitive | What it does |
|---|---|
container_name: 'app-db' | Forces a stable container name so docker’s embedded DNS resolves app-db to the postgres container’s IP. See the DNS gotcha below. |
healthcheck: { test: [...], interval, retries, start_period } | Per-service liveness probe. Compose-spec § service.healthcheck shape — Perry’s engine honors it for depends_on gating. |
depends_on: { db: { condition: 'service_healthy' } } | Holds the dependent service back until the dependency reports healthy. Three valid conditions: service_started, service_healthy, service_completed_successfully. |
networks: { ..., internal: true } | Marks the network as internal-only — postgres is unreachable from the host or from sibling stacks. See Networking. |
restart: 'unless-stopped' | The runtime restarts the container after a crash, but not after an explicit docker stop. |
The full ComposeSpec shape is exported from perry/compose as
ComposeSpec, with sub-types Service, ComposeNetwork,
ComposeVolume, Build, and Healthcheck.
Recognised Service fields
The full set Perry’s engine understands (matches compose-spec § services):
interface Service {
image?: string;
container_name?: string;
ports?: string[]; // "host:container[:proto]"
environment?: Record<string, string> | string[]; // map or KEY=VALUE list
labels?: Record<string, string>;
volumes?: string[]; // "host:container[:ro]" or "named:container"
build?: Build; // { context, dockerfile, args, … }
depends_on?: string[] | Record<string, { condition?: string }>;
restart?: "no" | "always" | "on-failure" | "unless-stopped";
entrypoint?: string | string[];
command?: string | string[];
networks?: string[];
healthcheck?: Healthcheck;
user?: string;
working_dir?: string;
read_only?: boolean;
privileged?: boolean;
cap_add?: string[];
cap_drop?: string[];
}
Healthcheck shape
interface Healthcheck {
test?: string[]; // ["CMD", "<cmd>", ...] | ["CMD-SHELL", "<line>"] | ["NONE"]
interval?: string; // Go duration: "5s", "2m", "1h30m"
timeout?: string;
retries?: number;
start_period?: string; // grace period before retries count
disable?: boolean;
}
Environment variable interpolation
Compose’s ${VAR} and ${VAR:-default} placeholders work in TS-side
specs too — Perry expands them against process.env at the FFI
boundary, before the JSON gets parsed:
import { up as upEnv } from "perry/compose";
// Compose YAML interpolation (`${VAR}` / `${VAR:-default}`) is applied
// to TS-side specs at the FFI boundary too — set `process.env` keys
// before calling up() and they'll resolve in the spec values.
async function envInterpolatedStack(): Promise<void> {
await upEnv({
version: "3.8",
services: {
web: {
image: "nginx:${NGINX_VERSION:-alpine}",
ports: ["${WEB_PORT:-8080}:80"],
environment: {
SERVER_NAME: "${WEB_DOMAIN:-localhost}",
},
},
},
});
}
Set the env vars before invoking your binary:
NGINX_VERSION=1.27 WEB_PORT=9000 ./my-stack
Without this, the literal string "${NGINX_VERSION:-alpine}" would
flow through to docker as the image tag and the pull would fail.
Cross-service DNS
Each service registers its service key (db, api, …) as a
network alias automatically — Perry’s engine emits
--network-alias <key> per service per network on every run. So this
just works:
api: {
image: "myapp/api",
environment: {
// ✅ "db" resolves in DNS via the auto-registered service-key alias
DATABASE_URL: "postgres://user:pw@db:5432/app",
},
}
container_name is no longer required for cross-service DNS. You can
still set one if you want a stable name visible to docker ps, but the
service key alone is enough for in-network resolution. Pre-v0.5.372 docs
described a workaround using container_name pinning — that pattern
still works but is now optional.
Tearing down
import { down } from "perry/compose";
async function tearDown(stack: number): Promise<void> {
// Default: containers + networks removed; named volumes preserved
// so a subsequent `up()` against the same spec resumes from
// committed state.
await down(stack);
// Pass `volumes: true` to also drop named volumes — DESTROYS DATA.
// Useful for test teardown or for a "rip and replace" redeploy.
await down(stack, { volumes: true });
}
down(handle) removes containers and networks, and preserves named
volumes by default. Pass { volumes: true } to also drop the volumes
(destroys committed data — use only for “rip and replace” redeploy or
test cleanup).
down option | Type | Default | Effect |
|---|---|---|---|
volumes | boolean | false | Also remove named volumes after containers + networks. |
removeOrphans | boolean | false | Remove containers labelled with this stack’s project but not in the current spec. |
Status / logs / exec
import {
ps,
logs as composeLogs,
exec as composeExec,
config,
start,
stop,
restart,
} from "perry/compose";
async function manageStack(stack: number): Promise<void> {
// Status of every service in the stack (returns a registry
// handle to a ContainerInfo[]; user-side array materialisation
// is a follow-up ergonomics task).
const statusHandle = await ps(stack);
console.log(statusHandle);
// Aggregated logs from one or all services.
await composeLogs(stack, { service: "db", tail: 200 });
// Exec a command inside a service's container by service KEY
// (not container name) — the engine resolves the service to its
// running container internally.
await composeExec(stack, "db", ["pg_isready"]);
// Resolved YAML the engine actually used (post-interpolation).
const yaml = await config(stack);
console.log(yaml);
// Stop / start / restart by service key. `services: []` (or
// omitted) targets every service in the stack.
await stop(stack, ["api"]);
await start(stack, ["api"]);
await restart(stack, []);
}
Like perry/container.{logs, exec}, the compose logs and exec
return registry-id handles for the ContainerLogs array. Treat them as
opaque for now; user-side materialisation is a planned ergonomics
task.
| Function | Signature |
|---|---|
ps(handle) | (handle) → Promise<ContainerInfo[]> |
logs(handle, opts?) | (handle, { service?, tail? }) → Promise<ContainerLogs> |
exec(handle, service, cmd[]) | (handle, service, cmd[]) → Promise<ContainerLogs> |
config(handle) | (handle) → Promise<string> (resolved YAML) |
start(handle, services?) | (handle, services?: string[]) → Promise<void> |
stop(handle, services?) | (handle, services?: string[]) → Promise<void> |
restart(handle, services?) | (handle, services?: string[]) → Promise<void> |
down(handle, opts?) | (handle, { volumes?, removeOrphans? }) → Promise<void> |
exec targets a service by its service key (e.g. 'db', not the
container name) — the engine resolves the key to its tracked container
name internally.
Idempotency
up() is idempotent: if a service is already running with a matching
configuration, it’s left alone; if it exists but is stopped, it’s
started; only when it doesn’t exist at all is it created from
scratch. This makes “redeploy” a no-op-or-restart operation rather
than a tear-down-and-recreate.
⚠️ Idempotency works at the service granularity, not field-level. If you change the spec (e.g. update an image tag), you’ll want
down(handle, { volumes: false })followed byup(newSpec)so the old containers are replaced with the new image.
Waiting for readiness
up() returns as soon as the engine has started every service —
not when each service is ready. To block until the stack is serving:
- Use the
healthcheckblock on the service (built-in, runtime handles it). Combined withdepends_on: { svc: { condition: 'service_healthy' } }, dependent services wait for the dependency to report healthy. - Run an explicit probe loop in your code. The
Forgejo example does this for both
postgres (
pg_isready) and Forgejo (/api/healthzover HTTP), each with its own timeout budget.
Errors and rollback
If any service fails to start, the engine rolls back the entire stack:
every container created during this up() call is stopped + removed,
every network created is removed, and (subject to the standard
session_volumes semantics) created volumes are removed too. The
returned Promise rejects with a ServiceStartupFailed containing the
failing service name and the underlying backend error.
try {
const stack = await up({ /* … */ });
} catch (err: any) {
// err.message is "Service '<name>' failed to start: <reason>"
console.error(err);
process.exit(1);
}
See also
- Networking — networks, ports, and the DNS gotcha.
- Volumes — preserving data across
down(). - Production patterns — case study with the Forgejo example.
- Security — image verification and capability isolation.