How to Deploy Next.js to a VPS
Updated Jul 2026
verified on Ubuntu 26.04 · Jul 2026A battle-tested, step-by-step guide to deploying a production Next.js app to your own VPS with Docker, Nginx, and zero-downtime restarts.
- 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.
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.