# Node.js apps

> Deploy Next.js, Nuxt, NestJS, Express and other Node.js servers with the right Node version, PORT, persistent files, cluster mode, rollback and logs.

Source: https://zopanel.net/docs/nodejs  
Updated: 2026-10-09

ZoPanel runs a Node.js server as a systemd service of the hosting account, behind nginx, which handles the domain, SSL and request limits. Use this page when your project has a `package.json` and runs a server process. For front-end builds that produce only static files (Vite, Create React App, Angular, Next.js static export), see [Static sites](/docs/static-sites). For the general deployment pipeline shared by all languages, see [Git deploy](/docs/git-deploy).

## Prerequisites

- At least one Node.js version installed on the server (the installer adds Node.js 22 by default).
- A website of type **Git deploy**, or any website with its **Deploy** tab (for example **Node / Proxy**).
- The account's package must include **Git deploy & apps**. Otherwise the panel answers "this feature is not included in your hosting package".

## Install Node.js versions (administrator)

1. Open **Runtimes** in the admin menu.
2. Under **Other runtimes**, find the **Node.js** card.
3. Click **Node 24**, **Node 22**, **Node 20** or **Node 18**. Several versions can be installed side by side.

ZoPanel downloads the latest release of that major version from nodejs.org, verifies its SHA-256 checksum, installs it in `/opt/zopanel/runtimes/node/<major>` and adds pnpm and yarn. Installed versions can be removed from the same card.

