You only need a Dockerfile when you want to own the base image, build steps,
dependency manager, caching strategy, or multi-stage layout. For simple
Tensorhub endpoints, omit the Dockerfile and declare build hints in
endpoint.toml's [[build.profiles]]; Tensorhub generates the Dockerfile
and satisfies the contract below.
When you do provide a Dockerfile, it is fully yours. Tensorhub does not own this layer.
You satisfy three contract points; everything else is up to you.
-
gen_workeris importable in the runtime environment. Whatever dependency manager you use, the resulting image must havegen-workerinstalled —gen-worker[torch]for PyTorch endpoints, plaingen-workerfor non-PyTorch ones. (Other extras:vision,images,signing.) -
Discovery is baked into the image at
/app/.tensorhub/endpoint.lock. Run discovery duringdocker build:RUN mkdir -p /app/.tensorhub \ && python -m gen_worker.discovery > /app/.tensorhub/endpoint.lockThis serializes every
@endpointobject'sResources, bindings, and payload schemas. The control plane reads the lock from the built image. -
The entrypoint runs
gen_worker.entrypoint.ENTRYPOINT ["python", "-m", "gen_worker.entrypoint"]
The entrypoint reads
endpoint.lock, connects to the orchestrator, and serves invocations.
FROM python:3.12-slim
WORKDIR /app
COPY . /app
RUN pip install -e .
RUN mkdir -p /app/.tensorhub \
&& python -m gen_worker.discovery > /app/.tensorhub/endpoint.lock
ENTRYPOINT ["python", "-m", "gen_worker.entrypoint"]No ARG BASE_IMAGE, no version pass-throughs. The endpoint's pyproject.toml
pins gen-worker and the Dockerfile installs it.
If the Dockerfile only looks like this, prefer Tensorhub's generated-Dockerfile path:
[[build.profiles]]
accelerator = "none"
python = "3.12"Use ARG BASE_IMAGE when your build profile uses managed mode
(declares python / torch / cuda) or explicit mode (declares
base_image). Tensorhub resolves or accepts the base image and passes it as
a build arg.
ARG BASE_IMAGE=<a real pytorch/pytorch tag+digest matching your profile>
FROM ${BASE_IMAGE}
WORKDIR /app
COPY . /app
RUN pip install -e .
RUN mkdir -p /app/.tensorhub \
&& python -m gen_worker.discovery > /app/.tensorhub/endpoint.lock
ENTRYPOINT ["python", "-m", "gen_worker.entrypoint"]Use a common upstream default matching the profile so local builds behave like Tensorhub builds. In production, tensorhub overrides it with the resolved / explicit ref.
Omit ARG BASE_IMAGE entirely when your profile is fully custom
(no python / torch / cuda / base_image declared). Tensorhub does not
inject BASE_IMAGE in that mode — your FROM line is the only source of
truth.
If your profile sets any of python / torch / cuda, tensorhub injects
matching PYTHON_VERSION / TORCH_VERSION / CUDA_VERSION build args.
Consume them when needed:
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
ARG CUDA_VERSION
SHELL ["/bin/bash", "-c"] # ${VAR//.} is a bash substitution; RUN defaults to sh
RUN pip install --extra-index-url https://download.pytorch.org/whl/cu${CUDA_VERSION//.} flash-attnMost endpoints don't need this — the base image already ships the right
version. Add an ARG only when a specific RUN step depends on it.
If you're already in the image, you can read the version directly at build
time without an ARG:
RUN python -c "import torch; print(torch.version.cuda)"One source of truth: the base image.
A single Dockerfile is reused across every build profile. Tensorhub passes the per-profile build args at build time; your Dockerfile branches as needed on the args it cares about.
For most endpoints this is a no-op — the same Dockerfile works for every profile because each profile resolves to a fully-formed base image.
For profiles that need different install steps (e.g. CUDA-specific wheels vs. CPU wheels), branch on a build arg you set yourself:
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
ARG ACCEL=cpu
RUN if [ "$ACCEL" = "cuda" ]; then \
pip install -e .[torch]; \
else \
pip install -e .; \
fiAdd an extra build-profile field convention or use the cuda field's
presence as your signal in the build script that drives docker build.
BUILD_NONCE, DEPS_NONCE, endpoint-specific commit pins — these are your
cache-bust knobs, unrelated to versioning. Layer caching is yours; Tensorhub
imposes no caching strategy.
ARG BUILD_NONCE=2026-04-23-default
RUN echo "build-nonce=${BUILD_NONCE}" \
&& mkdir -p /app/.tensorhub \
&& python -m gen_worker.discovery > /app/.tensorhub/endpoint.lockA Dockerfile that asks BuildKit for a persistent cache directory is rejected at publish:
400 invalid_tarball
buildkit cache mounts are not allowed in org Dockerfiles
Why. The builder is multi-tenant, and such a cache is addressed by an id. The obvious id is a constant — every org copies the same one off a page like this one — and a constant id is one mutable directory shared across tenant builds. Org A seeds a poisoned wheel into it; org B's build mounts the same id and installs it. That is build-time code injection across a tenant boundary: the same shape of trust failure as adopting a foreign compiled graph, one layer down.
Install uncached instead — uv pip install --no-cache. The ordinary layer
cache still covers the common case, and a cold dependency install is a
build-time cost paid once per release, not a per-pod one.
If build speed later justifies a real cache, the answer is a per-org namespaced id the validator enforces (the id must embed the org; the validator rewrites or refuses). Never a shared one — speed does not reopen a cross-tenant channel.
The validator matches the raw bytes of the whole Dockerfile, comments included. Do not paste the banned directive into your file even to explain why it is banned: an explanatory comment is refused exactly like a live instruction. Fail-closed is deliberate here.
AOTInductor is the only lane that host-compiles: it emits a C++ wrapper and
links a real .so, on the machine running the mint. The dynamo/JIT lane does
not — it emits Triton kernels behind a Python wrapper — so an endpoint that
declares no compile= export never needs any of this.
If any of your endpoints DOES declare an AOT export, the image must carry a C++ compiler. The pytorch runtime bases ship a C compiler and no C++ one. Install it yourself: your Dockerfile is author-owned content and the platform never injects layers into it.
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl g++ \
&& rm -rf /var/lib/apt/lists/*g++, recommends-off, and not build-essential — the latter drags ~250 MB
of make/dpkg-dev the wrapper compile never invokes. Measured cost of this layer
on a ~9.2 GB endpoint image: +80 MB.
The platform verifies it rather than establishing it. python -m gen_worker.discovery already runs inside your final image, so it can ask the
question about the image that will actually serve. An image whose endpoints
declare an AOT export and whose PATH holds no C++ compiler fails the build, by
name:
error: aot precondition cxx_toolchain: no C++ compiler on this image, but
['micro-4d', 'micro-diffusion'] declare an AOT export
That refusal costs $0.00, at the build, naming the families. Before the check
existed the same defect cost 336 s of rented L4 time and surfaced as
InvalidCxxCompiler at the link step. A guarantee you can observe beats a
quieter one: the refusal IS the guarantee.
torch's cpp_extension also needs a CUDA root it can discover, and the pytorch
runtime bases ship CUDA as pip wheels without ever creating /usr/local/cuda.
An image with g++ and no CUDA root reaches the link step and dies with
CUDA_HOME environment variable is not set — three separate facts are missing,
and none of them is a compiler.
One line, after your dependency install:
RUN python -m gen_worker.cuda_rootIt composes /usr/local/cuda out of parts the image already ships (the
nvidia/* wheels' headers and libs, the real crt/ headers, and nv/target
from cuda-cccl fetched into a throwaway directory). It writes nothing inside
a pip package, is a no-op when the image already has a CUDA install, and never
fails a build — a CPU image simply has nothing to compose.
You invoke it; the SDK owns whether the recipe is right. That is deliberate: the alternative is twenty lines of shell transcribed into every Dockerfile that needs it, drifting apart the moment a base image changes.
The same build-time gate covers it:
error: aot precondition cuda_root: torch's cpp_extension cannot host-compile on
this image and ['micro-diffusion'] declare an AOT export. Missing: …
Everything above is a step Tensorhub takes for you when you don't ship a Dockerfile, and cannot take when you do — your file is yours, and the platform never injects layers into it. That asymmetry has produced the same bug four times: a step the generated Dockerfile takes, which a hand-written one skips, which nothing asks for until a rented pod pays for it.
So the steps are enumerated, and you can ask for them:
python -m gen_worker.build_guarantees . # your endpoint source directory
python -m gen_worker.build_guarantees --list # what it checks, and whyIt exits non-zero and names the missing line, where the platform would have refused, and what skipping it costs. Run it in CI for every endpoint that ships its own Dockerfile — it needs no build, no image and no GPU, and it decides in milliseconds what a build otherwise decides in minutes and a pod in dollars.
An endpoint with no Dockerfile is asked for nothing: it takes the generated path, where all of this is already true.
The check is a pre-flight, not the authority. gen_worker.discovery still
verifies the AOT preconditions inside your real image at build time, and
Tensorhub still inspects the built image. This just moves the cheapest failures
onto the diff that caused them.
ARG BASE_IMAGE=<a real pytorch/pytorch tag+digest matching your profile>
FROM ${BASE_IMAGE}
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# The AOT host toolchain. Drop this layer and the cuda_root line below only if
# no endpoint in the image declares a compile export — Tensorhub's own
# generated Dockerfile does both in every image it synthesizes.
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml uv.lock /app/
RUN uv export --no-cache --link-mode copy \
--no-dev --no-hashes --no-sources --no-emit-project --no-emit-local \
-o /tmp/requirements.txt \
&& uv pip install --no-cache --link-mode copy \
--system --break-system-packages --no-deps -r /tmp/requirements.txt
COPY . /app
RUN uv pip install --no-cache --link-mode copy \
--system --break-system-packages --no-deps --no-sources /app \
&& python -m gen_worker.cuda_root \
&& mkdir -p /app/.tensorhub \
&& python -m gen_worker.discovery > /app/.tensorhub/endpoint.lock
ENTRYPOINT ["python", "-m", "gen_worker.entrypoint"]Use this shape for GPU endpoints. For CPU-only endpoints, python:3.12-slim
is still a good common default.