How to Securely Deploy Next.js on a VPS – Docker, Nginx, Secrets and Monitoring
Vercel is convenient. It is not the only legitimate production for Next.js. A VPS (Docker + Nginx + Cloudflare) pays when you want a fixed bill, compliance, an existing WP/API beside the app, or no vendor lock-in on ISR. Below is the checklist I actually use — not “hello world as root with 3000 on the internet”.
Internet
→ Cloudflare
→ Nginx (TLS, reverse proxy, rate limit)
→ Docker network (private)
→ Next.js (unprivileged)
→ PostgreSQL / Redis
→ (optional) API / WordPress only on the internal net
Related: performance and cache in Next.js 16.3, panel access in VPN / Zero Trust.
Prepare the VPS
- Minimal image (Debian/Ubuntu LTS), patches, unattended-upgrades for security.
- A sudo user; root SSH disabled.
- SSH keys only (ed25519),
PasswordAuthentication no. - Firewall: 22 from your IP/VPN, 80/443 from Cloudflare (or the world if you skip CF). Nothing else.
- fail2ban or equivalent on sshd.
Ports 3000, 5432, 6379 do not listen on the public interface.
Docker and Compose
Multi-stage, non-root, no secrets in layers. NEXT_PUBLIC_* is inlined at next build — not the place for an SMTP password.
# Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# server secrets via ARG only if the build truly needs them
# (prefer runtime env; never put secrets in NEXT_PUBLIC_*)
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --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"]
In next.config: output: "standalone". The health endpoint returns 200 without leaking versions or env.
# docker-compose.yml (abbreviated)
services:
web:
build: .
restart: unless-stopped
env_file: /etc/kopyszko/web.env
expose:
- "3000"
networks: [internal]
depends_on:
db:
condition: service_healthy
nginx:
image: nginx:1.27-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
networks: [internal, edge]
depends_on: [web]
db:
image: postgres:16-alpine
restart: unless-stopped
env_file: /etc/kopyszko/db.env
volumes:
- pgdata:/var/lib/postgresql/data
networks: [internal]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
retries: 5
networks:
internal: {}
edge: {}
volumes:
pgdata:
internal does not publish database ports on the host. If the app must call Sentry/Stripe, do not set internal: true on the web network (that cuts egress). Postgres and Redis still have no ports:.
Nginx: TLS, proxy, limits
# /etc/nginx/nginx.conf (server excerpt)
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header X-Frame-Options SAMEORIGIN always;
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://web:3000;
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;
}
location / {
proxy_pass http://web:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
HTTP on 80 only to redirect to 443. With Cloudflare Full (strict), you still want a real origin cert. Rate-limit /api/ and login, not static assets.
Cloudflare
- Orange-cloud apex and www. SSH is not “just 443 because it is easy” — separate port or VPN.
- WAF + bot fight on
/apiand/admin. - Do not cache session HTML. Cache
/_next/staticfor a long time (immutable). - Trust CF-Connecting-IP only if Nginx allowlists Cloudflare IPs.
Secrets and NEXT_PUBLIC_*
# /etc/kopyszko/web.env.example (this file is committed)
DATABASE_URL=postgresql://app:CHANGE_ME@db:5432/app
REDIS_URL=redis://redis:6379/0
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32
NEXTAUTH_URL=https://example.com
SENTRY_DSN=
NEXT_PUBLIC_SITE_URL=https://example.com
# NEXT_PUBLIC_* is visible in the browser. No passwords here.
Real values: root:app, mode 640, outside git, outside the image. BuildKit secret mounts if the build truly needs a secret. Scan images (Docker Scout / Trivy). Never ENV DATABASE_PASSWORD= in the Dockerfile.
The CI agent does not get production credentials. Separate preview env. Workflow: AI coding workflow.
Postgres, Redis, backup
Postgres: app role without SUPERUSER, one database, pg_dump + WAL/off-site (S3, another region). Test restore monthly. Redis: password, not bound to 0.0.0.0, eviction that matches cache (sessions need TTL).
A Docker volume on the same disk is not a backup. A backup is something you restore onto an empty VPS.
Logs, health, monitoring
- Container stdout → journald or Loki. Do not log tokens, emails, form bodies.
HEALTHCHECK+ restart policy. Nginx upstream failure → 502, not a hung socket.- Sentry (be careful with PII on server/edge). Uptime from outside the VPS network.
- Disk, inodes, cert expiry (e.g. 21 days), backup job queue.
CI/CD and rollback
CI builds an image tagged with the git SHA, scans it, deploys staging. Production: pull SHA, compose up -d, health, then cut over (or blue-green with two Nginx upstreams). Rollback is the previous tag, not “we will fix it on the box”. DB migrations are a separate, reversible command, not the app CMD.
Least privilege and updates
- Container
USER nextjs, read-only rootfs if you can (tmpfs for cache). - No Docker socket in the web container.
- Pin image digests; no
:latestin production. - Patch the OS and images on a schedule.
A Next app that has to survive production, not only a Vercel preview: Księgowy AI.
FAQ
Certbot inside the Nginx container?
Possible. Simpler: certbot on the host or a Cloudflare Origin cert. Renewal must not need a 3 a.m. SSH.
Is standalone mandatory?
No, but a smaller image without production node_modules is a smaller surface. The snippet above is the usual path.
Can I expose Postgres on 5432 “just for a minute”?
No. VPN or an SSH tunnel. “Just for a minute” lasts a quarter — the same anti-pattern as in the WordPress security checklist.
Summary
A secure Next.js VPS is: non-root, a private Docker network, secrets outside the image, Nginx with limits, Cloudflare as a shield, backups you have restored, health + Sentry, SHA deploys with rollback. Everything else is comfort. Root + npm start on 0.0.0.0:3000 is not production — it is a lab on the public internet.