**Note:** Node.js 18 and 20 have reached end of life in the official [Node.js release schedule](https://nodejs.org/en/about/previous-releases). Use 22 or 24 for new projects.

### Which version an app uses

| Source (first found wins) | Example | Result |
| --- | --- | --- |
| `.nvmrc` | `22` or `v22.11.0` | major 22 |
| `.node-version` | `24.1.0` | major 24 |
| `engines.node` in `package.json` | `">=20"` | major 20 (the first number) |
| none of the above | | the newest installed version |

If the requested major version is not installed, the newest installed version is used. You can also pin a version yourself: turn **Auto-detect** off in the **Build & run** card and pick it under **Version** (**Latest installed** = the newest one).

**Tip:** `engines.node: ">=18"` selects Node.js 18 when it is installed. Write the major you actually want in `.nvmrc`.

## Detection

On every deploy, unless **Auto-detect** is off, ZoPanel reads `package.json` and fills in the build settings. A project with `composer.json` is treated as PHP unless it depends on a Node server framework. Likewise, a Python web app (with `manage.py`, or `django`, `flask`, `fastapi`, `uvicorn` or `gunicorn` in its Python dependency files) is treated as Python when `package.json` has neither a `start` script nor a Node server framework; see [Python apps](/docs/python).

**Package manager:** `pnpm-lock.yaml` (or `"packageManager": "pnpm@…"`) → `pnpm install --frozen-lockfile`; `yarn.lock` (or `yarn@…`) → `yarn install --frozen-lockfile`; `package-lock.json` or `npm-shrinkwrap.json` → `npm ci`; otherwise `npm install`. A bun lockfile is installed with `npm install`. The build command is `<pm> run build` when a `build` script exists.

| Dependency in package.json | Detected as | Proposed start command |
| --- | --- | --- |
| `next` | Next.js | `start` script, else `npx next start` |
| `nuxt` | Nuxt | `node .output/server/index.mjs` |
| `@remix-run/serve`, `@react-router/serve`, `@remix-run/node` | Remix / React Router | `start` script, else `npx remix-serve build/index.js` |
| `@sveltejs/kit` with `adapter-node` | SvelteKit | `node build` |
| `astro` with `@astrojs/node` | Astro | `node ./dist/server/entry.mjs` |
| `@nestjs/core` | NestJS | `npm run start:prod` if that script exists, else `node dist/main` |
| `express`, `fastify`, `koa`, `hono`, or anything else | Express / Fastify / Koa / Hono / Node.js | `start` script, else `node <main>`, `node server.js`, `node index.js` or `node app.js` |

Next.js with `output: 'export'`, Astro without `@astrojs/node`, SvelteKit with `adapter-static`, and Vite projects without a `start` script are deployed as static sites. A `Procfile` with a `web:` line always provides the start command.

Click **Analyze repository** on the **Deploy** tab to see the result before deploying, then **Use these settings and customize** to edit it. Notes such as "Could not find a start script; set the start command manually" appear under the settings.

## Deploy an app

1. Open **Websites → New website**, choose **Git deploy**, enter the **Repository URL**, **Branch** and, for monorepos, the **Root directory**, then click **Create website**.
2. Wait for the first deployment in the task log. ZoPanel clones the code, runs the install and build commands, starts the app and switches traffic to it once it is healthy.
3. To change commands, open the **Deploy** tab, turn **Auto-detect** off in **Build & run**, edit the fields and click **Save & deploy**.

To deploy without Git, choose **Uploaded files (File Manager)** under **Source**, upload the project to `domains/<domain>/source` and click **Deploy**.

## PORT and HOST

The app must listen on `127.0.0.1` at the port in the `PORT` environment variable. ZoPanel sets `PORT` (different for each blue/green slot) and `HOST=127.0.0.1`; you cannot override them.

```js
// Express
const port = process.env.PORT || 3000;
app.listen(port, "127.0.0.1");
```

```ts
// NestJS: main.ts
await app.listen(process.env.PORT ?? 3000, "127.0.0.1");
```

`next start`, Nuxt's server output, SvelteKit's `adapter-node` and Astro's `@astrojs/node` read `PORT` on their own. Never hard-code a port.

## Environment variables

Add variables in the **Environment variables** card, or click **Paste .env** to paste many at once. Then click **Save & redeploy**.

- Up to 200 variables. Names use letters, digits and `_` and cannot start with a digit; values are a single line of up to 8,192 characters.
- Variables are available to the install and build commands and at runtime. Members with view-only access see the names, not the values.
- `NODE_ENV=production` is set for the running app only. During the build it is not set, so `npm ci` still installs `devDependencies` (TypeScript, Vite…). **Important:** if you add `NODE_ENV=production` yourself, it also applies to the build and npm skips `devDependencies`, which usually breaks the build.
- `CI=true` is set during the build.

## Persistent paths

Each deployment builds into a new folder under `domains/<domain>/releases/`, so files the app writes there disappear with the next release. List folders or files to keep in **Persistent paths**, separated by commas, for example `uploads, .env`.

- They are stored in `domains/<domain>/shared/` and linked into every release. On the first deploy, content already in the repository at that path is copied there.
- Names starting with `.env` are treated as files, everything else as folders.
- **Important:** when `.env` is a persistent path and the website has environment variables, ZoPanel rewrites `shared/.env` from those variables on every deploy. Keep values in one place.

## Cluster mode (every CPU core)

A Node.js process uses one CPU core. The **Use every CPU core** card (Node.js server apps only) starts one worker per CPU the package allows, up to 16, without code changes: ZoPanel preloads a small helper with Node's `cluster` module, and the workers share the port.

1. Make sure the app keeps no state in memory: sessions, caches, rate-limit counters and websocket rooms must live in Redis or a database.
2. Turn on **One process per CPU core (redeploys to apply)**. Changing the switch starts a deployment.

A crashed worker is restarted after one second; after more than 20 quick crashes the app stops with `[zopanel] workers keep crashing, stopping` in the output. To use fewer workers, set `ZP_CLUSTER_WORKERS` in the environment variables.

## Zero-downtime deploys and rollback

Every server app has two slots. A new release starts in the idle slot on its own port and must answer the **Health check path** (default `/`) with an HTTP status below 500 within 90 seconds. Only then does nginx switch to it; the previous process is stopped a few seconds later, so requests in flight finish. A release that fails to build, exits or never becomes healthy is discarded and the live one keeps running.

The **Releases** card keeps the last 5 builds. **Rollback** starts the chosen release in the idle slot with the current settings and switches after the health check, without downtime. It does not rebuild.

**Note:** during a switch both releases run at the same time for a few seconds, which also doubles memory use for that moment.

## Logs

- **Application output** on the **Deploy** tab shows the last 400 lines of stdout and stderr from both slots. Turn on **Live** to follow it.
- The build log of each deployment is in **Tasks**, or click **View log** in **Deployments**.
- The website's **Logs** tab shows nginx's access and error logs.

## Memory and CPU limits

The app runs inside the hosting account's resource group, so the package's RAM, CPU and process limits apply to all its websites together. The status card shows the app's current **RAM**. If the account runs out of memory, the kernel stops the process and systemd restarts it after one second.

To give V8 a heap limit below the package's memory, add an environment variable:

```text
NODE_OPTIONS=--max-old-space-size=384
```

## WebSockets

nginx forwards the `Upgrade` and `Connection` headers, so WebSockets and Server-Sent Events work without extra settings. Proxy buffering is off and a connection idle for 300 seconds is closed, so send a ping or heartbeat more often than that. With cluster mode on, use a shared adapter (for example Socket.IO's Redis adapter) and the WebSocket transport, because each worker holds its own connections.

## Troubleshooting

| Message or symptom | Cause and fix |
| --- | --- |
| `the app did not respond on port … within 90s (it must listen on $PORT)` | The app listens on a fixed port or crashed before listening. Read `process.env.PORT`. The last 25 lines of output are printed in the task log. |
| `the app exited during startup` | Open **Application output**: usually a missing environment variable, a missing build (`.next` not found) or a module that failed to load. |
| `Node.js is not installed — install it under Runtimes` | No Node.js version on the server. An administrator installs one under **Runtimes**. |
| `install failed: …` / `build failed: …` | The command failed; the task log shows its output. Check that the lockfile matches `package.json` (`npm ci` refuses a mismatch). |
| `a start command is required for server apps` | Set **Start command**, or switch **Type** to **Static site** for a front-end build. |
| `start command must be a single line (chain commands with &&)` | Commands are one line, up to 2,000 characters. |
| `port … is already in use by another process (uid …); try again in a few seconds` | The previous process is still stopping. Deploy again after a few seconds. |
| `a deployment of this app is already running` | Wait for the current deployment to finish. |
| 502 or the busy page after a deploy | The process stopped later (for example out of memory). Check **Application output** and the account's memory under **Resources**. |
| Build is killed or very slow | Builds share the package's CPU and memory. Raise the package's RAM, or reduce the build's memory with `NODE_OPTIONS`. |

## Related

- [Git deploy](/docs/git-deploy)
- [Node.js and Python apps](/docs/apps-node-python)
- [Static sites and front-end builds](/docs/static-sites)
- [Packages and limits](/docs/packages-limits)
- [Next.js deployment docs](https://nextjs.org/docs/app/getting-started/deploying)
- [Node.js release schedule](https://nodejs.org/en/about/previous-releases)
