Skip to content
Docs menu / Dockerfiles

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:

apps/api/docker.ts
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.

  • target is the app’s workspace directory, relative to the monorepo root.
  • runner describes the production stage. workdir, copy, and cmd are required. Setting production: true adds a stage that installs production dependencies only (see the table below).
  • dev is optional and describes a single-stage development image. cmd is required; env, expose, and workdir are optional. Without a dev block only Dockerfile is emitted, not Dockerfile.dev.
  • packageManager, runtime, build, and sharedRootFiles override what is otherwise detected. Their defaults are described further down.

Optional fields of runner:

FieldMeaning
productionAdds 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)
envEnvironment variables baked into the image
exposePort number or array of ports
entrypointENTRYPOINT line
userWhich user the process runs as
healthcheckHEALTHCHECK line
platformTarget platform
baseImageReplace the default runtime base image with { image, tag }
removePathsPaths 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.*:

FamilyConstructors
Docker.copybuilderArtifact({ src, dst, chown? }), workspaceSource(name), workspaceSourceAll(), path({ src, dst, from?, chown? })
Docker.runtimebun({ alpine? }), node({ alpine? })
Docker.pmbun(), npm(), pnpm(), yarn({ variant? })
Docker.buildscript(name), command(argv), none()
Docker.healthcheckhttpGet({ path, port, interval?, timeout?, retries?, startPeriod? }), command({ argv, ... })
Docker.usernonRoot({ uid?, gid?, name? }), root()
Docker.platformlinuxAmd64(), 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 for workspace:* links and export conditions to resolve, and the target’s own source is needed when the command runs from source, for example bun 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 with builderArtifact.
  • Docker.copy.workspaceSource("@example/env-contracts") copies one workspace package. Naming a package that is not in the closure fails with WorkspaceSourceUnknown.
  • Docker.copy.builderArtifact({ src, dst }) copies a build output such as dist from the builder stage. Both paths are relative to the target directory.
  • Docker.copy.path({ src, dst, from? }) is a raw COPY for 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:

FieldDefault
packageManagerThe packageManager field of the root package.json, then the lockfile present. UnsupportedPm if neither settles it.
runtimeBun when the package manager is Bun, Node otherwise
buildDocker.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 workspace package.json plus the lockfile and installs with scripts disabled.
  • builder: copies node_modules from deps, copies closure sources, runs the build atom.
  • prod-deps (optional): rewrites workspaces to the closure, drops devDependencies, installs with --production.
  • runner: creates a non-root user (app, uid/gid 1001 unless user says otherwise), sets ENV (with NODE_ENV=production unless you set it), EXPOSE, copies the declared artifacts with --chown, then HEALTHCHECK, optional ENTRYPOINT, USER, and CMD. A multi-platform value is rejected here with PlatformMultiUnsupported, because a single FROM line 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

  1. Preview to stdout.

    Terminal window
    konfig docker preview apps/api # both files
    konfig docker preview apps/api --prod-only

    --dev-only is the counterpart. The target argument is a workspace directory relative to the current working directory.

  2. Write Dockerfile and Dockerfile.dev next to the target. --out-dir places them elsewhere, --prod-only / --dev-only writes one of the two, --force replaces unmanaged files.

    Terminal window
    konfig docker write apps/api
  3. Check for drift in CI. The command exits non-zero if the on-disk files differ from what the spec would emit. --format is summary, detail, or json, default summary.

    Terminal window
    konfig docker diff apps/api --format detail
  4. Build with the monorepo root as the context. Every COPY path is relative to the root, so a build from apps/api fails.

    Terminal window
    docker build -f apps/api/Dockerfile -t ghcr.io/example/api:$TAG .

Next: push the tag and record it with Image promotion.