# Languages

What the platform detects in a .NET, Go, Ruby, PHP, Node.js, Python or Rust project, which version it builds on, and what ends up in the image.

A project with no Dockerfile is built from a Dockerfile the platform writes for its language, on that language's official image. This page says, language by language, what is detected, which version is used and how to pin another, which commands run and how to replace them, and what the running image contains. It is about projects that run as a server; static sites (Vite, Astro, Hugo…) are on[Builds](/docs/builds/).

## How the language is decided

A `Dockerfile` in the build root wins over everything below: it is built exactly as written. Without one, the first line of this table that matches your build root decides.

| Language | Recognised by |
| --- | --- |
| .NET | a .csproj, .fsproj or .vbproj file |
| Go | go.mod |
| Ruby | Gemfile |
| PHP | composer.json or index.php |
| Node.js | package.json, with no other language's manifest beside it |
| Python | requirements.txt, pyproject.toml, Pipfile, setup.py or main.py |
| Rust | Cargo.toml |

The order is what settles a mixed tree. A Go service with a package.json for its CSS is a Go project, a Laravel app with Vite is a PHP project, a Rails app with jsbundling is a Ruby project, and a Flask or Django app with a Vite front end is a Python project whose front end is built in the same image.

Languages with no row here (Elixir, Java, Deno, Dart and others) are built by a general-purpose fallback builder, which handles the common layouts. A Dockerfile is the way to full control for any of them. A tree that nothing here can build stops with "add a Dockerfile; supported stacks:" and the list above.

## Versions at a glance

| Language | When nothing is pinned | Pinned by | Built on | Runs on |
| --- | --- | --- | --- | --- |
| .NET | none: the project must name it | <TargetFramework> | Microsoft's sdk:<version> | Microsoft's aspnet:<version> |
| Go | the current release | cannot be pinned lower | golang:1-alpine | alpine:3 |
| Ruby | 3.3 | Gemfile ruby line, .ruby-version | ruby:<version> | the same image |
| PHP | 8.3 (8.2–8.4 available) | composer.json require.php | the platform's PHP image | the same image |
| Node.js | 24 | engines.node, .nvmrc, .node-version | node:<major> | node:<major>-slim |
| Python | 3.13 (3.9 or later) | .python-version, requires-python, runtime.txt, Pipfile | python:<version> | python:<version>-slim |
| Rust | the newest stable | rust-toolchain.toml | rust:<version>-bookworm | debian:bookworm-slim |

## What every language has in common

