How to deploy a Next.js app to a VPS
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.
Updated
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.
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.
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 bynext 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 aNEXT_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 oncacheComponents, which refuses to build a page that callsconnection()outside<Suspense>.
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 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.
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.
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.
.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, and your NEXT_PUBLIC_ values in build arguments.
Test the image locally
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 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.
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
- 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 explains. - Add the server variables. In the Variables tab, add the variables your server reads, and choose Sensitive for tokens and passwords.
- Add the health check. In the Configuration tab, add a 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.
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
404for/_next/staticfiles 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.
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 rather than in memory, and uploads in a bucket.