Skip to content

How to Deploy Next.js to a VPS

Updated Sep 2026

verified on Ubuntu 26.04 · Jul 2026
We earn commissions when you shop through the links below. Full disclosure →

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 recommend a 2 vCPU / 4 GB VPS. Best VPS for Node.js & Next.js Apps ranks the current options by price.

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.

Where to host itaffiliate disclosure
Hetzner Cloudrun it on
2 vCPU · 4 GB RAM · 80 GB SSD · $23.59/mo
Get Hetzner Cloud (opens in new tab)
Kamaterafree trial
1 vCPU · 1 GB RAM · 20 GB SSD · $4.00/mo
Start free on Kamatera → (opens in new tab)
DigitalOceanalso works on
1 vCPU · 1 GB RAM · 25 GB SSD · $6.00/mo
Deploy on DigitalOcean → (opens in new tab)

Paid link — we earn a commission if you shop through it.

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 — 2 vCPU / 4 GB is the right tier. See Best VPS for Node.js & Next.js Apps for the ranked picks.

Next steps

Automatic HTTPS with Caddy →Run Claude Code with Ollama on Your Own VPS →Deploy Coolify on a VPS →How to Deploy Actual Budget on a VPS →How to Deploy AFFiNE on a VPS →How to Deploy AnythingLLM on a VPS →How to Deploy Appwrite on a VPS →How to Deploy Audiobookshelf on a VPS →How to Deploy Authelia on a VPS →How to Deploy authentik on a VPS →How to Deploy Baserow on a VPS →How to Deploy Beszel on a VPS →How to Deploy Bitwarden on a VPS →How to Deploy BookStack on a VPS →How to Deploy CapRover on a VPS →How to Deploy Checkmate on a VPS →How to Deploy copyparty on a VPS →How to Deploy Directus on a VPS →How to Deploy Discourse on a VPS →How to Deploy docker-mailserver on a VPS →How to Deploy Docmost on a VPS →How to Deploy Dokku on a VPS →How to Deploy Dokploy on a VPS →How to Deploy Firefly III on a VPS →How to Deploy Flarum on a VPS →How to Deploy Forgejo on a VPS →How to Deploy Gatus on a VPS →How to Deploy Ghost on a VPS →How to Deploy Ghostfolio on a VPS →How to Deploy Gitea on a VPS →How to Deploy a GitHub Actions Self-Hosted Runner on a VPS →How to Deploy GitLab on a VPS →How to Deploy GlitchTip on a VPS →How to Deploy Grafana on a VPS →How to Deploy Graylog on a VPS →How to Deploy Headscale on a VPS →How to Deploy Healthchecks on a VPS →How to Deploy Home Assistant on a VPS →How to Deploy Immich on a VPS →How to Deploy Infisical on a VPS →How to Deploy Invoice Ninja on a VPS →How to Deploy Jan on a VPS →How to Deploy Jellyfin on a VPS →How to Deploy Kanboard on a VPS →How to Deploy Karakeep on a VPS →How to Deploy Keycloak on a VPS →How to Deploy Leantime on a VPS →How to Deploy LibreChat on a VPS →How to Deploy Linkwarden on a VPS →How to Deploy LocalAI on a VPS →How to Deploy Mailcow on a VPS →How to Deploy Mailu on a VPS →How to Deploy Matomo on a VPS →How to Deploy Mattermost on a VPS →How to Deploy Meilisearch on a VPS →How to Deploy Memos on a VPS →How to Deploy a Minecraft Server on a VPS →How to Deploy n8n on a VPS →How to Deploy Navidrome on a VPS →How to Deploy NetBird on a VPS →How to Deploy Netdata on a VPS →How to Deploy Nextcloud on a VPS →How to Deploy Nginx Proxy Manager on a VPS →How to Deploy NocoDB on a VPS →How to Deploy ntfy on a VPS →How to Deploy Odoo on a VPS →How to Deploy Ollama on a VPS →How to Deploy ONLYOFFICE Docs on a VPS →How to Deploy Open WebUI on a VPS →How to Deploy OpenHands on a VPS →How to Deploy OpenObserve on a VPS →How to Deploy OpenProject on a VPS →How to Deploy Outline on a VPS →How to Deploy Pangolin on a VPS →How to Deploy Paperless-ngx on a VPS →How to Deploy Passbolt on a VPS →How to Deploy Plane on a VPS →How to Deploy Plausible Analytics on a VPS →How to Deploy Plex Media Server on a VPS →How to Deploy Pocket ID on a VPS →How to Deploy PocketBase on a VPS →How to Deploy Prometheus on a VPS →How to Deploy Psono on a VPS →How to Deploy Radarr on a VPS →How to Deploy Rocket.Chat on a VPS →How to Deploy SigNoz on a VPS →How to Deploy Sonarr on a VPS →How to Deploy Stalwart on a VPS →How to Deploy Stirling-PDF on a VPS →How to Deploy Supabase on a VPS →How to Deploy Synapse on a VPS →How to Deploy Taiga on a VPS →How to Deploy TeamPass on a VPS →How to Deploy Tinyauth on a VPS →How to Deploy Traefik on a VPS →How to Deploy Trilium on a VPS →How to Deploy Twenty CRM on a VPS →How to Deploy Umami on a VPS →How to Deploy Uptime Kuma on a VPS →How to Deploy Vaultwarden on a VPS →How to Deploy Vikunja on a VPS →How to Deploy wg-easy on a VPS →How to Deploy Wiki.js on a VPS →How to Deploy WordPress on a VPS →How to Deploy Zabbix on a VPS →How to Deploy Zitadel on a VPS →How to Deploy Zulip on a VPS →Docker & Compose on Ubuntu 26.04 →Building AI Workflows with n8n →Install Open WebUI with Ollama →Adding AI-Powered Insights to Plausible Analytics →Building 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.