Deploy Next.js to a VPS with Docker and HTTPS
A tested setup for Next.js 16 on your own server: standalone output, a small multi-stage Docker image, PostgreSQL and Caddy with automatic HTTPS, and the build-time traps that break apps after the first deploy.
TL;DR
- Add
output: "standalone"tonext.config.tsso the build emits a self-containedserver.jsthat runs with plain Node.js (opens in a new tab). - Build a three-stage Docker (opens in a new tab) image (dependencies, build, run) that copies
.next/standalone,.next/staticandpublic, and starts as the non-rootnodeuser. - Run it with Docker Compose behind Caddy (opens in a new tab), which gets the HTTPS certificate itself, and next to PostgreSQL (opens in a new tab), which publishes no port.
NEXT_PUBLIC_variables are baked into the bundle at build time, so set them where the image is built, not on the server.
On this page
To deploy Next.js (opens in a new tab) to a VPS, turn on output: "standalone", build a small Docker image from the standalone output, and run it on the server with Docker Compose, next to PostgreSQL and behind Caddy, which gets and renews the HTTPS certificate on its own. Build the image in CI or on your computer, push it to a registry, point your domain at the server, and start the stack with docker compose up -d.
Every file below was run on 11 November 2026: a fresh create-next-app project (Next.js 16.4.6) built into an image on Node.js 24.22.2, then run with PostgreSQL 18.6 and Caddy 2.11.9 under Docker 29.9.3. It is one part of our complete guide to deploying a web app to a VPS, which covers choosing the server, DNS, backups and hardening around it.
If v0 (opens in a new tab) or another AI builder generated your Next.js app, read how to deploy a v0 app without Vercel as well: it covers what those projects add on top of a plain Next.js app.
1. Turn on standalone output
create-next-app 16.4.6 writes a next.config.ts with a few defaults of its own. Add one line, output: "standalone":
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
// ...the options create-next-app wrote stay as they are
};
export default nextConfig;With this setting, next build traces which files the server loads and copies them, with a minimal server.js, into .next/standalone. The Next.js documentation notes what it leaves out on purpose: the public folder and .next/static, which you copy next to server.js yourself. The Dockerfile below does that.
2. Add a health route
Docker, Caddy and your deploy script need a cheap way to ask "is this copy of the app ready?". Create app/api/health/route.ts:
import { connection } from "next/server";
export async function GET() {
await connection(); // answer at request time, never from the build
return Response.json({ status: "ok", version: process.env.APP_VERSION ?? "dev" });
}await connection() matters in Next.js 16. Without a request-time API, Next.js may render a route once at build time and serve that copy forever, and a health check that answers from the build proves nothing. The version field is optional; it helps you see which release answers.
3. Write the Dockerfile
This follows the structure of the official with-docker example in the Next.js repository, cut down to npm (opens in a new tab). Put it in the project root as Dockerfile:
# syntax=docker/dockerfile:1
FROM node:24-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
FROM node:24-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:24-slim AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
HEALTHCHECK --interval=5s --timeout=3s --start-period=10s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
CMD ["node", "server.js"]What each part does:
depsinstalls exactly whatpackage-lock.jsonlists. Docker caches this layer until the lockfile changes, so code-only changes rebuild fast.builderrunsnext build. Anything the build needs, includingNEXT_PUBLIC_variables, must exist here (see below).runnerstarts from a clean image and copies only the standalone server, the static files andpublic.HOSTNAME=0.0.0.0makes the server listen on every interface inside the container; Next.js's docs sayserver.jstakes its port and host name fromPORTandHOSTNAME.HEALTHCHECKuses Node's built-infetch, because the slim image has neithercurlnorwget.USER noderuns the app without root privileges inside the container.
Add a .dockerignore, so your local node_modules, build output and secrets never enter the build context:
node_modules
.next
.git
.env*
npm-debug.log*4. Build and run it locally
docker build -t myapp .
docker run --rm -p 3000:3000 -e APP_VERSION=v1 myappThe image was about 385 MB on disk (93 MB compressed in the registry). In another terminal, curl localhost:3000/api/health answered {"status":"ok","version":"v1"} in our test, the home page answered 200, and docker ps showed the container as healthy within seconds. If the health check fails, the app is almost always listening on another port or only on localhost; check PORT and HOSTNAME.
5. Add PostgreSQL and Caddy with Compose
On the server, create a folder (for example /srv/myapp) with three files.
compose.yaml:
services:
app:
image: ghcr.io/your-name/myapp:${APP_TAG:-latest}
restart: unless-stopped
environment:
DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD}@db:5432/app
depends_on:
db:
condition: service_healthy
db:
image: postgres:18
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
- app
volumes:
db-data:
caddy-data:
caddy-config:Caddyfile, with your own domain:
example.com {
encode zstd gzip
reverse_proxy app:3000
}
www.example.com {
redir https://example.com{uri} permanent
}.env, readable only by you (chmod 600 .env):
POSTGRES_PASSWORD=replace-with-the-output-of-openssl-rand-hex-24
APP_TAG=latestGenerate the password with openssl rand -hex 24; hex keeps it safe inside the DATABASE_URL.
Notes on these files:
- PostgreSQL 18 mounts its volume on
/var/lib/postgresql. The official image now keeps data in a folder named after the major version below it; guides written for older versions mount/var/lib/postgresql/data. - The database has no
ports:entry. The app reaches it asdb:5432on Compose's network. We checked from inside the app container thatdb:5432accepted connections; with no published port, nothing outside Compose's network can reach it. depends_onwithservice_healthystarts the app only afterpg_isreadypasses.
How we tested the HTTPS part
A public certificate authority cannot reach a laptop, so we checked the production Caddyfile with caddy validate, then ran the whole stack with one change: a tls internal line inside each site block, which makes Caddy sign its own certificate. The site answered 200 over HTTPS through Caddy, https://www.example.com/pricing answered 301 to https://example.com/pricing, and PostgreSQL 18.6 answered queries. On the server you leave tls internal out, and Caddy requests a real certificate from Let's Encrypt (ZeroSSL is its fallback) the first time it starts.
6. Push the image and start the stack on the server
The server should run images, not build them: in our test next build peaked at about 780 MB of memory, which on a 2 GB server competes with the app and the database. Build in CI (the GitHub Actions workflow for Hetzner works for any VPS) or, for the first deploy, from your computer.
To push from your computer to GitHub (opens in a new tab) Container Registry, log in with a personal access token (classic) that has the write:packages scope, as GitHub's documentation describes:
echo "$CR_PAT" | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin
docker build -t ghcr.io/your-name/myapp:latest .
docker push ghcr.io/your-name/myapp:latestNew packages are private, so on the server log in once with a token that has only read:packages.
Then, on the server (Docker installed as in the Hetzner or DigitalOcean walk-through):
- Create an A record for
example.com(andwww) with the server's IP address, and wait untildig +short example.comreturns it. - Make sure the provider's firewall allows TCP 80 and 443 (and UDP 443 for HTTP/3).
- In
/srv/myapp, rundocker compose up -d, thendocker compose logs -f caddyto watch Caddy obtain the certificate.
Open https://example.com. If Caddy logs a certificate error, the DNS record or the firewall is almost always the cause.
The traps after you deploy Next.js to a VPS
NEXT_PUBLIC_ variables are frozen at build time. Next.js replaces each process.env.NEXT_PUBLIC_* reference with its value during next build and ships it in the browser bundle. Setting it in the server's .env changes nothing for the browser. Pass it to the build instead, as a build argument read in the builder stage, and never put a secret in it: everyone who loads the page can read it.
Server-side variables are read when the code runs. Variables without the prefix stay on the server. In the App Router they are read at request time in dynamically rendered code (after await connection(), cookies() or headers()); a page that Next.js prerendered at build time keeps the values it had then. If a page shows a stale value, it was prerendered.
Two copies of the app need the same keys. For zero-downtime updates you run two containers. The Next.js self-hosting guide warns that a Server Function encrypted by one instance cannot be decrypted by another unless they share NEXT_SERVER_ACTIONS_ENCRYPTION_KEY (a base64 AES key set at build time), and it recommends a deploymentId in next.config.ts: when a browser holds the previous release, Next.js then does a full page reload instead of a client-side navigation, so the browser never mixes assets from two releases.
Caches are per container. The default cache lives in each container's memory and disk. One container is fine; with several, the self-hosting guide describes a shared cache handler.
Images on glibc Linux. The self-hosting guide says next/image optimisation works with zero configuration under next start, and notes that on glibc-based Linux (which node:24-slim is) it may need extra configuration to prevent excessive memory use; watch the container's memory if your app serves many images.
Updating the app
The simplest update: build and push a new image, set APP_TAG to its tag in .env, and run docker compose up -d. Compose replaces the app container and leaves the database and Caddy alone. For a few seconds during the swap, requests fail. If that matters, run two copies of the app as shown in zero-downtime deployments with Docker Compose, which we tested under load with this same image.
Doing this with OpsCrew
OpsCrew, the open-source tool we build, does these steps for a Next.js app with your approval at each one. It prepares the app for a container, chooses how to build its image (see how OpsCrew builds your app's image), runs PostgreSQL next to it with a generated password and no public port (databases), and puts Caddy in front with HTTPS (the reverse proxy). Deploys run from a GitHub Actions (opens in a new tab) workflow it opens as a pull request, and switch releases without downtime. When you leave Vercel (opens in a new tab), it also carries the routing rules from vercel.json into the proxy's configuration; rules in next.config.js need nothing, since Next.js applies them itself.
Where to go next
- Automate the build and deploy: Deploy a Docker Compose app to Hetzner with GitHub Actions.
- Remove the seconds of downtime: Zero-downtime deployments with Docker Compose.
- See every other part of running the server, from backups to hardening: How to deploy a web app to a VPS.
Key facts
| Fact | Value | Source |
|---|---|---|
| Next.js version tested | 16.4.6 (create-next-app defaults: App Router, TypeScript, Tailwind CSS) | create-next-app on npm (opens in a new tab)Verified on |
| Node.js image used | node:24-slim (Node.js 24.22.2) | Node.js official Docker image (opens in a new tab)Verified on |
| Final image size in our test | 385 MB on disk (93 MB compressed)The default create-next-app project built with the Dockerfile in this article. | Verified on |
| What standalone output leaves out | The public and .next/static folders: copy them next to server.js yourself | Next.js docs, output (opens in a new tab)Verified on |
Frequently asked questions
Do I need Vercel to run Next.js?
No: Next.js runs on any server with Node.js, and its self-hosting guide says next/image optimisation and Proxy work with no extra configuration under next start, the Next.js server you run yourself. What you give up are the platform's extras, such as its CDN, preview deployments and managed scaling, which you replace with your own tools or do without.
Can I use next start instead of standalone output?
Yes, but then the image must contain your whole node_modules folder, including packages only the build needs, so it is much larger. Standalone output traces the files the server actually loads and copies only those, which is why the official Docker example uses it.
How much memory does a Next.js app need on a VPS?
The default app used about 50 to 65 MB of memory at idle in our test, but your app's own code and traffic decide the real figure. With PostgreSQL next to it, plan on at least 2 GB for the server.
Sources
- Next.js docs: output (standalone) (opens in a new tab)
- How to self-host your Next.js application (opens in a new tab)
- Next.js docs: environment variables (opens in a new tab)
- Next.js example: with-docker (Dockerfile) (opens in a new tab)
- Automatic HTTPS (opens in a new tab)
- Postgres official image (opens in a new tab)
- Working with the Container registry (opens in a new tab)
Related articles
- Deploying
How to deploy a web app to a VPS: the complete guide
From an empty server to a production app: server size, Docker, a reverse proxy with HTTPS, DNS, CI/CD, secrets, backups, zero-downtime updates, previews, logs and hardening.
- Deploying
Zero-downtime deployments with Docker Compose
Two app replicas behind Caddy, health checks, retries and a 25-line script: a zero-downtime rollout with plain Docker Compose, tested under constant load, including a broken release and a rollback.
- Deploying
Deploy a Docker app to a DigitalOcean Droplet
Create a Droplet, lock it down with a Cloud Firewall, install Docker from Docker's own repository, and run your app with Compose and automatic HTTPS. Every command tested; prices checked on 11 November 2026.