Download CV
Projects

How to Securely Deploy Next.js on a VPS – Docker, Nginx, Secrets and Monitoring

·Łukasz Kopyszko
[Next.js & Web Development][Next.js][Docker][Nginx][VPS][Security]

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 /api and /admin.
  • Do not cache session HTML. Cache /_next/static for 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 :latest in 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.