Build configuration
Every project is built from what the platform detects in your repository. When detection gets something wrong — or you'd rather the build be pinned in code than in a dashboard — commit a sahabti.json and it wins.
The file is optional, every field in it is optional, and it is versioned with your code: the build a branch produces is described by that branch.
Where the file goes
At your project's root directory — the one you set when you imported the repository, which is the repository root unless you changed it.
| Root directory | File |
|---|---|
./ | sahabti.json |
apps/web | apps/web/sahabti.json |
Anywhere else and it is not read.
What wins
Three layers, highest first:
sahabti.json— this file.- Build settings — the project's dashboard overrides.
- Auto-detection — what the platform works out on its own.
It is decided per field, not per file: a sahabti.json holding only buildCommand pins the build command and leaves everything else to the layers below. Each deployment's build log names the source of every value it used, so you can always see which layer won.
The fields
{
"framework": "nextjs",
"installCommand": "pnpm install --frozen-lockfile",
"buildCommand": "pnpm run build",
"startCommand": "pnpm run start",
"outputDir": "",
"dockerfilePath": "",
"nodeVersion": "22"
}
| Field | Value |
|---|---|
framework | One of nextjs, nuxt, astro, sveltekit, react-router, vite, node, python, go, static, docker. Skips detection instead of correcting it — if the repository has nothing of that kind in it, the build fails rather than guessing again. |
installCommand | The command that installs dependencies. |
buildCommand | The command that builds your app. |
startCommand | The command that starts it. |
outputDir | The directory to publish, relative to your root directory. |
dockerfilePath | Build this Dockerfile instead of a generated one. |
dockerfileBuildEnv | inject (default) or verbatim — see Dockerfiles. |
nodeVersion | A major version, as a string: "22". Otherwise taken from engines.node, then .nvmrc or .node-version, and 24 if nothing says. |
Commands are a single line each. Paths are relative to your root directory — no leading /, no ...
An empty string means "don't do this at all", which is different from leaving the field out. "installCommand": "" skips installation; omitting installCommand lets the dashboard or detection fill it in.
Examples
Fix just the build command, leave the rest detected:
{ "buildCommand": "pnpm run build:prod" }
Dependencies are vendored, so skip the install step:
{ "installCommand": "" }
Publish a build as a static site — an output directory with no start command means nothing runs, and the directory is served as files:
{ "buildCommand": "npm run build", "startCommand": "", "outputDir": "dist" }
Point Python at the right entry point:
{ "startCommand": "gunicorn app.wsgi:application --bind 0.0.0.0:$PORT" }
Your app must listen on the port in $PORT. Generated builds handle it; a hard-coded port fails the health check that guards every deploy.
A package inside a monorepo usually needs nothing here — set the root directory on the project, and dependencies are installed at the workspace root while the build runs in your package.
What doesn't belong in it
Environment variables are never read from your repository. They live in Settings → Environment, and only there — a config file in git is one push away from being a public secret. Variables reach both the build and the running container from the dashboard.
The repository, branch, root directory and automatic-deploy switch also stay in the dashboard: the platform needs them to find and clone your code before there is any file to read.
Dockerfiles
Setting dockerfilePath, or "framework": "docker" with a Dockerfile at your root directory, builds your own Dockerfile. installCommand, buildCommand, startCommand and outputDir are then ignored — your Dockerfile is the build — and the log says so.
Your environment variables are not build arguments, so an ARG receives nothing from the dashboard. They are mounted as a build secret instead, and each RUN step is prepared to read it, so a build step sees the same variables the running container will. Your committed file is never modified — the preparation happens on a copy.
A step written in a shape that can't be adjusted safely is left exactly as you wrote it and named in the build log. Wire those up yourself:
RUN --mount=type=secret,id=buildenv sh -c '. /run/secrets/buildenv; npm run build'
To keep your Dockerfile untouched, build it as committed:
{ "dockerfilePath": "Dockerfile", "dockerfileBuildEnv": "verbatim" }
Nothing is then added to any step, and no build-time variables are set unless you mount the secret yourself.
When the file is wrong
A sahabti.json you committed is never quietly ignored. Invalid JSON, an unknown field, or a value that doesn't fit fails the deployment at the Detecting framework step, and the build log names the field and the problem. Your running app keeps serving — a failed build never replaces it.
Next steps
- Projects — importing a repository, deploy on push, rollbacks
- Workspaces & roles — who on your team can deploy
- API tokens — trigger and read deployments from a script