docker-ts-iac
v0.1.0
Published
Imperative Docker infrastructure-as-code in TypeScript: define networks, volumes, services and stacks as typed objects and run them through the Docker Compose CLI.
Maintainers
Readme
docker-ts-iac
Imperative Infrastructure-as-Code for Docker: define networks, volumes, services and stacks as typed TypeScript objects, then run them through real Docker Compose commands. A class-based alternative to hand-written compose files where the definitions live next to the logic that uses them.
Runs on Bun (uses Bun.spawn to drive the docker CLI).
Install
bun add docker-ts-iacQuick start
import { Compose, Network, Service, Volume } from "docker-ts-iac";
const appNetwork = new Network({ name: "app-network" });
const databaseVolume = new Volume({ name: "database-data" });
const database = new Service({
id: "database",
container_name: "app-database",
image: "postgres:17-alpine",
restart: "unless-stopped",
ports: ["5432:5432"],
networks: [appNetwork],
volumes: [databaseVolume.mount("/var/lib/postgresql/data")],
});
await database.buildAndRun();Runnable demo: bun examples/example.ts (dry-run by default).
How it works
Every run method generates a Docker Compose model from the object graph,
serializes it to YAML under /tmp/docker-iac/ and shells out to the
docker compose CLI — so behavior is exactly compose semantics
(healthchecks, depends_on ordering, env interpolation), not a re-implementation:
docker compose -p <project> -f /tmp/docker-iac/<project>.<id>.yml up -d --build --no-deps <id>Key properties:
- Project namespace — all services run under one
-pname (defaultCOMPOSE_PROJECT_NAMEenv var, fallback a sanitized form of the current directory name), so services started in separate invocations behave as if defined in one file. - Explicit resource names — networks/volumes are declared with
name:, keeping them stable across invocations instead of project-prefixed. - External resources are created if missing before any
up. - Relative paths are absolutized against
process.cwd()at call time (build contexts,env_file, bind mounts), so generated files work from anywhere. - depends_on is validated, not auto-started — dependency services are
embedded in the per-service model so compose accepts the file, but only
the target service starts. You control startup order. (
Composestacks do start everything together via oneup, where ordering comes from compose.) - Mutations apply on the next run — config changes take effect when the
model is regenerated; compose detects the changed container config and
recreates automatically, so plain
buildAndRun()is enough after editing. No need for force-recreate unless nothing changed.
Dry-run
Set DOCKER_IAC_DRY_RUN=true (or pass { dryRun: true }) to print the
equivalent docker command without executing anything. The example script
enables this by default.
Network
import { Network } from "docker-ts-iac";
const app = new Network({ name: "app-network" }); // created by compose
const proxy = new Network({ name: "global-proxy", external: true }); // must exist (auto-created if missing)| Config | Type | Notes |
| --------- | --------------- | ------------------------------------------------ |
| name | string | Real network name (declared as name:) |
| driver | NetworkDriver | "bridge" \| "host" \| "none" \| "overlay" \| "macvlan" + custom strings |
| external| boolean | Expected to pre-exist; created if missing |
Volumes and mounts
import { Volume } from "docker-ts-iac";
const data = new Volume({ name: "database-data" });
data.mount("/var/lib/postgresql/data"); // named volume
data.mount("/var/lib/postgresql/data", "ro"); // with mode
Volume.bind("./Caddyfile", "/etc/caddy/Caddyfile", "ro"); // inline host bindMount entries accept Volume | string sources; relative bind paths are
resolved at generation time. MountMode is "ro" | "rw".
Service
new Service(config: ServiceConfig) — see ServiceConfig for the full
field list (image, build, container_name, restart, command,
env_file, environment, ports, networks, volumes, depends_on,
healthcheck, deploy, ulimits, stop_signal, stop_grace_period).
Enum-ish fields are typed unions with a string escape hatch:
RestartPolicy ("no" | "always" | "unless-stopped" | "on-failure" | …),
StopSignal, NetworkDriver, VolumeDriver.
Lifecycle
All accept { projectName?, dryRun? }.
| Method | Command |
| ---------------------------------------- | ------------------------------------------ |
| buildAndRun() | up -d --build |
| runForceRecreate() | up -d --build --force-recreate |
| runNoRecreate() | up -d --build --no-recreate |
| pull() | pull |
| restart() | restart |
| stop() | stop |
| pause() / resume() | pause / unpause |
| remove() | rm -sf (stop + remove container) |
| logs({ follow?, tail? }) | logs [-f] [--tail N] |
| exec(["psql", "-U", "postgres"]) | exec <id> … (interactive stdio) |
Mutations (chainable, applied on the next run)
database.exposePorts(["5432:5432"]); // duplicates ignored
database.unexposePorts(); // no args = un-publish all
server.addNetworks(proxyNet).removeNetworks("monitoring");
db.addVolumes(data.mount("/data")).removeVolumes("/data"); // remounting a target replaces it
server.addDependsOn([redis]).removeDependsOn("redis");
api.setEnvironment({ TZ: "UTC" }).unsetEnvironment(["DEBUG"]);
api.setImage("ghcr.io/me/api:v2").setCommand("bun start")
.setContainerName("api").setRestart("unless-stopped")
.setHealthcheck({...}).setBuild({...}).setStopSignal("SIGINT");Zero-downtime temporary exposure
await database.withExposedPorts(
["5432:5432"],
async () => {
// port reachable at 127.0.0.1:5432 here
},
{ projectName: "app", hostIp: "127.0.0.1" },
);Publishes ports only for the duration of the callback without touching the
running container: a throwaway alpine/socat sidecar on the service's
network forwards host traffic, then is removed in finally (even on throw).
Already-published ports are skipped; UDP is rejected (socat forwards TCP
only). This avoids the container recreation (and its downtime) that a
permanent port change requires.
Compose (stacks)
A Compose groups services into one runnable unit executed via a single
docker compose up — member depends_on ordering is honored by compose
itself, so no manual sequencing:
import { Compose } from "docker-ts-iac";
const stack = new Compose({
name: "app", // project namespace (docker compose -p)
services: [database, redis, server],
});
await stack.buildAndRun(); // up -d --build (everything)
await stack.stop(); // stop all
await stack.down({ removeVolumes: true }); // down [-v]; externals/binds untouchedversion is accepted for legacy tooling but obsolete in Compose v2
(emits a warning and is ignored).
Options reference
| Option | Where | Meaning |
| --------------- | ------------------------ | ---------------------------------------------------- |
| projectName | RunOptions (everywhere)| Overrides the compose project namespace |
| dryRun | RunOptions | Print instead of execute |
| removeVolumes | DownOptions (down) | Also delete named volumes |
| hostIp | ExposeOptions | Bind address for temporary forwarders (default loopback) |
| follow,tail | LogsOptions | Log streaming/truncation |
Environment: DOCKER_IAC_DRY_RUN=true, COMPOSE_PROJECT_NAME.
Development
bun install # install dependencies
npm run build # bundle dist/ (ESM + CJS + types)
npm run typecheck # strict type check
npm run example # dry-run walkthrough
npm publish # runs typecheck + build first