-   **Replacing a command.** `install`, `command` and`start` under `[build]` in nesly.toml replace the matching step in every language; the console and `nesly build set` write the same fields (see[Builds](/docs/builds/)). A `Procfile` with a`web:` line is the start command when you set none.
-   **Other builders' settings.** `NIXPACKS_*` variables and`nixpacks.toml` are not read for any language on this page; write the same thing in nesly.toml `[build]`. When your project has either, the build log says so in one line.
-   **The port.** Every image sets `PORT=8080`. Listen on`$PORT` and on all interfaces (`0.0.0.0`), not on localhost.
-   **Your environment variables** reach every build step as a secret. They are not written into the image; the running container gets them from the platform.
-   **No system packages.** The build installs nothing with apt: what the base image carries is what you have, and each section below says what that is. A system library beyond it needs a Dockerfile.
-   **Images** come from the official ones (for PHP, the platform's own, built on the official one) through the platform's registry cache. The images a project that pins nothing builds on are kept warm on the build machines, so a first build does not wait for them to download.
-   **Faster rebuilds.** For Node.js, Python and PHP only the dependency files are copied before the install, so a deploy that changes only source code reuses the installed layer. Node.js, Python, PHP and Rust keep downloaded packages in a cache between builds.
-   The container starts as root. A build has 15 minutes.

## .NET

**Recognised by** a .csproj, .fsproj or .vbproj anywhere in the first few directory levels (bin, obj and node\_modules are skipped). With a single .sln at the root that lists projects, only those count. With several projects, the one that uses `Microsoft.NET.Sdk.Web` is published and its project references are restored with it; several web projects, or several projects and no web one, are refused with a sentence naming them, and a Dockerfile picks one.

**Version.** The `<TargetFramework>` of the published project, or of a Directory.Build.props or Directory.Build.targets above it: `net10.0` builds on Microsoft's `sdk:10.0` image and runs on `aspnet:10.0`. There is no default version; a project whose framework cannot be read is refused before the build. Changing the version means changing `TargetFramework`.

**Build.** `dotnet publish "<project>" -c Release -o /app -r linux-x64 --self-contained false -p:PublishReadyToRun=true`: ReadyToRun, so the app's code is precompiled and the first requests after a cold start spend less time compiling, and framework-dependent, so it runs on the aspnet image. The runtime identifier follows the build machine's architecture (`linux-arm64` on arm64) and replaces a `RuntimeIdentifier` the project sets itself. Native AOT is not used; it needs a Dockerfile.

**Turning ReadyToRun off.** Set `<PublishReadyToRun>false</PublishReadyToRun>`in the project file or a Directory.Build.props: a project that sets the property decides alone. An environment variable `PublishReadyToRun=false` on the project does the same without a code change. The restore downloads the ReadyToRun compiler and runtime packs from nuget.org, so a`nuget.config` that clears nuget.org needs a feed that carries them, or ReadyToRun off.

**Migrations.** When the tree has an EF Core `Migrations` folder and either references Entity Framework Core or asks for `dotnet ef database update` as its release command, a migration bundle is built to `/app/efbundle` as well (see[release commands](/docs/builds/)).

**Start.** `dotnet <name>.dll`, where the name is the project's`<AssemblyName>`, or the project file's name without one.

**Overrides.** `install` runs before the publish;`command` replaces the publish and must write its output to`/app`; `start` replaces the entrypoint.

**In the image.** `aspnet:<version>` with the publish output in`/app`: no SDK and no source code. `ASPNETCORE_URLS` is set to port 8080, so the app needs no listen address of its own.

## Go

**Recognised by** a go.mod in the build root.

**Version.** Always the current Go release (`golang:1-alpine`). The`go` line in go.mod is a minimum, not a pin: the newest release builds it, and a module that asks for a toolchain newer than the image gets that toolchain downloaded. An older Go cannot be chosen here; a Dockerfile can.

**Build.** Without a go.sum, `go mod tidy` writes one first. Then`go build -trimpath` of the package in the build root, with`CGO_ENABLED=0`.

**Start.** The binary that build produced.

**Overrides.** `install` runs after the tidy and before the build.`command` replaces `go build`; the platform then cannot know which binary you built, so a build command without a start command is refused. `start`replaces the binary as the container's command.

**In the image.** `alpine:3` with your whole source tree in`/app`, so templates and static files read by relative path are found, and the binary outside it, where no file of yours can collide with it. With your own build command, the image holds what that command left in the tree, and your start command runs it from `/app`.

## Ruby

**Recognised by** a Gemfile. Unless you set a start command, a deploy with no Gemfile.lock (`bundle lock` writes it) or no config.ru is refused before the build, naming the file. A Gemfile beside a Jekyll `_config.yml` is a static site, not this.

**Version.** The `ruby` line of the Gemfile, then`.ruby-version`. An exact version (`ruby "3.2.4"`) builds on exactly that image; a range (`ruby "~> 3.3"`) on the newest patch of the minor it names. With neither, Ruby 3.3. CRuby only: JRuby, TruffleRuby and mruby need a Dockerfile.

**Install.** The Bundler version the lock's `BUNDLED WITH` names; a Bundler 1 lock is raised to 2.5.23, because Bundler 1 does not run on Ruby 3.2 or later. Then`bundle install` without the development and test groups. A package.json beside the Gemfile brings Node 24 and npm from the official Node image and installs the JavaScript packages with the lockfile's manager (`yarn install`, `pnpm install --frozen-lockfile`,`npm ci`, or `npm install` without a lock).

**Build.** A Rails app with an asset pipeline (sprockets-rails, propshaft, jsbundling-rails or cssbundling-rails in the lock) runs `bundle exec rails assets:precompile`, with a placeholder `SECRET_KEY_BASE` for that step when you have not set one. Anything else has no build step. `RACK_ENV` and `RAILS_ENV` are`production` unless you set them.

**Start.** Rails: `bundle exec rails server -b 0.0.0.0 -p ${PORT}`. Any other Rack app: `bundle exec rackup config.ru -o 0.0.0.0 -p ${PORT:-3000}`.

**Overrides.** `install` replaces `bundle install` and the JavaScript install; `command` replaces the asset precompile;`start` replaces the server command.

**In the image.** The same `ruby:<version>` image the build ran on, with your source in `/app` and the installed gems. It is the full image, so native gems find their compiler and the libpq, libsqlite3, libyaml and libxml2 libraries.

## PHP

**Recognised by** a composer.json or an index.php in the build root.

**Version.** The lowest version composer.json's `require.php` admits:`"^8.2"` builds on 8.2. The platform has 8.2, 8.3 and 8.4; a constraint below 8.2 builds on 8.2, one above 8.4 on 8.4, and the build log says so. Without a `php`requirement, 8.3.

**The image.** The platform's PHP image, built from the official`php:<version>-apache`: the pdo\_pgsql, pdo\_mysql, zip, intl, bcmath, gd and opcache extensions, Composer 2, Node 24 with npm and corepack, git and unzip, and the production php.ini. An extension not on that list needs a Dockerfile.

**Install.** composer.json and composer.lock first, then`composer install --no-dev --no-scripts --no-autoloader --prefer-dist`, then your code, then `composer dump-autoload -o --no-dev`, which is where your Composer scripts run. A`path` repository in composer.json moves the install after the code is copied.

**Build.** A package.json with a `build` script (Laravel's Vite, Mix) installs the JavaScript packages with the lockfile's manager and runs `<manager> run build`. No build script, no Node step.

**Laravel** (artisan beside composer.json): `LOG_CHANNEL=stderr` unless you set it, so the application log reaches `nesly logs`; storage/ and bootstrap/cache are writable by the web server.

**Start.** Apache with mod\_php as the only process, on `$PORT`, logging to the container's output. The document root is `public/` when`public/index.php` exists, the project root otherwise. mod\_rewrite is on.

**Overrides.** `install` replaces the Composer install and the JavaScript install, and runs after your code is copied; `command` replaces the JavaScript build;`start` replaces Apache.

**In the image.** The same PHP image, with your project in `/var/www/html`.

## Node.js

**Recognised by** a package.json with no manifest of another language beside it (composer.json, requirements.txt, pyproject.toml, Pipfile, setup.py, setup.cfg, Cargo.toml, pom.xml, build.gradle, mix.exs, gleam.toml, deno.json, pubspec.yaml, shard.yml, Package.swift, stack.yaml, build.zig). A frontend framework that builds to static files is served as a static site (see[Builds](/docs/builds/)); this section is for a Node server.

**Version.** `engines.node` in package.json, then`.nvmrc`, then `.node-version`. A version or a range with an upper end (`"20"`, `"^20"`, `">=18 <21"`) builds on the line it starts from. An open range (`">=18"`) builds on Node 24 when it allows 24, otherwise on the highest of 20, 22, 24 and 26 it allows. With nothing pinned, Node 24.

**Package manager.** The `packageManager` field of package.json, else the lockfile: pnpm-lock.yaml is pnpm, yarn.lock is Yarn, bun.lock or bun.lockb is Bun; npm otherwise. pnpm, and Yarn named in `packageManager`, run through corepack at the version you name; Bun comes from its official image.

| Manager | Install |
| --- | --- |
| npm | npm ci with package-lock.json, npm install without |
| pnpm | pnpm install --frozen-lockfile with pnpm-lock.yaml, pnpm install without |
| Yarn 1 | yarn install --frozen-lockfile with yarn.lock, yarn install without |
| Yarn 2 and later | yarn install --immutable with yarn.lock, yarn install --no-immutable without |
| Bun | bun install |

devDependencies are installed, even when your project sets `NODE_ENV=production`, and they stay in the image: a start script that runs tsx or ts-node needs them.

**Build.** `<manager> run build` when package.json has a`build` script, with `NODE_ENV=production` unless you set`NODE_ENV` yourself.

**Start.** For a recognised server framework, its own start (the[deploy guides](/deploy/express/) name them). Otherwise`<manager> start` when package.json has a `start` script (Bun: `bun run start`), then `node <main>` for package.json's`main`, then `node index.js`. With none of them the build stops and asks for a start script.

**Overrides.** `install`, `command` and`start` replace the install, the build script and the start.

**In the image.** `node:<major>-slim` with `/app`as the build left it, node\_modules included, and the package manager the build used, so the running container downloads nothing. `NODE_ENV=production`.

## Python

**Recognised by** requirements.txt, pyproject.toml, Pipfile, setup.py or main.py, when no language above has claimed the tree.

**Version.** The first of `.python-version`,`requires-python` in pyproject.toml (Poetry's `python`), runtime.txt and the Pipfile's `python_version`. A constraint builds on its lower bound (`">=3.11"` is 3.11). The image is tagged by minor, so the newest patch of it builds. Below 3.9 builds on 3.9. With nothing pinned, Python 3.13. CPython 3 only: PyPy needs a Dockerfile.

**Install.** Into a virtual environment at `/opt/venv`, by the first file found:

| File | Install |
| --- | --- |
| poetry.lock | poetry export of the main group, installed with pip |
| uv.lock | uv sync --frozen --no-dev --no-install-project |
| Pipfile.lock | pipenv requirements, installed with pip |
| requirements.txt | pip install -r requirements.txt |
| pyproject.toml alone | uv pip install . |
| setup.py | pip install . |
| Pipfile without a lock | refused: run pipenv lock and commit Pipfile.lock |

poetry, uv and pipenv are installed for the build only and are not in the running image. A lockfile installs your dependencies, not your project itself: the app runs from its source in`/app`.

**Build.** A package.json with a `build` script beside the Python manifest (Vite or Webpack next to Flask or Django) brings Node from the official image (the project's own Node version, Node 24 without one), installs the JavaScript packages and runs the build after the Python install. A Django app (manage.py and a django dependency) with no build command of its own runs`manage.py collectstatic --noinput` when django.contrib.staticfiles is installed.

**Start.** When you set none and there is no Procfile, the first that fits:

-   FastAPI with uvicorn declared: `uvicorn <module>:<app> --host 0.0.0.0 --port $PORT`.
-   Flask with gunicorn declared: `gunicorn <module>:<app> --bind 0.0.0.0:$PORT`.
-   A module that starts its own server when it is run (`app.run(…)`):`python <module>.py`.
-   Streamlit: `streamlit run <script> --server.port=$PORT --server.address=0.0.0.0 --server.headless=true --server.enableCORS=false`.
-   Django: `gunicorn <WSGI module>`, from the directory that holds manage.py, with`python manage.py migrate` as the release command, run once per deploy before the new version serves. A project on its own SQLite file migrates in each container before gunicorn instead.
-   `python main.py`, when main.py exists. Beside a requirements.txt or pyproject.toml only when main.py starts a server itself (a `.run(` call): a script that runs and exits is not a web app.

With none of these the deploy is refused with a start line for your framework. In a chained start (`cd backend && gunicorn app.wsgi`), the last command becomes the container's main process.

**Overrides.** `install` replaces the Python install (the JavaScript install still runs); `command` replaces the front-end build and Django's collectstatic;`start` replaces the start.

**In the image.** `python:<version>-slim` with`/opt/venv` and your project in `/app`; no Node. A dependency that exists only as source on Linux and links a system library at run time (psycopg2, mysqlclient, pycurl) makes the image the full `python:<version>`, because the slim one lacks that library; psycopg2-binary and `psycopg[binary]` do not.`PYTHONUNBUFFERED=1`, so print output reaches the logs at once.

## Rust

**Recognised by** a Cargo.toml, when no language above has claimed the tree.

**Version.** A release pinned in `rust-toolchain.toml` (or the older`rust-toolchain` file):

```
[toolchain]
channel = "1.85"
```

`stable`, or no file, builds on the newest stable. `rust-version` in Cargo.toml is a lower bound and never selects an older compiler. nightly, beta and dated channels are refused; pin a release or use a Dockerfile.

**Build.** `cargo build --release`, with `--locked` when Cargo.lock is committed. Every executable at the top of target/release is kept, so a workspace and a renamed `[[bin]]` need nothing extra.

**Start.** `./bin/<name>` for the single binary, or the`default-run` one. A project that builds several binaries and names none must set a start command; without one the container stops and says so.

**Overrides.** `install` runs before the build. `command`replaces `cargo build` and must leave its executables in target/release.`start` replaces the binary.

**In the image.** `debian:bookworm-slim` with the CA certificates, your source tree in `/app` without target/ (templates and config files read at run time are there), the binaries in `/app/bin`, and the shared libraries they load beyond libc (libssl, libpq) in `/app/lib`.

---
Canonical: https://nesly.net/docs/languages/
Markdown: Accept: text/markdown on the HTML URL, or replace the trailing slash with .md
