Builds
How nesly decides what to build, and how to tell it something different.
Most projects need nothing on this page. On every deploy the platform looks at your source, recognises the framework, installs with the package manager your lockfile names, runs your build and serves the result — as static files when that is what the build produced, as a container when your project is a server.
The rest of this page is for when that is not what you want.
What is detected
| Framework | Build | Output | Served as |
|---|---|---|---|
| Vite (React, Vue, Svelte, Solid…) | npm run build | dist | static site |
| Create React App | npm run build | build | static site |
| Astro | npm run build | dist | static site (a server adapter makes it a container) |
| SvelteKit | npm run build | build | static site with adapter-static, container otherwise |
| Next.js | npm run build | out | static site with output: 'export', container otherwise |
| Nuxt | npm run build | — | container |
| Remix | npm run build | — | container |
| Angular | npm run build | from angular.json | static site |
| Gatsby | npm run build | public | static site |
| Docusaurus | npm run build | build | static site |
| VitePress | npm run build | docs/.vitepress/dist | static site |
| Eleventy | npm run build | _site | static site |
| Plain HTML/CSS/JS | — | — | served as it is |
The package manager comes from the lockfile: pnpm-lock.yaml,yarn.lock, bun.lockor package-lock.json. The Node version comes fromengines.node or .nvmrc. Anything not in this table — Go, .NET, Python, Ruby, a Dockerfile — is built as a container, as before.
nesly.toml
Put the file in the repository root and commit it. It travels with the code, it is reviewed with the code, and it wins over anything set in the console.
[project]
name = "shop" # project this repository deploys into
[build]
root = "apps/web" # directory the build runs in
install = "pnpm install --frozen-lockfile" # install dependencies
command = "pnpm run build" # build the project
output = "dist" # directory to publish as a static siteGenerate it from what is already there:
nesly initEvery field is optional. A field you leave out is decided by the console settings, and then by detection — in that order. nesly build show prints the result, and the build log names the source of every value it used.
The settings
| root | Directory the build runs in — the one setting a monorepo always needs. | apps/web |
| install | Command that installs dependencies. Detected from the lockfile. | pnpm install --frozen-lockfile |
| command | Command that builds the project. | pnpm run build |
| output | Directory to publish as a static site. Setting it means “this is a site, not a server”. | dist |
| start | Command that runs the server. Setting it means the opposite, and wins over output. | node server.js |
The same five fields live in the console (project → Settings → Build settings) and in the CLI:
nesly build show
nesly build set --root apps/web --output dist
nesly build clear outputOld addresses that should keep working
A site that moves onto nesly arrives with its addresses already printed, linked and indexed. Redirect rules keep those addresses working: write them in nesly.toml and they go live with the deployment.
[[redirect]]
from = "/about-us"
to = "/about"
[[redirect]]
from = "/blog/*" # everything under /blog, and /blog itself
to = "/news/:splat" # :splat carries the rest of the path across
[[redirect]]
from = "/shop"
to = "https://shop.example.com"
status = 302 # 301 (default), 302, 307 or 308Rules are tried in the order you wrote them and the first match answers. The query string comes across, so a link with campaign parameters on it still reports the way you expect. One redirect is applied per request: two rules pointing at each other cannot loop.
The rules belong to the deployment, not to the project. That is what makes a rollback whole — the version you roll back to brings its own redirects with it, so the pages and the addresses that lead to them are never out of step. nesly inspect prints the rules a deployment actually shipped with.
A rule that cannot work stops the deploy rather than reaching visitors: a destination that is not a path or a full address, a status that is not a redirect, two rules for the same address, or a rule pointing at itself.
Running one command before the new version serves anything
A schema migration has to happen between the build and the first visitor. Run it yourself after deploying and the new code is already live against the old schema; run it before and the old code meets the new schema. Name it here instead and the platform runs it in the middle, where it belongs.
[deploy]
release = "dotnet ef database update"
timeout = "10m" # optional; 15 minutes by default, 30 at mostIt runs once, in the image that was just built, with the same environment variables that deployment will run with — so a preview migrates the preview's database, not production's. Nothing is serving yet: if the command exits non-zero the deployment stops there, no instance is ever started, and your previous release keeps serving. Its output appears in the deployment log.
For ASP.NET, that line is rewritten to a migration bundle the build places at/app/efbundle(the runtime image has no SDK and nodotnet-eftool). Add aMigrationsfolder first; without one the build refuses the release line with a sentence naming what to add.
Rolling back does not run it again. A rollback returns to an older image, and running that version's migration command would move the schema forward, not back — so a rollback stays what it should be: immediate and predictable.
Asking for the memory your application needs
Every application gets 512Mi unless it asks for more. That is enough for most, and not enough for some — an application over the ceiling is stopped by the platform, and the only trace it leaves is the word "Killed". Say the size here and it travels with your code: rolling back to an earlier version restores the size that version was written for.
[resources]
memory = "1Gi" # or 512Mi, 2048Mi — the units you already write limits inAsk for more than your plan allows and the deployment stops before anything starts, naming both numbers and the line to change. We would rather stop there than hand your application less memory than it asked for and let it fail later for a reason that looks unrelated.
It is not a separate charge. Usage has always been measured from this ceiling, so a larger one costs more on the bill you already have — and `nesly ps` shows what each process is actually using against it.
Deciding when a release is healthy
A deployment is only pointed at your address after the application answers. By default any answer counts, which means an application that starts but cannot reach its database — answering 500 to everything — goes live. Say what healthy means and a release like that stops instead, with the previous one still serving.
[health]
path = "/healthz"
status = 200 # optional; without it, anything that is not a server errorThe same path becomes the readiness check for every instance afterwards, so one that stops answering stops receiving visitors. Write the check to test what your application actually needs — reaching its database, say — rather than only that the process is up, which the platform can already see.
Leave the block out and nothing changes: the root path is requested and any answer is accepted, exactly as before. A release that answers with a server error under that rule now says so in the deployment log instead of going live quietly.
The check belongs to the deployment, like the rules above. Rolling back restores the release and the question asked of it together — a version judged by a later version's health check is one you could not roll back to.
Serving part of your site from somewhere else
A redirect sends the visitor away. A rewrite does not: the content comes from another origin and is served under your own address, with nothing in the address bar to say so. That is how a blog or an API you run elsewhere lives under your domain instead of beside it.
[[rewrite]]
from = "/blog/*"
to = "https://blog.example.com/:splat"
[[rewrite]]
from = "/api/*"
to = "https://api.internal.example.com/:splat"The matching language is the redirects' language, including :splat, so writing both does not mean learning two. Rules travel with the deployment for the same reason redirects do, and the first match answers.
The destination has to be an https address with a public hostname. We reject a plain http address, because a rewrite is invisible and a visitor would have no way to know part of your encrypted page was not; and we reject a bare IP address or a name that only resolves inside our network. The address is checked again at the moment of connecting, so a name that changes where it points between then and now does not get through.
The other origin's own redirects are handed back to your visitor rather than followed, and if it cannot be reached your visitor gets a plain error page that says nothing about what is behind the rewrite.
Response headers
Caching, framing, a content security policy — a static site is served straight from storage, so there is no application of yours in the path to set them. Write them in nesly.toml instead and they go live with the deployment, on static sites and containers alike.
[[header]]
for = "/*"
[header.values]
X-Frame-Options = "DENY"
Referrer-Policy = "strict-origin-when-cross-origin"
[[header]]
for = "/assets/*" # everything under /assets, and /assets itself
[header.values]
Cache-Control = "public, max-age=31536000, immutable"for matches the same way a redirect'sfrom does. Unlike redirects,every rule that matches is applied — broad rules and narrow ones together. Where two rules name the same header, the one further down wins, so write the exception below the rule it excepts.
A rule replaces what your application sent for that header rather than adding a second value beside it. On the platform's own pages — the maintenance page, the paused page, the address that serves nothing — your rules do not apply: those are not your content.
A few names are refused, and the deploy fails rather than the rule being ignored: the ones that describe how a response is framed (Content-Length,Transfer-Encoding,Connection and their neighbours),Strict-Transport-Security, which is the platform's HTTPS guarantee, and Set-Cookie, which a gradual rollout uses to keep a visitor on one side. A line break in a value is refused too.
Environment variables during the build
Your project's variables are available while it builds, not only while it runs — which is whatVITE_* andNEXT_PUBLIC_* need. Set them before you deploy:
nesly env set VITE_API_URL=https://api.example.com
nesly deployRemember that anything a frontend build reads ends up in the files you ship. Keep real secrets on the server side.
Dependencies fetched from GitHub
A dependency that points at a GitHub repository ("pkg": "github:owner/repo"or a git+https://github.com/… URL) is fetched with the platform's own read-only GitHub identity, because GitHub sometimes refuses anonymous clones from shared build hosts. This identity can read public repositories only; a private dependency still needs its own credential and fails with GitHub's "Repository not found".
Detected builds get this automatically. If you ship your own Dockerfile, the credential is offered to it as a build secret and only reaches the steps you mount it on — it never becomes a layer, an environment variable, or a line in the log:
RUN --mount=type=secret,id=nesly_git_credentials,target=/root/.git-credentials \
git config --global credential.helper store && npm ciThe step needs git in the image, and the target path is the home directory of the user the step runs as. A Dockerfile that never mounts the secret builds exactly as before.
When a build goes wrong
- “the build finished but produced no dist directory” — your build writes somewhere else. Set
outputto that directory. - “dist is not a static site” — the build produced a server. Remove
output, or setstartto the command that runs it. - “build root apps/web is not a directory in this repository” — the path is relative to the repository root, and the deploy is refused before anything runs.
- “no start command for this Python project” — without one, the fallback is
python main.py, which runs your file and exits instead of serving. The deploy is refused up front instead of timing out five minutes later; setstart(e.g.uvicorn main:app --host 0.0.0.0 --port $PORT).nesly build showprints the same warning before you deploy. - A build has 15 minutes. The build log is kept with the deployment, so you can read it after the fact:
nesly logs.