Build and start commands
The build block and who wins when it is written twice, the base directory, your own commands, static output, the build cache and how long a build may take.
How Noust installs, builds and starts an application when the commands of its type are not what the project needs: your own commands, a project in a subdirectory of the repository, the directory a static build leaves, and the Node, Python or PHP version it runs on. And the Node frameworks and Python servers Noust now knows by name.
Every type has commands of its own (npm ci, npm run build, gunicorn app:app), and that is
what makes a redeploy and a rollback behave like the first deploy. Nothing here changes an
application that says nothing: the commands of its type apply exactly as before. What you say
replaces what the type would have done, step by step, and what you leave out keeps the type's.
- The build block
- Where it comes from
- Base directory
- Your own commands
- Static output
- Runtimes
- Node frameworks
- Python
- Go, Rust and Ruby
- Java and Kotlin, .NET, Elixir and Deno
- Process types
- Build cache
- How long a build may take
- System packages
- Updating, and what an update does to the unit
- Commands and API
- Limits
- What changes when you upgrade
The rest of this guide is on pages of its own:
The build block
A noust.yaml (or .noust.yaml) at the root of the repository may carry a build: block. Every
key is optional:
build:
base_directory: apps/web # the project lives in this subdirectory
install: pnpm install --frozen-lockfile
build: pnpm --filter web build
start: node dist/server.js --port ${PORT}
publish_directory: dist # static output the web server serves (no start)
runtime: node@22 # node@22, python@3.12, php@8.3
timeout: 3600 # seconds each of install and build may takeThe file is untrusted input, like the rest of the repository, and its schema is closed:
- An unknown key is refused, never ignored: a misspelt
stratwould otherwise be a start command that silently never applies. Every refusal names the field (build.base_directory). - A path is relative to the repository.
.., an absolute path,~, a backslash and names with spaces or quotes are refused, and a link anywhere on the way to the base directory is refused when the code is fetched: nothing is built, run or served through a link a repository committed. - A command is an argument vector: Noust splits it the way a shell would split it
(
make 'all targets'is two words) and never gives it to a shell.&&,|,>and$(...)are ordinary arguments. For a pipe or a redirection, commit a script and run it (build: ./scripts/build.sh). A list of arguments works too:start: [gunicorn, -b, "127.0.0.1:${PORT}", "app:app"]. startandpublish_directoryare exclusive: an application is files the web server serves or a process it proxies to.runtimemust benode@,python@orphp@followed by a major (22), a minor (22.11), a full version (22.11.0),ltsorlatest; a range is for a repository's own version files (see Runtimes).
A type only accepts the keys it can honour. publish_directory on a Node server, start on a
static site and base_directory on a PHP application are refused, with what to do instead.
| Type | base_directory |
install |
build |
start |
publish_directory |
runtime |
|---|---|---|---|---|---|---|
nodejs, nextjs |
yes | yes | yes | yes | no | node |
nuxt, sveltekit, astro, remix, angular |
yes | yes | yes | yes | yes (when static) | node |
vite |
yes | yes | yes | yes (SSR) | yes | node |
static |
yes | yes | yes | no | yes | node, python |
python |
yes | yes | yes | yes | no | python |
php-fpm |
no (use webroot) |
yes | yes | no | no | php |
go, rust, ruby, java, dotnet, elixir, deno |
yes | yes | yes | yes | no | none: the server's toolchain |
monorepo |
no: it is built by its workspaces' own tools | |||||
docker-compose, container |
no: the image is built by the stack's or the Dockerfile's own configuration. Only timeout and cache apply (how long a build may take) |
Where it comes from, and who wins
There are two places, and the operator's wins whole, the way the operator's deploy hooks win:
- The operator's block, stored on the server for one application (below). When it exists, the
repository's
build:is not read at all: nothing is merged, so what only the repository said (a base directory, say) no longer applies. - The repository's
noust.yaml. - Neither: the commands of the application's type.
noust app build show shop.example.com # which block applies, and where it comes from
noust app build set shop.example.com --start 'node dist/server.js' --runtime node@22
noust app build set shop.example.com --file build.yaml # the whole block from a file ('-': stdin)
noust app build unset shop.example.com runtime # remove one key
noust app build unset shop.example.com # remove the block: the repository's applies againset with flags adds to what the operator already set; --file replaces it. The same is
GET, PUT and DELETE /api/apps/{domain}/build (the body names the keys; PUT needs sudo
mode and the root_equivalent permission, because these commands run in the build sandbox and,
for start, as the application with its secrets). Every change is audited as apps.build.
Before the first deploy there is no application to hold a block, so a new application is given
its own at creation, with --build-file: a YAML file holding build:. It replaces the
repository's, decides the type detection too (--type auto looks in its base directory), and is
stored as the operator's block for every later update. That is how several applications come
from one repository:
noust create -d web.example.com -s https://github.com/you/shop.git --build-file web.yaml
noust create -d api.example.com -s https://github.com/you/shop.git --build-file api.yaml# web.yaml # api.yaml
build: build:
base_directory: apps/web base_directory: apps/api
build: pnpm --filter web build start: ./bin/api --port ${PORT}--build-file is refused for a monorepo or Docker Compose project, and with --recipe.
In the console, Settings > Build is this block: the six keys for what the type can
honour, where the commands come from (this application's own settings, the repository's
noust.yaml or the type), the rule that the operator's block wins whole, and what the service was
last written from. Saving asks for sudo mode. Runtimes cannot be listed from the console (no
endpoint says what is installed): it suggests the common ones and names noust runtime list.
Base directory
For a monorepo (or any repository whose project is not at its root), base_directory names the
subdirectory the project lives in. Noust then:
- Detects the type there (
--type autolooks atapps/web, not at the workspace root). - Builds there. Install and build run with
apps/webas their working directory, in the sandbox, with the whole release writable (a workspace installs at its root). The release is handed to the service account whole. - Runs there. The unit's
WorkingDirectoryand the web server's root arecurrent/apps/web;currentstill points at the release, so an activation is the same swap. - Finds the package manager above it. pnpm, Yarn, Bun and npm lockfiles live at the root of
a workspace, not in the project: the nearest lockfile from the project up to the root of the
repository decides, then the
packageManagerfield ofpackage.json(Corepack's), then npm. - Reuses dependencies from the same subdirectory of the active release when its lockfile is unchanged. A workspace whose lockfile is above the project installs every time: slower, never wrong.
noust.yaml, hooks and noust app build always refer to the repository's root; a hook's
workdir, .env.example and noust.nginx.yaml are read from the base directory.
Your own commands
install replaces the type's install, build its build and start its start. Where each runs:
| Command | Runs | As | With |
|---|---|---|---|
install |
in the project, at deploy and every update | noust-build, in the build sandbox |
the install network and the application's variables as the sandbox allows |
build |
in the project, after install | noust-build, in the build sandbox |
the project's own tools on PATH (node_modules/.bin, venv/bin) |
start |
the unit's ExecStart= |
the application's account | the unit's environment, then its .env |
A failing install is not retried as something else: what the type does when pnpm install --frozen-lockfile fails (it retries without the flag) is for the type's own command only.
start is the text of an ExecStart=, so systemd expands ${VARIABLE} in it, from the unit's
environment: ${PORT} is the port Noust proxies to (for blue/green, each instance's own),
${WEB_CONCURRENCY} the workers of a Python application. A literal % stays a literal %
(Noust doubles it), and a literal $ is written $$. systemd needs an absolute program, so the
first word is made one:
- An absolute path stays as it is.
- A path with a slash (
./bin/server,dist/server) is inside the project, as it is seen throughcurrent. - A bare name is looked for in the project's own tools (
node_modules/.binof the project and of its workspace,venv/bin), then in the runtime the application is pinned to, then in the system's directories. Never nvm's: a service cannot read a home directory.
A script of venv/bin is started through the environment's own interpreter
(current/venv/bin/python current/venv/bin/gunicorn ...), because its first line names the
release it was installed in, which pruning eventually deletes.
Examples:
build: # a Go service built by make, with a binary in the repository
build: make release
start: ./bin/server --listen 127.0.0.1:${PORT}
build: # Bun
install: bun install --frozen-lockfile
build: bun run build
start: bun run start
build: # Python with the server of your choice
start: uvicorn app.main:app --host 127.0.0.1 --port ${PORT} --workers 2A toolchain Noust does not pin (Go, Ruby, Rust, Bun) is used as the server has it: it has to be installed, and the command found in the system's directories or named by its path.
Static output
publish_directory names where a static build leaves the files the web server serves, relative
to the project. It makes any build a static site: a static application with install and
build is built first (Eleventy, Hugo, Docusaurus: whatever the server has the tools for) and
served from that directory; a Vite or framework application serves it instead of running a server.
build:
install: npm ci
build: npm run build
publish_directory: _siteThe directory must exist after the build and must not be a link. A Node framework's
deployment fails right after the build naming where to set it; for a Vite or static site the
health gate refuses a release that has nothing to serve, and the previous one stays. It is
served through current like any release, so activating a release needs no reload.
Build cache
The package managers' downloads live in /var/cache/noust/build/<app>, outside every release:
npm, pnpm, yarn, pip, uv and Composer's caches, Go's build and module caches, Cargo's registry and
compiled dependencies. A deploy fetches and compiles only what changed. noust update --no-cache
(and the console's "Rebuild without cache") empties it first, and build: {cache: false} does so on
every build. noust app cache show|clear reports and empties it without deploying.
How long a build may take
The install of the dependencies and the build each get a time limit: 1800 and 2700 seconds
unless something says otherwise. build: {timeout: 3600} (or noust app build set DOMAIN --timeout 3600, or the console's build commands) gives that application 3600 seconds for each of the
two; noust config set deploy.install_timeout N and deploy.build_timeout N change the server's
default for every application that does not say (60 to 86400, read again every few seconds). Every
type that Noust builds takes timeout, including PHP and static sites.
Images the Docker daemon builds take it too. A Docker Compose stack and a container application
(a Dockerfile or Railpack) are not built by the build block, but the block may carry the two keys
that apply to an image: timeout (how long docker compose build or docker build may take, and
how long bringing the containers up may wait on one; the server's deploy.build_timeout, 2700
seconds, when the application does not say) and cache: false (the same as noust update --no-cache, on every build). noust app build set stack.example.com --timeout 7200, or build: {timeout: 7200} in the repository's noust.yaml. Any other key is refused with the reason, not
ignored. A monorepo is built by its own tools and takes no build block.
A step that is stopped says whether it was the time (did not finish within 2700s) or the memory
(killed due to insufficient memory), and for the time it names the two commands that give it
more. More time does not help a build that is stuck rather than slow (a private registry that does
not answer): read the output above the message.
Updating, and what an update does to the unit
An update does not write the unit again, so a start command, a base directory or a runtime
that changed (in the repository, or by noust app build set) would never have reached the
process. Noust remembers what each unit was written from, and when an update would write
something different it edits the unit's ExecStart, WorkingDirectory and PATH and nothing
else of it (a unit you edited by hand keeps its edits), before the release is activated.
- When the release does not pass its health gate, the previous release and the previous unit are put back and restarted, and the deployment fails with the probes and the journal.
- Removing the block puts the type's own command back, in the same way.
- An application the build block does not touch (no
start, nobase_directory, no pinned runtime) never has its unit rewritten by an update.
noust app build show prints what the unit was last written from (Unit starts).
Commands and API
noust app build show DOMAIN |
The block the next deployment uses, where it comes from, what the unit runs (--json) |
noust app build set DOMAIN |
--base-directory, --install, --build, --start, --publish-directory, --runtime, or --file |
noust app build unset DOMAIN [KEY]... |
Remove keys of the operator's block, or all of it |
noust runtime list, install, remove |
The runtimes |
GET, PUT, DELETE /api/apps/{domain}/build |
The same, over the API |
noust app process-types show DOMAIN |
The process types declared, and which a deployment uses (GET /api/apps/{domain}/process-types) |
What changes when you upgrade
Nothing for an application whose repository has no build: block, no version file Noust
reads and no pinned runtime, with these exceptions, each a fix:
- A new Python application binds
127.0.0.1and takes its workers fromWEB_CONCURRENCY; an existing one keeps its unit until an update rewrites it for another reason. - Django's
collectstaticno longer runs for a project without aSTATIC_ROOT, where it failed. - Poetry installs with
--without dev, the form Poetry 2 accepts (--no-devwas removed). - A Django project's WSGI module is found from the project, not from the virtual environment.
- Next.js, Vite and Node projects are unaffected by the new frameworks unless they depend on
Nuxt, SvelteKit, Astro, Remix, React Router or have an
angular.json: those were refused (Nuxt, Angular) or deployed as Vite (SvelteKit, Astro, Remix) before. - An application whose repository has a
.nvmrc,.node-version,engines.node,.python-version,runtime.txtor arequire.phpthat the server's runtime does not satisfy gets a managed runtime at its next update, and its unit is rewritten to use it.