Docs
Builds without a Dockerfile
What is detected for Node, Bun, Python and Go, and how to read the Dockerfile before it ships.
A plain Node, Bun, Python or Go project deploys with keel deploy and nothing else: when the project has no Dockerfile, Keel detects the runtime, renders a multi-stage Dockerfile and builds it. The same detection runs on the CLI local build path (keel deploy), on the platform git build path (LocalDockerImageBuilder on the single host, the CodeBuild project on Fargate) and in keel init --dockerfile, so a project builds the same way everywhere. The code lives in packages/buildpacks (@keel/buildpacks).
What happens on keel deploy
build.dockerfileinkeel.yamlnames a file: it must exist and it is used as is.- Otherwise a
Dockerfileat the root of the build context is used as is. - Otherwise the runtime is detected and a Dockerfile is rendered into a temporary directory (
keel-build-*/Dockerfile.keel), with aDockerfile.keel.dockerignorenext to it when the project has no.dockerignore. Nothing is written into the project;docker build --filepoints at the temporary file and the directory is removed after the push. The detection summary and the Dockerfile are printed on stderr. keel deploy --print-dockerfileprints the Dockerfile that step 2 or 3 would use and exits without deploying (--jsonadds the detection).keel init --dockerfilewrites it into the project asDockerfile(and.dockerignorewhen missing), after whichkeel deployuses that file and generation stops.
On the platform path the source binding's Dockerfile path is now nullable; null (the default, an empty field in the dashboard form) means the same three steps run on the checkout, and keel.yaml in the repository supplies the build: overrides and the ingress port. The build log starts with the detection summary and the Dockerfile, and ImageBuild.detectedRuntime (for example node 22, python 3.12 (fastapi), go 1.23) is shown on the build in the dashboard, in keel deploy --json and on GET /v1/builds/{id}.
Detection rules
The project is one runtime. Two markers of different runtimes (say package.json and go.mod) are refused with ambiguous_runtime until build.runtime picks one. Every refusal names the file or setting that fixes it.
Node and Bun
Marker: package.json.
| Decision | Rule |
|---|---|
| Package manager | build.packageManager, else the packageManager field of package.json, else the lockfile (pnpm-lock.yaml, yarn.lock, package-lock.json or npm-shrinkwrap.json, bun.lock or bun.lockb). Several lockfiles without a packageManager field are refused. No lockfile means npm install with a warning. |
| Runtime | bun when the package manager is Bun (image oven/bun:1, user bun), else node (image node:<major>-slim, user node). build.runtime: node with a Bun lockfile installs Bun on the Node image for the install (npm install -g bun@<version or 1>); build.runtime: bun with another package manager is refused, the Bun image has no npm, pnpm or yarn. |
| Node version | build.nodeVersion, else the newest of 24, 22, 20, 18 that satisfies engines.node, else .nvmrc or .node-version (lts/* and the LTS codenames are understood), else 22. |
| Build step | build.buildCommand (null disables it), else <pm> run build when scripts.build exists. With a build step the image installs dev dependencies, builds, then prunes them (npm prune --omit=dev, pnpm prune --prod, yarn install --production, a fresh bun install --production). Yarn Berry cannot prune and keeps them. |
| Start command | build.startCommand, else node server.js for a Next.js standalone build, else scripts.start (npm start, pnpm run start, ...), else node <main> when main names an existing file (a main with spaces or shell characters is refused), else the web: line of a Procfile, else node_modules/.bin/next start for Next.js. Nothing found is refused with no_start_command. |
| Next.js | A next dependency. With output: "standalone" in next.config.* the runtime stage copies .next/standalone, .next/static and public and runs node server.js with HOSTNAME=0.0.0.0. Without it the whole pruned tree ships and a warning suggests standalone. |
| Install files | package.json, the lockfile and any of .npmrc, .yarnrc, .yarnrc.yml, .pnpmfile.cjs, pnpm-workspace.yaml are copied before the install so the dependency layer caches. pnpm is installed with npm install -g pnpm@<version from packageManager or 10>; Yarn Berry is activated through corepack with COREPACK_HOME=/opt/corepack so the runtime stage does not need the network. |
Python
Marker: pyproject.toml, requirements.txt (a Pipfile alone is refused with the export command to run).
| Decision | Rule |
|---|---|
| Package manager | build.packageManager, else uv (uv.lock or [tool.uv]), else poetry (poetry.lock or [tool.poetry]), else pip with requirements.txt (files it pulls in with -r or -c, up to 20, are copied into their directories before the install), else pip install . from pyproject.toml (unlocked, with a warning). uv and poetry both configured is refused. |
| Python version | build.pythonVersion, else .python-version (3.12, 3.12.4, cpython@3.12), else the newest of 3.13 to 3.9 that satisfies requires-python or the poetry python constraint, else 3.12. Image python:<major.minor>-slim, dependencies in /opt/venv, user app. |
| Framework | django when it is a dependency and manage.py exists, else fastapi, else flask. Dependencies are read from requirements.txt, [project].dependencies and [tool.poetry.dependencies]. |
| Start command | build.startCommand, else the Procfile web: line, else per framework: FastAPI uvicorn <module>:<app> --host 0.0.0.0 --port $PORT (needs uvicorn or fastapi[standard]), Flask gunicorn <module>:<app> --bind 0.0.0.0:$PORT (needs gunicorn), Django gunicorn <package>.wsgi:application --bind 0.0.0.0:$PORT (needs gunicorn). The module is the one file up to two directories deep, with an importable path (every segment a Python identifier), that assigns FastAPI( or Flask( at the top level (main.py, app.py, app/main.py, src/main.py win ties; a src/ layout adds --app-dir src or --chdir src); several are refused with ambiguous_start. A missing server dependency is refused with the dependency to add. Without a framework, python main.py or python app.py. |
| Build step | Only build.buildCommand. Django collectstatic is not run for you. |
Go
Marker: go.mod.
| Decision | Rule |
|---|---|
| Go version | build.goVersion, else the toolchain directive, else the go directive, else 1.23. Build image golang:<major.minor>. |
| Main package | package main at the module root, else exactly one cmd/<name> directory (names are letters, digits, dot, dash and underscore; others are ignored); several are refused with ambiguous_start and a build.buildCommand suggestion. |
| Build | CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server <package>. build.buildCommand replaces the whole line and must write /out/server. |
| Runtime | gcr.io/distroless/static-debian12:nonroot with the binary at /app/server, USER nonroot. There is no shell: build.startCommand and Procfile commands must be plain (no &&, pipes or $VAR). Templates and other files read at runtime are not copied; embed them or write a Dockerfile. cgo dependencies need a Dockerfile. |
Static sites
index.html without any of the markers above is not a runtime: keel deploy refuses and points at keel sites.
Overrides in keel.yaml
build:
runtime: node # node | bun | python | go
packageManager: pnpm # npm | pnpm | yarn | bun | pip | uv | poetry
buildCommand: null # a shell string, or null to skip the build step
startCommand: node dist/server.js
nodeVersion: 22 # a Node major, 18 or newer
pythonVersion: "3.11" # quote it so YAML keeps 3.10
goVersion: "1.22"
ingress:
port: 3000 # the image sets ENV PORT and EXPOSE to this, default 8080
build.dockerfile must be a relative path inside the project made of letters, digits, dot, dash, underscore and slash, with no .. segment (the same rule as the API's dockerfilePath); build.context and build.platform keep their meaning (The CLI). buildCommand and startCommand are one line each without control characters. The schema is buildOverridesSchema in @keel/buildpacks, embedded in the CLI's keel.yaml parser; the platform reads the same section from the repository's keel.yaml through readBuildSettings.
Generated Dockerfile properties
- Multi-stage: dependencies and the build in one stage, a clean runtime stage that copies only the result.
NODE_ENV=production,PYTHONUNBUFFERED=1,PORT=<port>andEXPOSE <port>are set in the runtime stage. - Never root:
node,bun, a dedicatedappuser for Python,nonrooton distroless for Go. Files are copied with--chown. - Base images are pinned by major (Node, Bun, Go) or major.minor (Python) tag. Digest pinning is a follow-up: the platform would have to refresh digests on a schedule, and a stale digest is worse than a floating patch tag.
- No secrets are baked: nothing reads
.env(it is in the synthesized.dockerignorewith.git,node_modules,.venv, caches and, when a build step regenerates them,dist,buildand.next), and the platform injects secrets at run time. - Lifecycle scripts run:
npm ci,pnpm install,yarn installandbun installexecute thepostinstallscripts of the lockfile's packages, as they would on a laptop.--ignore-scriptsbreaks packages that compile or download at install time (esbuild, sharp, prisma), so Keel does not add it. The install runs inside the build container of the customer's own build, not on shared infrastructure, and the resulting image runs as a non root user under the platform egress policy. Audit your lockfile if this matters to you. CMDis exec form whenever the command is plain; commands with$PORTor quotes run throughsh -c "exec ..."so the server is still PID 1 and receives SIGTERM.- Every string that comes from the project (
buildCommand,startCommand, aProcfileline, acmd/<name>directory) is rendered as a JSON exec-formRUN ["sh","-c",...]orCMD [...], never spliced into a shell-form instruction, so no value from the repository can add Dockerfile instructions. File names that would need quoting in a command are refused or skipped instead of quoted.
The platform build path
LocalDockerImageBuilder(single host,IMAGE_BUILDER=local) callsprepareBuildContexton the checkout, writesDockerfile.keeland the.dockerignoreinto it for the build and removes them afterwards.- The CodeBuild buildspec (
infra/terraform/modules/build/buildspec.yml) downloads the bundled CLIkeel-buildpacks.mjsfrom the build tools bucket (keel-build-tools-<environment>, uploaded by Terraform frompackages/buildpacks/dist/keel-buildpacks.mjs, so runpnpm --filter @keel/buildpacks buildbeforeterraform plan) and runskeel-buildpacks prepare /tmp/source [--dockerfile <path>]. It prints the detection and the Dockerfile to the build log, writes the files into the checkout and answers{"dockerfile": ..., "runtime": ...}; the buildspec exportsDETECTED_RUNTIMEnext toIMAGE_DIGEST. keel-buildpacks print <dir>prints the Dockerfile without writing; exit code 3 means detection refused, with the reason on stderr.
Examples
Express with npm, scripts.start: node server.js, engines.node: ">=20":
FROM node:24-slim AS base
WORKDIR /app
FROM base AS build
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
FROM base
ENV NODE_ENV=production \
PORT=8080
COPY --from=build --chown=node:node /app ./
USER node
EXPOSE 8080
CMD ["npm","start"]
FastAPI with uv (pyproject.toml, uv.lock, main.py):
FROM python:3.12-slim AS build
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
VIRTUAL_ENV=/opt/venv \
PATH=/opt/venv/bin:$PATH
WORKDIR /app
RUN python -m venv /opt/venv
RUN pip install --no-cache-dir "uv<1"
ENV UV_PROJECT_ENVIRONMENT=/opt/venv \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY . .
RUN uv sync --frozen --no-dev
FROM python:3.12-slim
ENV VIRTUAL_ENV=/opt/venv \
PATH=/opt/venv/bin:$PATH \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PORT=8080
WORKDIR /app
RUN groupadd --system app \
&& useradd --system --gid app --create-home --home-dir /home/app app
COPY --from=build --chown=app:app /opt/venv /opt/venv
COPY --from=build --chown=app:app /app /app
USER app
EXPOSE 8080
CMD ["sh","-c","exec uvicorn main:app --host 0.0.0.0 --port $PORT"]
Go with main.go at the module root:
FROM golang:1.23 AS build
WORKDIR /src
COPY go.* ./
RUN go mod download
COPY . .
ENV CGO_ENABLED=0 \
GOOS=linux
RUN go build -trimpath -ldflags="-s -w" -o /out/server .
FROM gcr.io/distroless/static-debian12:nonroot
ENV PORT=8080
WORKDIR /app
COPY --from=build /out/server /app/server
USER nonroot:nonroot
EXPOSE 8080
CMD ["/app/server"]
Tests
pnpm --filter @keel/buildpacks test runs the fixture projects under packages/buildpacks/test/fixtures (every runtime, the overrides, and each refusal). pnpm --filter @keel/buildpacks test:docker builds the Node fixture with the generated Dockerfile from a temporary directory, runs it with PORT=9090, and checks the answer, the non root uid and that the ignored .env is not in the image. packages/infrastructure/src/builds/local-build.test.ts does the same through LocalDockerImageBuilder and checks the log and detectedRuntime.