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

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:

PrimitiveWhat 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 optionTypeDefaultEffect
volumesbooleanfalseAlso remove named volumes after containers + networks.
removeOrphansbooleanfalseRemove 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.

FunctionSignature
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 by up(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:

  1. Use the healthcheck block on the service (built-in, runtime handles it). Combined with depends_on: { svc: { condition: 'service_healthy' } }, dependent services wait for the dependency to report healthy.
  2. Run an explicit probe loop in your code. The Forgejo example does this for both postgres (pg_isready) and Forgejo (/api/healthz over 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