Skip to content

How to Deploy Next.js to a VPS

Updated Jul 2026

verified on Ubuntu 26.04 · Jul 2026

A battle-tested, step-by-step guide to deploying a production Next.js app to your own VPS with Docker, Nginx, and zero-downtime restarts.

Before you start
  • A VPS with 2 GB RAM minimum — 4 GB if you build on the box, since `next build` is memory-hungry.
  • Docker on the server (the app runs in a container) and Nginx on the host (the reverse proxy).
  • A domain with an A record pointed at the server, for HTTPS.
  • Your app in a Git repo you can clone onto the box.
Need a box for this guide? Kamatera's free tier lets you spin one up now.Start free on Kamatera → (opens in new tab)

Vercel is great until the bill scales with traffic. Running the same Next.js app on a small VPS gives you predictable cost and full control — the box costs the same whether you serve a thousand requests a day or a million. This is the exact setup I run in production: the app in a Docker container, Nginx on the host as the reverse proxy, Let's Encrypt for TLS, and a blue-green deploy script so restarts never drop a request.

One honest caveat up front: you're taking on the ops that Vercel handles for you — OS updates, TLS renewal (automated below), and being the one who gets paged. For a side project or a cost-sensitive app that's a fair trade. If you'd rather not manage a box at all, a self-hosted PaaS like Coolify splits the difference.

Server sizing — don't undersize the build

Serving a built Next.js app is light; building it is not. next build holds the whole module graph in memory and routinely peaks past 1.5 GB on a real app. On a 1 GB box the build gets OOM-killed halfway through, which looks like a random failure until you check dmesg.

So size for the build, not the runtime:

  • 2 GB RAM is the floor, and only comfortable for small apps. Add swap.
  • 4 GB is the realistic pick if you build on the server — it's cheaper than debugging failed deploys.
  • Or build somewhere else (CI, or your laptop) and ship only the image, in which case a 1 GB box runs the app fine.

That's why we point people at a Hetzner CX22 (2 vCPU / 4 GB) as the value pick, or Kamatera when you want to dial in the exact RAM and scale on demand. See Best VPS for Node.js & Next.js Apps for the ranked picks.

Prepare the server

Start from a fresh Ubuntu 22.04, 24.04, or 26.04 server. SSH in as root, update, and create a non-root user to work as day to day:

apt update && apt upgrade -y
adduser deploy
usermod -aG sudo deploy

Lock down the firewall to just SSH and the web ports — Nginx terminates TLS on 443, so the app's port 3000 never faces the internet:

ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw enable

Install Docker (the app runs in a container) and Nginx (the host reverse proxy):

curl -fsSL https://get.docker.com | sh
usermod -aG docker deploy
apt install -y nginx

Log out and back in as deploy so the docker group takes effect.

1. Dockerfile

Next.js has a standalone output mode that traces exactly the files your app needs and bundles a minimal server.js. It cuts the image from ~1 GB to ~150 MB and removes node_modules from the runtime layer entirely. Turn it on:

// next.config.mjs
export default { output: "standalone" };

Then a multi-stage build — deps, build, and a tiny runtime that ships only the standalone bundle and runs as a non-root user:

FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:22-alpine AS run
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# standalone output already excludes node_modules
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
USER node
EXPOSE 3000
CMD ["node", "server.js"]

The HOSTNAME=0.0.0.0 line matters: without it, older standalone servers bind to localhost inside the container and Nginx can't reach them.

2. Nginx reverse proxy

Nginx sits on the host, terminates TLS, and proxies to the container on 127.0.0.1:3000. Keep the upstream target in its own file — the deploy script in step 5 rewrites this one line to flip traffic between releases:

# /etc/nginx/conf.d/nextjs-upstream.conf
upstream nextjs { server 127.0.0.1:3000; }

Then the site itself:

# /etc/nginx/sites-available/example.com
server {
  listen 80;
  server_name example.com;

  # Serve _next/static straight from Nginx if you bind-mount it; otherwise
  # the standalone server handles it fine.
  client_max_body_size 25m;          # bump if you accept large uploads

  location / {
    proxy_pass http://nextjs;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;      # WebSockets / HMR
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;  # so Next.js knows it's HTTPS
    proxy_cache_bypass $http_upgrade;
  }
}

Enable it and reload:

ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx

3. HTTPS with Let's Encrypt

Certbot's Nginx plugin edits the config, requests a certificate, and sets up auto-renewal in one command. Your DNS A record must already point at the server — Let's Encrypt verifies the domain over port 80.

apt install -y certbot python3-certbot-nginx
certbot --nginx -d example.com -d www.example.com

