oxid/ docs

// 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:

#SourceWhy it ranks there
1oxid.tomlAn explicit answer from the project. Nothing overrides it.
2docker-compose.ymlA service declaring build: already names its context and Dockerfile.
3A committed DockerfileSomeone made a decision. It is never second-guessed.
4Stack detectionOnly 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.

RuntimeDetected as
Nodenestjs · nextjs · nuxt · sveltekit · astro · remix · spa (Vite/CRA/Angular) · node-server
Gofiber · gin · echo · go-server
Pythonfastapi · django · flask
Rustaxum · actix · rust-server
PHPlaravel · symfony
Rubyrails
JVMspring-boot (Maven and Gradle)
.NETaspnet
Statica 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:

StackGenerated image
Go24.5 MB
SPA / static94.5 MB
Rust136 MB
FastAPI209 MB
NestJS215 MB
Express246 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 target is a mount, so COPY --from=build /src/target/… finds nothing. The mount is not part of any layer; the binary has to be copied out inside the same RUN. It is also sharing=locked, because cargo takes its own lock on a target directory and concurrent branch builds would queue anyway.
  • pip install --no-cache-dir is 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.

RuleWhy
A member builds from the repository root, not its own directoryIts 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 treeCopying 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 serviceA 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 ci refuses to run without a lockfile — and plenty of repositories do not commit one.
  • go build -o app ./... breaks on any module with more than one main.
  • The Rust stage copied target/release/app for a binary Cargo names after the package.
  • Remix's build output is a request handler rather than a server, so node on 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_FOUND when 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.