Dockerfiles
Writing a Dockerfile for one app inside a monorepo is tedious because the image needs the app plus every workspace package it depends on, and that list changes as the code does. @konfig.ts/docker generates the Dockerfile from a short spec instead. You write one docker.ts next to the app; the package works out which workspace packages the app transitively depends on (its closure) and writes the COPY lists for you. It supports Bun, npm, pnpm, and yarn workspaces. It emits files only: no building, pushing, or tagging.
The spec
The spec is a typed object you pass to Docker.app. The type checker validates it as you write it. If you need to validate untyped input, for example a spec loaded from JSON, decodeDockerSpec and decodeDockerSpecSync do the same check at runtime. The konfig docker commands import the default export of docker.ts and reject anything that is not a Docker app with SpecNotADockerApp. The example’s API service:
export default Docker.app({
target: "apps/api",
runner: {
production: true,
workdir: "/app/apps/api",
copy: [Docker.copy.workspaceSourceAll()],
expose: 8080,
cmd: ["bun", "run", "src/main.ts"],
env: {
// per-env values and secrets come from Environment.bind, not here
LOG_LEVEL: "info"
},
healthcheck: {
tag: "HealthcheckHttpGet",
path: "/healthz",
port: 8080,
interval: "15s",
timeout: "3s",
retries: 3
}
},
dev: {
cmd: ["bun", "--watch", "src/main.ts"],
expose: 8080
}
})The spec has four kinds of fields.
targetis the app’s workspace directory, relative to the monorepo root.runnerdescribes the production stage.workdir,copy, andcmdare required. Settingproduction: trueadds a stage that installs production dependencies only (see the table below).devis optional and describes a single-stage development image.cmdis required;env,expose, andworkdirare optional. Without adevblock onlyDockerfileis emitted, notDockerfile.dev.packageManager,runtime,build, andsharedRootFilesoverride what is otherwise detected. Their defaults are described further down.
Optional fields of runner:
| Field | Meaning |
|---|---|
production | Adds a prod-deps stage that trims the root package.json workspaces to the closure and runs bun install --production (or the package manager’s equivalent) |
env | Environment variables baked into the image |
expose | Port number or array of ports |
entrypoint | ENTRYPOINT line |
user | Which user the process runs as |
healthcheck | HEALTHCHECK line |
platform | Target platform |
baseImage | Replace the default runtime base image with { image, tag } |
removePaths | Paths deleted in the runner stage (one rm -rf line per path) |
Secrets do not belong in an image layer, so the schema screens env values with a heuristic: values with an sk_ prefix, values containing a BEGIN PRIVATE KEY header, and base64-looking strings of 40 or more characters that are not plain hex are rejected. A value that looks like a secret fails the decode. Runtime secrets are wired through Environment.bind instead.
Atoms
Each option in the spec is a small tagged value rather than a raw string, so the generator knows what it is copying, which runtime it targets, and so on. You build them with constructors under Docker.*:
| Family | Constructors |
|---|---|
Docker.copy | builderArtifact({ src, dst, chown? }), workspaceSource(name), workspaceSourceAll(), path({ src, dst, from?, chown? }) |
Docker.runtime | bun({ alpine? }), node({ alpine? }) |
Docker.pm | bun(), npm(), pnpm(), yarn({ variant? }) |
Docker.build | script(name), command(argv), none() |
Docker.healthcheck | httpGet({ path, port, interval?, timeout?, retries?, startPeriod? }), command({ argv, ... }) |
Docker.user | nonRoot({ uid?, gid?, name? }), root() |
Docker.platform | linuxAmd64(), linuxArm64(), multi(values) |
The copy atoms decide what lands in the final image, so they deserve a closer look.
Docker.copy.workspaceSourceAll()copies the source of every closure member, including the target itself, from the builder stage. Bun needs this forworkspace:*links and export conditions to resolve, and the target’s own source is needed when the command runs from source, for examplebun run src/main.ts. If your build produces a bundled artifact instead, skip the workspace source atoms for the target and copy the artifact explicitly withbuilderArtifact.Docker.copy.workspaceSource("@example/env-contracts")copies one workspace package. Naming a package that is not in the closure fails withWorkspaceSourceUnknown.Docker.copy.builderArtifact({ src, dst })copies a build output such asdistfrom the builder stage. Both paths are relative to the target directory.Docker.copy.path({ src, dst, from? })is a rawCOPYfor anything else.
Whenever at least one workspace source atom is present, node_modules (root and per-workspace) is copied in as well. It comes from the prod-deps stage when production is set and from the builder stage otherwise. Setting runner.baseImage switches this off: no node_modules is copied automatically and the copy list must include everything the runner needs.
The overrides default as follows:
| Field | Default |
|---|---|
packageManager | The packageManager field of the root package.json, then the lockfile present. UnsupportedPm if neither settles it. |
runtime | Bun when the package manager is Bun, Node otherwise |
build | Docker.build.script("build") when the target has a build script, none() otherwise. Naming a script that does not exist fails with BuildScriptMissing. |
Runtime version from engines
The generator never writes a floating latest tag. The base image version comes from the target’s package.json: engines.bun or engines.node picks the runtime version, and engines.bun, engines.npm, engines.pnpm, or engines.yarn picks the package manager version. The example declares "engines": { "bun": "1.3.5" }, which covers both, so every stage starts with FROM oven/bun:1.3.5-alpine. Alpine images are the default; pass Docker.runtime.bun({ alpine: false }) for the Debian image. A missing engines field is a hard EngineVersionMissing error.
Workspace closure resolution
The closure is the set of workspace packages the target depends on, directly or transitively. It is computed in two steps.
First, findRoot walks up from the target to the nearest directory that has a pnpm-workspace.yaml or a package.json with a workspaces field. If there is none, the error is MonorepoRootNotFound.
Then closureOf follows dependencies and peerDependencies whose version starts with workspace: or link:. It rejects cycles with CircularWorkspaceDep. devDependencies are deliberately excluded, so build-only tooling never lands in the runner.
The closure drives every generated COPY. The deps stage copies every workspace’s package.json so the lockfile resolves, while the builder, prod-deps, and runner stages copy only closure members.
Some root files are copied automatically: the root package.json, the lockfile(s) present, the package manager’s config files (bunfig.toml, .npmrc, pnpm-workspace.yaml, .yarnrc, .yarnrc.yml), and a root patches/ directory. Any other root file the build step needs, such as a shared tsconfig.base.json, goes in sharedRootFiles. Those are copied into the builder and dev stages. Naming one that does not exist fails with SharedRootFileMissing.
Stages of the production Dockerfile
The production Dockerfile is a multi-stage build: base → deps → builder → runner, with prod-deps inserted between builder and runner when runner.production is set.
base:FROM <runtime>:<engines version>[-alpine].deps: copies every workspacepackage.jsonplus the lockfile and installs with scripts disabled.builder: copiesnode_modulesfromdeps, copies closure sources, runs the build atom.prod-deps(optional): rewritesworkspacesto the closure, dropsdevDependencies, installs with--production.runner: creates a non-root user (app, uid/gid 1001 unlessusersays otherwise), setsENV(withNODE_ENV=productionunless you set it),EXPOSE, copies the declared artifacts with--chown, thenHEALTHCHECK, optionalENTRYPOINT,USER, andCMD. A multi-platform value is rejected here withPlatformMultiUnsupported, because a singleFROMline cannot express it.
The dev Dockerfile is base → dev: one install with scripts enabled, closure sources copied in, NODE_ENV=development, the watch-mode command, no user or healthcheck.
Every generated file starts with a three-line header so the tooling can recognise its own output: the marker # konfig-managed: @konfig.ts/docker, # spec: <docker.ts path relative to the monorepo root>, and # hash: sha256:<digest of the rendered body>. konfig docker write refuses to overwrite a file without that marker (DockerWriteRefused) unless you pass --force, so a hand-written Dockerfile is not clobbered by accident.
Commands
-
Preview to stdout.
Terminal window konfig docker preview apps/api # both fileskonfig docker preview apps/api --prod-only--dev-onlyis the counterpart. The target argument is a workspace directory relative to the current working directory. -
Write
DockerfileandDockerfile.devnext to the target.--out-dirplaces them elsewhere,--prod-only/--dev-onlywrites one of the two,--forcereplaces unmanaged files.Terminal window konfig docker write apps/api -
Check for drift in CI. The command exits non-zero if the on-disk files differ from what the spec would emit.
--formatissummary,detail, orjson, defaultsummary.Terminal window konfig docker diff apps/api --format detail -
Build with the monorepo root as the context. Every
COPYpath is relative to the root, so a build fromapps/apifails.Terminal window docker build -f apps/api/Dockerfile -t ghcr.io/example/api:$TAG .
Next: push the tag and record it with Image promotion.