Choose the redirect-HTTP-to-HTTPS option when prompted. Certbot installs a systemd timer that renews ~30 days before expiry, so you never touch it again. (Prefer Caddy's fully-automatic TLS instead? See Automatic HTTPS with Caddy.)

4. Environment variables

Keep secrets out of the image. Put them in a .env file on the server (never commit it) and pass it in at run time:

# /home/deploy/app/.env
DATABASE_URL=postgres://…
AUTH_SECRET=

One Next.js gotcha: NEXT_PUBLIC_* variables are inlined at build time, not read at runtime. If you build the image on the server that's fine, but if you build in CI you must supply those values as build args there — changing them on the box afterwards has no effect until you rebuild.

5. Zero-downtime deploys

A plain docker restart drops every in-flight request during the swap. To avoid that, run a blue-green flip: start the new release alongside the old one on a second port, health-check it, point Nginx at it with a graceful reload (which lets existing connections drain), then retire the old container.

Save this as deploy.sh next to your .env:

#!/usr/bin/env bash
set -euo pipefail

IMAGE=myapp:latest
HEALTH=/                                        # any route that returns 200
UPSTREAM=/etc/nginx/conf.d/nextjs-upstream.conf
BLUE=3000; GREEN=3001

# Which slot is live right now?
if grep -q ":$BLUE;" "$UPSTREAM"; then
  LIVE=$BLUE; NEXT=$GREEN; NEXT_NAME=web-green; LIVE_NAME=web-blue
else
  LIVE=$GREEN; NEXT=$BLUE; NEXT_NAME=web-blue;  LIVE_NAME=web-green
fi

echo "→ Building $IMAGE"
docker build -t "$IMAGE" .

echo "→ Starting $NEXT_NAME on :$NEXT"
docker rm -f "$NEXT_NAME" 2>/dev/null || true
docker run -d --name "$NEXT_NAME" --restart unless-stopped \
  --env-file .env -p "127.0.0.1:$NEXT:3000" "$IMAGE"

echo "→ Waiting for health"
for i in $(seq 30); do
  curl -fsS "http://127.0.0.1:$NEXT$HEALTH" >/dev/null && break
  [ "$i" -eq 30 ] && { echo "unhealthy — rolling back"; docker rm -f "$NEXT_NAME"; exit 1; }
  sleep 1
done

echo "→ Flipping Nginx :$LIVE → :$NEXT"
sudo sed -i "s/:$LIVE;/:$NEXT;/" "$UPSTREAM"
sudo nginx -t && sudo nginx -s reload           # graceful — no dropped requests

echo "→ Retiring $LIVE_NAME"
docker rm -f "$LIVE_NAME" 2>/dev/null || true
echo "✓ Live on :$NEXT"

Now every deploy is git pull && ./deploy.sh. The script alternates between ports 3000 and 3001 on each run, so there's always a healthy container serving traffic while the next one builds and warms up.

Adding a database

If your app needs Postgres, the simplest reliable option is a container on the same box, on a private network, never published to the internet:

# compose.yaml — Postgres only; the app is deployed by deploy.sh above
services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: changeme
      POSTGRES_DB: app
    volumes:
      - ./pgdata:/var/lib/postgresql/data
    ports:
      - "127.0.0.1:5432:5432"   # localhost only

docker compose up -d, point DATABASE_URL at 127.0.0.1:5432, and back up ./pgdata (or pg_dump to off-box storage) on a schedule. For anything you can't afford to lose, a managed Postgres is worth the money.

Troubleshooting

Build gets killed / server freezes during deploy. Almost always memory. Add swap (fallocate -l 2G /swapfile && mkswap /swapfile && swapon /swapfile) as a stopgap, but the real fix is the 4 GB sizing above or building off-box.

502 Bad Gateway from Nginx. The container isn't reachable on the expected port. Check it's running (docker ps), that it's published to 127.0.0.1:3000, and that the app bound to 0.0.0.0 inside the container (the HOSTNAME env in the Dockerfile).

Certificate won't issue. The A record must resolve to this server and port 80 must be open — Let's Encrypt fetches a challenge file over HTTP. Confirm with dig example.com and ufw status.

NEXT_PUBLIC_* value looks stale. It's baked in at build time. Rebuild the image after changing it; restarting the container alone won't update it.

Verification + next steps

You're done when you can load your domain over HTTPS with a valid certificate, run ./deploy.sh and watch traffic flip to the new release with zero failed requests, and reboot the server and have everything come back up on its own (the --restart unless-stopped policy plus Nginx's systemd unit handle that).

From here, add a healthcheck route for the deploy script to hit, wire up off-box database backups, and set up log rotation. The one thing that makes or breaks the experience is the box underneath it — a Hetzner CX22 is the value pick at 2 vCPU / 4 GB; reach for Kamatera when you want to size RAM precisely. See Best VPS for Node.js & Next.js Apps for the ranked picks.

Next steps

Automatic HTTPS with CaddyRun Claude Code with Ollama on Your Own VPSDeploy Coolify on a VPSHow to Deploy AnythingLLM on a VPSHow to Deploy authentik on a VPSHow to Deploy Beszel on a VPSHow to Deploy Bitwarden on a VPSHow to Deploy Checkmate on a VPSHow to Deploy docker-mailserver on a VPSHow to Deploy Gatus on a VPSHow to Deploy Gitea on a VPSHow to Deploy Grafana on a VPSHow to Deploy Headscale on a VPSHow to Deploy Healthchecks on a VPSHow to Deploy Home Assistant on a VPSHow to Deploy Immich on a VPSHow to Deploy Jan on a VPSHow to Deploy LibreChat on a VPSHow to Deploy LocalAI on a VPSHow to Deploy Mailcow on a VPSHow to Deploy Mailu on a VPSHow to Deploy n8n on a VPSHow to Deploy NetBird on a VPSHow to Deploy Netdata on a VPSHow to Deploy Nextcloud on a VPSHow to Deploy Ollama on a VPSHow to Deploy Open WebUI on a VPSHow to Deploy OpenHands on a VPSHow to Deploy Pangolin on a VPSHow to Deploy Passbolt on a VPSHow to Deploy Plausible Analytics on a VPSHow to Deploy Psono on a VPSHow to Deploy Stalwart on a VPSHow to Deploy Supabase on a VPSHow to Deploy TeamPass on a VPSHow to Deploy Twenty CRM on a VPSHow to Deploy Uptime Kuma on a VPSHow to Deploy Vaultwarden on a VPSHow to Deploy wg-easy on a VPSHow to Deploy Zabbix on a VPSDocker & Compose on Ubuntu 26.04Building AI Workflows with n8nInstall Open WebUI with OllamaAdding AI-Powered Insights to Plausible AnalyticsBuilding AI-Powered Apps with Supabase and pgvector

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.