Deploying

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" to next.config.ts so the build emits a self-contained server.js that 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/static and public, and starts as the non-root node user.
  • 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:

  • deps installs exactly what package-lock.json lists. Docker caches this layer until the lockfile changes, so code-only changes rebuild fast.
  • builder runs next build. Anything the build needs, including NEXT_PUBLIC_ variables, must exist here (see below).
  • runner starts from a clean image and copies only the standalone server, the static files and public. HOSTNAME=0.0.0.0 makes the server listen on every interface inside the container; Next.js's docs say server.js takes its port and host name from PORT and HOSTNAME.
  • HEALTHCHECK uses Node's built-in fetch, because the slim image has neither curl nor wget.
  • USER node runs 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 myapp

The 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=latest

Generate 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 as db:5432 on Compose's network. We checked from inside the app container that db:5432 accepted connections; with no published port, nothing outside Compose's network can reach it.
  • depends_on with service_healthy starts the app only after pg_isready passes.

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:latest

New 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):

  1. Create an A record for example.com (and www) with the server's IP address, and wait until dig +short example.com returns it.
  2. Make sure the provider's firewall allows TCP 80 and 443 (and UDP 443 for HTTP/3).
  3. In /srv/myapp, run docker compose up -d, then docker compose logs -f caddy to 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

Key facts

FactValueSource
Next.js version tested16.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 usednode:24-slim (Node.js 24.22.2)Node.js official Docker image (opens in a new tab)Verified on
Final image size in our test385 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 outThe public and .next/static folders: copy them next to server.js yourselfNext.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

  1. Next.js docs: output (standalone) (opens in a new tab), Vercel (Next.js), accessed on
  2. How to self-host your Next.js application (opens in a new tab), Vercel (Next.js), accessed on
  3. Next.js docs: environment variables (opens in a new tab), Vercel (Next.js), accessed on
  4. Next.js example: with-docker (Dockerfile) (opens in a new tab), Vercel (Next.js repository), accessed on
  5. Automatic HTTPS (opens in a new tab), Caddy, accessed on
  6. Postgres official image (opens in a new tab), Docker Hub, accessed on
  7. Working with the Container registry (opens in a new tab), GitHub, accessed on
  • 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.

    12 min read

  • 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.

    9 min read

  • 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.

    8 min read

Put OpsCrew to work on your app.

Run npx opscrew init in your app's repository. It checks your tools and tokens first, for free.