// stacks
Deploying a repository that has no Dockerfile
Refusing a repository without a Dockerfile asks every team that wants
preview environments to become Docker authors first. Oxid instead reads what a
repository already says about itself — package.json,
go.mod, pyproject.toml, Cargo.toml,
composer.json, Gemfile, pom.xml,
*.csproj — and generates an image for it.
Nothing to enable and nothing to configure. Push a branch; if the repository has no build instructions of its own, detection supplies them.
What wins over what
parse_project resolves build instructions in a fixed order, and
detection is last:
| # | Source | Why it ranks there |
|---|---|---|
| 1 | oxid.toml | An explicit answer from the project. Nothing overrides it. |
| 2 | docker-compose.yml | A service declaring build: already names its context and Dockerfile. |
| 3 | A committed Dockerfile | Someone made a decision. It is never second-guessed. |
| 4 | Stack detection | Only when the repository said nothing at all. |
Two consequences worth knowing. The generated Dockerfile is written into the
private build-context copy, never the checkout — a developer's
git status stays clean, and committing their own Dockerfile takes over
immediately with no state to clear. And detection returns nothing rather
than guessing: an unrecognised repository gets the same “write a
Dockerfile” error it always did, because a generated build that dies halfway
through is worse than an honest refusal.
What is recognised
Nine runtimes, twenty-three frameworks. The wire name in the table is exactly what
the API returns, the dashboard tag shows and oxid ps prints — one
spelling everywhere.
| Runtime | Detected as |
|---|---|
| Node | nestjs · nextjs · nuxt · sveltekit · astro · remix · spa (Vite/CRA/Angular) · node-server |
| Go | fiber · gin · echo · go-server |
| Python | fastapi · django · flask |
| Rust | axum · actix · rust-server |
| PHP | laravel · symfony |
| Ruby | rails |
| JVM | spring-boot (Maven and Gradle) |
| .NET | aspnet |
| Static | a directory of HTML and assets |
Detection order inside that set is load-bearing and pinned by tests. Node runs
first, so a service whose docs tooling has a Gemfile still deploys as
Node. The meta-frameworks run before the generic SPA rule, since a Nuxt app also
has vite. Static runs last, because almost every repository has an
index.html somewhere. Astro is a SPA until @astrojs/node
makes it a server — the two need entirely different images.
The detected stack is stored on the project and surfaced as a tag in the dashboard
and a column in oxid ps. Empty is the normal case for a repository that
answered for itself.
What the generated images look like
Multi-stage, with the toolchain left behind in the build stage. Measured on real repositories, one per stack, deployed through Docker:
| Stack | Generated image |
|---|---|
| Go | 24.5 MB |
| SPA / static | 94.5 MB |
| Rust | 136 MB |
| FastAPI | 209 MB |
| NestJS | 215 MB |
| Express | 246 MB |
For scale: the hand-written single-stage Dockerfile the Nest service shipped with produced 1.63 GB against the generated 215 MB.
Build speed — cache mounts
Every generated Dockerfile mounts its ecosystem's download cache with
RUN --mount=type=cache: the npm/pnpm/yarn/bun stores,
GOMODCACHE and GOCACHE, cargo's registry and
target, pip's cache. A layer cache dies the moment a lockfile changes;
a cache mount is not part of a layer and is shared between every branch of the
project.
# one-line change to an Axum service, rebuilt:
before 17s
after 2s
Two things about this are easy to get wrong, and both are worth knowing if you write your own Dockerfile:
- Cargo's
targetis a mount, soCOPY --from=build /src/target/…finds nothing. The mount is not part of any layer; the binary has to be copied out inside the sameRUN. It is alsosharing=locked, because cargo takes its own lock on a target directory and concurrent branch builds would queue anyway. pip install --no-cache-diris right without BuildKit and wrong with it. The cache is no longer in a layer, so the flag only discards work between builds.
Base images are fetched ahead of time. prewarm_base_images pulls what a
detected stack will need in a detached task at registration — minutes to hours
before the first push — so the first deploy does not open with a download.
Best-effort by contract: only a detected stack's images are prewarmed (a
project with its own Dockerfile could be built on anything), and a failure is logged
at debug, since the build pulls the image itself anyway.
Monorepos and several services per repository
detect_monorepo recognises pnpm workspaces, a workspaces
array in the root package.json (npm/yarn/bun) and lerna.json,
reporting Turborepo or Nx on top when present.
A member counts as deployable if it has a recognised framework,
depends on something that listens (Express, Fastify, Hono, …) or declares a
start script. Anything else is a library other packages import, and
listing it would send an operator to register something with nothing to serve.
| Rule | Why |
|---|---|
| A member builds from the repository root, not its own directory | Its dependencies include siblings, and the lockfile resolving them is at the root. When [build].context names a member, the Docker context switches to . and the generated Dockerfile installs at the root, filtered to that package. |
| The runtime stage carries the whole built tree | Copying node_modules plus the one package looks tighter and produces an image that starts and dies on MODULE_NOT_FOUND: a workspace links siblings in as symlinks pointing back at packages/*. |
| Zero-config points at the first deployable service | A monorepo root usually builds nothing. The dashboard lists every service with the active one marked, so changing it is a [build].context edit rather than a guess. |
One repository, several projects
What is unique is not a repository but a repository plus the part being built. Register the same repo once per service:
oxid up main --repo https://github.com/you/monorepo.git # then point each project at its own workspace member via oxid.toml: # apps/api/oxid.toml [build] context = "apps/api"
A webhook push deploys every project registered against that repository. Oxid cannot know which packages a commit touched without building the workspace's dependency graph, and guessing wrong leaves a service silently running stale code. Symmetrically, deleting a branch destroys every service's environment for it — otherwise a monorepo leaks one environment per service per deleted branch.
Workspace globs are deliberately not parsed. “Has a package.json”
reaches the same answer without a glob language, and the manifest walk is bounded to
the root plus one level inside apps/, packages/,
services/ and libs/.
Taking over from detection
Any of these makes detection step aside, permanently and with no state to clear:
# 1. commit a Dockerfile — that is the whole procedure # 2. or name the build explicitly in oxid.toml [build] dockerfile = "docker/Dockerfile.preview" context = "." [routing] port = 3000 # the port your app listens on
Per-deploy config is re-read from the commit, so [build],
[routing].port and [dependencies] can change on a branch
and take effect on that branch's next push. The base domain and the idle/lifetime
policy stay with the project instead, because those are operator decisions owned by
oxid configure.
How these are verified
Every stack in the table is checked by building and serving a real repository through Docker, not by asserting on generated text. That practice has found a defect in almost every stack added, none of which the unit tests could see:
npm cirefuses to run without a lockfile — and plenty of repositories do not commit one.go build -o app ./...breaks on any module with more than onemain.- The Rust stage copied
target/release/appfor a binary Cargo names after the package. - Remix's build output is a request handler rather than a server, so
nodeon it exits silently. - pnpm 10 refuses to finish an install whose dependencies have skipped build scripts.
- Next.js has no
public/unless someone made one. - A workspace member's image starts and dies on
MODULE_NOT_FOUNDwhen siblings are not carried along.
Two spellings were caught by tests rather than users: Spring Boot is
spring-boot-* in Maven and org.springframework.boot in
Gradle, and a .csproj names the assembly dotnet publish
produces — so the file's own name is what the runtime stage must run.