cnt
How to write Dockerfile + compose.yaml — the develop/integrate service convention, multi-stage targets, dind helpers. Use when containerizing a project, writing docker compose services, or setting up
SKILL.mdplugins/sayt/skills/cnt
GitHublaunch / integrate — Docker Compose
sayt launch starts a containerized dev environment. sayt integrate runs integration tests in containers. Both use Docker Compose with a specific service convention.
How It Works
sayt launch (launch.nu):
docker compose down -v --timeout 0 --remove-orphans—--force-recreatealone leaves anonymous volumes and orphaned services behind.docker compose up launch --build --force-recreate --remove-orphans --wait— detaches and returns once the stack is ready: 0 when the healthcheck passes (server mode), the container's exit code (CLI mode). Tear down withdocker compose down -v.
--watch swaps --wait for --attach-dependencies --watch: foreground, with file sync from develop.watch. The two never combine because --wait conflicts with the attach flags. Extra args land on compose up.
sayt integrate (integrate.nu):
down -vof the stack a previous run of this project left behind.docker compose up integrate --abort-on-container-failure --exit-code-from integrate --force-recreate --build --renew-anon-volumes --remove-orphans --attach-dependencies— the same stack plus the test runner, with an explicit verdict.- Success cleans up; failure leaves the containers running for
docker compose logsand a manualdocker compose down -v.
launch never bridges a daemon. integrate opens a dind session only for a bake build, --with-testcontainers or --with-host-env, and the socat TCP bridge inside it only for --dind-bridge, --with-buildx or --with-host-env; --dind just hands the runtime compose up the host's DOCKER_HOST. --target <svc,…> picks other services (several at once need --bake), --no-cache rebuilds from scratch, --progress <mode> sets compose output. Extra args go to the axis's primary tool: compose up in compose mode, bake in bake modes. The build axes and capability flags are tabled in reference.md.
The compose.yaml Convention
Two services are required: launch and integrate.
services:
launch:
command: ./gradlew dev -t # your dev command
ports: ["8080:8080"]
build:
network: host
context: ../.. # monorepo root, or "." for standalone
dockerfile: services/myapp/Dockerfile
secrets: [host.env]
target: debug # Dockerfile stage for dev
volumes:
- //var/run/docker.sock:/var/run/docker.sock
entrypoint: [/monorepo/plugins/devserver/dind.sh]
secrets: [host.env]
network_mode: host
integrate:
command: "true" # overridden by Dockerfile CMD
build:
network: host
context: ../..
dockerfile: services/myapp/Dockerfile
secrets: [host.env]
target: integrate # Dockerfile stage for integration tests
secrets:
host.env:
environment: HOST_ENV # injected by sayt's dind helper
The HOST_ENV secret contains Docker credentials, Kubernetes config, and dind connection info.
network_mode: host on launch means services reach each other via localhost, testcontainers talks back to the host Docker daemon, and there are no port-mapping conflicts. On Linux this is native; Docker Desktop emulates it on macOS.
Pinning Base Images
Always pin base images to a multi-platform sha256 digest. Tags drift — the same node:22 can resolve to different bits on different days, and a tag that was multi-arch yesterday can silently lose a platform tomorrow. A digest-pinned manifest list is the only way to guarantee every host (arm64 Macs, amd64 CI, Graviton) pulls the same image.
Fetching a Manifest-List Digest
docker buildx imagetools inspect node:22
Look for Name: docker.io/library/node:22@sha256:<digest>. That top-level digest is the manifest list — the one you want. The per-arch digests in the Manifests: section cover one arch only — do not use those.
Verify it covers the platforms you need:
docker buildx imagetools inspect node:22 --raw | jq '.manifests[] | .platform'
# Expect at least:
# {"architecture":"amd64","os":"linux"}
# {"architecture":"arm64","os":"linux"}
Then pin:
FROM node:22@sha256:d53eaf0d... AS debug
services:
postgres:
image: postgres:16@sha256:bb9ef9d4...
When you bump a tag, re-run imagetools inspect, verify the new digest is still a manifest list covering the same platforms, and update FROM / image: lines in one commit.
Image Choice
- Use official language images —
eclipse-temurin:21,node:22,python:3.13-slim-bookworm,elixir:1.18-slim,ruby:3.3-slim-bookworm,mcr.microsoft.com/dotnet/sdk:10.0. - Avoid niche community images — They may lack multi-arch support. Install tools not in the base (e.g., sbt) in a
RUNstep from the official release URL. - Prefer slim/distroless for production —
-slim-bookwormvariants cut size; add dev packages only as needed.
Dockerfile Multi-Stage Pattern
sayt expects Dockerfiles with at least two targets: debug (used by launch) and integrate (used by integrate, extends debug).
FROM node:22@sha256:... AS debug
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
CMD ["pnpm", "dev"]
FROM debug AS integrate
CMD ["pnpm", "test:int", "--run"]
Canonical Examples
JVM (Gradle):
FROM eclipse-temurin:21@sha256:... AS debug
WORKDIR /app
COPY gradlew gradle.properties settings.gradle.kts build.gradle.kts ./
COPY gradle/ gradle/
RUN ./gradlew dependencies
COPY src/main/ src/main/
RUN ./gradlew assemble
FROM debug AS integrate
COPY src/it/ src/it/
CMD ["./gradlew", "integrationTest"]
Python (uv):
FROM python:3.13-slim-bookworm@sha256:... AS debug
RUN pip install uv
WORKDIR /app
COPY . .
RUN uv sync # install + lockfile verification in one step
CMD ["uv", "run", "flask", "run"]
FROM debug AS integrate
CMD ["uv", "run", "pytest", "-v", "--tb=short"]
Rust:
FROM rust:1-bookworm@sha256:... AS debug
WORKDIR /app
COPY . . # Rust build scripts often read arbitrary files
RUN cargo build --locked
CMD ["cargo", "run", "--locked"]
FROM debug AS integrate
CMD ["cargo", "test", "--locked"]
Patterns By Language Family
Everything not above follows the same recipe: copy dependency manifests, install deps (leveraging Docker layer caching), copy the rest, build, set a debug CMD, then extend with an integrate CMD. The only per-language deltas worth knowing:
| Language | Dep manifest | Install command | Notes |
|---|---|---|---|
| Node (pnpm) | package.json + pnpm-lock.yaml |
corepack enable && pnpm install --frozen-lockfile |
— |
| Node (bun) | package.json + bun.lock* |
bun install --frozen-lockfile |
Use Dockerfile.sayt if devcontainer owns Dockerfile |
| Maven | all pom.xml files + parent poms |
mvn dependency:go-offline -pl <mod> -am -q || true |
-pl <module> -am for multi-module |
| Elixir | mix.exs + mix.lock |
mix local.hex --force && mix local.rebar --force && mix deps.get && mix deps.compile |
Slim needs build-essential + ca-certificates |
| .NET | .sln, Directory.Build.props, global.json, all .csproj |
dotnet restore |
Official multi-arch mcr.microsoft.com/dotnet/sdk:10.0 |
| Scala (sbt) | build.sbt + project/ |
Install sbt via curl tarball; sbt update || true |
Don't use niche sbtscala/scala-sbt images — no arm64 |
| Ruby | Gemfile + all *.gemspec |
bundle install |
Slim needs build-essential libssl-dev libyaml-dev pkg-config |
| C / autotools | — | apt install build-essential autoconf libtool; chain autoreconf -i && ./configure && make -j$(nproc) in one RUN |
git submodule update --init if submodules are vendored |
When adapting an existing project that already has a Dockerfile with its own stage names (shell, dev, test, integration-test, etc.), map target: to the real stage names instead of renaming them, and add privileged: true if the existing tests use BuildKit or testcontainers:
services:
launch:
build: { context: ., dockerfile: Dockerfile, target: shell }
privileged: true
integrate:
build: { context: ., dockerfile: Dockerfile, target: integration-test }
privileged: true
Docker-in-Docker Support
sayt provides dind helpers for scenarios where containers need to talk to Docker (testcontainers, BuildKit):
- A socat container proxies the host's Docker socket over TCP (
DOCKER_HOST_TCP). DOCKER_AUTH_CONFIG,TESTCONTAINERS_HOST_OVERRIDE,KUBECONFIG_DATAand the buildx instance ride the same session, each only when its flag asks.- The session reaches compose as the
HOST_ENVvariable, which thehost.envsecret reads.
This lets testcontainers create sibling containers on the host daemon.
Writing Good compose.yaml for sayt
- Define both
launchandintegrate. These are the services sayt expects. - Use
target:in build. Map to Dockerfile stages, not the default. - Set
context:to the monorepo root. Usually../..from a service dir. - Include the
host.envsecret. Required for dind and credential forwarding. network_mode: hostonlaunch. Simplifies networking.command: "true"onintegrate. Let the Dockerfile CMD handle execution.- Pin every image with a manifest-list sha256. Both
FROMlines andimage:lines in compose.
Current flags
Run sayt help launch and sayt help integrate for current flags.