# How to deploy a Next.js app to a VPS

Source: https://tenkai.cloud/help/deploy-nextjs

> Build a Next.js 16 app with the standalone output, set NEXT_PUBLIC_ variables at build time, read the others at runtime and run it in Docker.

This guide takes a Next.js 16 app to production on your own server, in a small Docker image built with the standalone output. Most of it works on any VPS that runs containers, and the last sections deploy it with Tenkai, which gives it HTTPS and starts each new version next to the running one.

We tested it on 2 November 2026 with a new app from `create-next-app`, on Next.js 16.4.4, React 19.3 and `node:24-trixie-slim`, so adapt the variables and routes to your own app.

## Prerequisites

You need a Next.js 16 app in a Git repository with a CI such as GitHub Actions, and Node.js 24 with npm. Docker is useful to test the image on your machine, and the Tenkai part needs a [project on a ready cluster](https://tenkai.cloud/help/projects-and-clusters).

## Turn on the standalone output

Add `output: 'standalone'` to the `nextConfig` object of `next.config.ts`, and keep the options already there, such as the Tailwind CSS loader that `create-next-app` writes, without which your styles reach the browser uncompiled.

```ts
const nextConfig: NextConfig = {
	output: 'standalone',
	// …the options create-next-app wrote
};
```

`next build` then writes `.next/standalone`, a `server.js` with only the `node_modules` the server needs, so the image needs no `npm ci` of its own. The standalone folder leaves out `public` and `.next/static`, which hold your images, styles and scripts, and the Dockerfile copies them in, or every page loads without its CSS and JavaScript.

## Know when each variable is read

Next.js reads variables at two moments, and mixing them up is the most common surprise in production.

- **`NEXT_PUBLIC_` variables are written into the browser JavaScript by `next build`.** A value you set when the container starts changes nothing, and the page keeps the one the build saw, so your CI passes them to the build as build arguments. They end in every visitor's browser and in the image, so never give a secret a `NEXT_PUBLIC_` name.
- **Other variables are read by the server**, but a page that Next.js prerenders reads them during the build too. Read one in a component that calls `await connection()` first, inside a `<Suspense>` boundary, so it renders for each request with the value of the running container while the rest of the page stays prerendered. A new app turns on `cacheComponents`, which refuses to build a page that calls `connection()` outside `<Suspense>`.

```tsx
import { connection } from 'next/server';
import { Suspense } from 'react';

async function Greeting() {
	await connection();
	return <p>{process.env.GREETING}</p>;
}

export default function Page() {
	return (
		<Suspense>
			<Greeting />
		</Suspense>
	);
}
```

Tenkai gives your [variables](https://tenkai.cloud/help/variables) to the running container, which suits these server variables, and the image already holds the `NEXT_PUBLIC_` ones.

## Add a health check

Create `app/api/health/route.ts`.

```ts
import { connection } from 'next/server';

export async function GET() {
	await connection();
	return Response.json({ status: 'ok' });
}
```

`connection()` keeps the route dynamic, so it answers from the running server instead of a file written at build time. Add a query to your database here when you have one, so the route fails when the app cannot work.

## Add a Dockerfile

Create `Dockerfile` at the repository root.

```dockerfile
FROM node:24-trixie-slim AS base
WORKDIR /app

FROM base AS build
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN mkdir -p public
ARG NEXT_PUBLIC_SITE_NAME
RUN npm run build

FROM base AS runtime
ENV NODE_ENV=production \
	HOSTNAME=0.0.0.0 \
	PORT=8080
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
COPY --from=build --chown=node:node /app/public ./public
USER node
EXPOSE 8080
CMD ["node", "server.js"]
```

`mkdir -p public` lets the runtime stage copy `public` in an app that has none, since the folder is optional. Each `ARG` of the `build` stage is a variable `next build` sees, so declare one line for each `NEXT_PUBLIC_` value your app uses, such as `NEXT_PUBLIC_SITE_NAME`.

`HOSTNAME=0.0.0.0` matters most at runtime. The standalone server listens on the address in `HOSTNAME`, and Docker sets that variable to the container's ID, so without the line the server is not reachable on every network the container joins. The `CMD` starts `node` directly, which receives `SIGTERM` and stops in well under a second.

## Exclude local files from the image

Create `.dockerignore` beside the Dockerfile.

```text
.git
node_modules
.next
.env
.env.*
npm-debug.log*
```

It keeps your local build and every `.env` file out of the image. `next build` copies the `.env` files it loads into `.next/standalone`, so a password in `.env` or `.env.production` would end in the final image, readable by anyone who can pull it. Your server secrets belong in [variables](https://tenkai.cloud/help/variables), and your `NEXT_PUBLIC_` values in build arguments.

## Test the image locally

```sh
docker build -t hello-next --build-arg NEXT_PUBLIC_SITE_NAME="Hello" .
docker run --rm --name hello-next -p 8080:8080 -e GREETING=hello hello-next
```

Visit `http://localhost:8080` and `http://localhost:8080/api/health`, then run `docker stop hello-next` in another terminal, which should return at once.

## Publish the image from your CI

Build the image in your CI on each push and publish it to a container registry, so your servers spend nothing on builds and your code can stay private. [Build an image in GitHub Actions and publish it to GHCR](https://tenkai.cloud/help/publish-image-ghcr) gives a complete workflow for this Dockerfile, built for your cluster's CPU architecture, which publishes an image such as `ghcr.io/acme/hello-next:main` on each push to `main`. Give the `NEXT_PUBLIC_` values to its `docker/build-push-action` step.

```yaml
build-args: |
  NEXT_PUBLIC_SITE_NAME=${{ vars.NEXT_PUBLIC_SITE_NAME }}
```

The values come from the repository's Actions variables, in **Settings**, then **Secrets and variables** and **Actions**.

## Deploy it with Tenkai

1. **Create the application.** Choose **Add resource**, then **Application**, **Web application** and **Container image**. Enter the image reference with its moving tag, such as `ghcr.io/acme/hello-next:main`, and for a private package choose a GHCR credential that **Can pull** it, as [pull a private GHCR image](https://tenkai.cloud/help/pull-private-ghcr-image) explains.
2. **Add the server variables.** In the **Variables** tab, add the variables your server reads, and choose **Sensitive** for tokens and passwords.
3. **Add the health check.** In the **Configuration** tab, add a [health check](https://tenkai.cloud/help/blue-green-deployments#the-health-check) on `/api/health`.

Each push then publishes a new image, and a last step of your workflow can deploy it by calling the application's [deploy hook](https://tenkai.cloud/help/deploy-hook).

## Deploy and verify

Choose **Deploy** and wait for an active release, then open its default route over HTTPS. Tenkai sets `PORT` to the web application's port, 8080 by default, which the standalone server reads.

- A page with no styles and `404` for `/_next/static` files means the Dockerfile did not copy `.next/static`.
- A `NEXT_PUBLIC_` value that stays old needs a new image, since only a build changes it, so check that your workflow passes it as a build argument.
- A server value that stays old comes from a prerendered page, which needs `await connection()`.

Once it works, [connect your own domain](https://tenkai.cloud/help/custom-domain).

## Run several replicas

Each replica keeps its own cache of fetched data and regenerated pages, and a new deployment starts with an empty one, so the replicas of one release may briefly show different versions of a page that revalidates. Keep sessions and data in a [database](https://tenkai.cloud/help/managed-services) rather than in memory, and uploads in a [bucket](https://tenkai.cloud/help/object-storage).
