Pobierz CV
Projekty

Jak bezpiecznie wdrożyć Next.js na VPS? Docker, Nginx, sekrety i monitoring

·Łukasz Kopyszko
[Next.js i web development][Next.js][Docker][Nginx][VPS][Security]

Vercel jest wygodny. Nie jest jedyną legalną produkcją dla Next.js. VPS (Docker + Nginx + Cloudflare) ma sens przy stałym koszcie, compliance, istniejącym WP/API obok albo gdy nie chcesz vendor lock-in na ISR. Poniżej checklista, którą stosuję, a nie „hello world z rootem i otwartym 3000 na świat”.

Internet
  → Cloudflare
    → Nginx (TLS, reverse proxy, rate limit)
      → Docker network (prywatna)
        → Next.js (unprivileged)
        → PostgreSQL / Redis
        → (opcjonalnie) API / WordPress tylko w sieci wewnętrznej

Powiązane: wydajność i cache w Next.js 16.3, dostęp do paneli w VPN / Zero Trust.

Przygotowanie VPS

  • Obraz minimalny (Debian/Ubuntu LTS), aktualizacje, automatyczny unattended-upgrades na security.
  • Użytkownik z sudo, logowanie root przez SSH wyłączone.
  • Tylko klucz SSH (ed25519), PasswordAuthentication no.
  • Firewall: 22 z Twojego IP/VPN, 80/443 z Cloudflare (albo z internetu, jeśli bez CF). Nic więcej.
  • fail2ban albo równoważny na sshd.

Port 3000, 5432, 6379 nie słuchają na publicznym interfejsie.

Docker i Compose

Multi-stage, non-root, zero sekretów w warstwach. NEXT_PUBLIC_* wpada do bundle na next build — to nie miejsce na klucz SMTP.

# 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 . .
# sekrety serwerowe przez ARG tylko jeśli MUSISZ ich użyć w buildzie
# (lepiej: runtime env + brak sekretów w 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"]

W next.config: output: "standalone". Health endpoint zwraca 200 bez ujawniania wersji i env.

# docker-compose.yml (skrót)
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:

Sieć internal nie publikuje portów bazy na hosta. Jeśli aplikacja musi wołać Sentry/Stripe, nie ustawiaj internal: true na sieci web (to odcina egress). Postgres i Redis nadal bez ports:.

Nginx: TLS, proxy, limit

# /etc/nginx/nginx.conf (fragment server)
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 tylko na 80 → 443. Jeśli Cloudflare jest Full (strict), cert na originie i tak. Nie włączaj ssl_stapling bez działającego OCSP. Rate limit na /api/ i login, nie na statykę.

Cloudflare

  • Proxy pomarańczowy na apex i www. SSH nie przez 443 „bo wygodnie” — osobny port/VPN.
  • WAF + bot fight na /api i /admin.
  • Nie cache’uj HTML z sesją. Cache assetów /_next/static na długo (immutable).
  • True-Client-IP / CF-Connecting-IP tylko jeśli Nginx ufa CF (allowlist IP Cloudflare).

Sekrety i NEXT_PUBLIC_*

# /etc/kopyszko/web.env.example  (to commitujesz)
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_* jest widoczne w przeglądarce. Tu nie ma haseł.

Plik z prawdziwymi wartościami: root:app, mode 640, poza repo, poza obrazem. Docker BuildKit secret mount jeśli coś musi być w buildzie. Skanuj image (docker scout / Trivy). Nigdy ENV DATABASE_PASSWORD= w Dockerfile.

Agent w CI nie dostaje produkcji. Osobny env preview. Workflow: AI w pracy programisty.

Postgres, Redis, backup

Postgres: użytkownik aplikacji bez SUPERUSER, baza tylko dla apki, backup pg_dump + WAL/off-site (S3, inny region). Test restore co miesiąc. Redis: hasło, brak bind na 0.0.0.0, eviction zgodny z cache (nie trzymaj sesji w Redisie bez TTL).

Backup to nie „volume Docker na tym samym dysku”. To kopia, którą odtwarzasz na pustym VPS.

Logi, health, monitoring

  • stdout kontenerów → journald albo Loki. Nie loguj tokenów, maili, ciał formularzy.
  • HEALTHCHECK + restart policy. Nginx upstream fail → 502, nie wiszące połączenie.
  • Sentry (server + edge ostrożnie z PII). Uptime poza siecią VPS (Healthchecks / Betterstack).
  • Dysk, inode, cert expiry (np. 21 dni), kolejka backupu.

CI/CD i rollback

CI buduje image tagowany SHA, skanuje, puszcza na staging. Produkcja: pull SHA, compose up -d, health, dopiero wtedy przełączenie (albo blue-green dwoma upstreamami Nginx). Rollback = poprzedni tag, nie „poprawimy na serwerze”. Migracje DB w osobnej, odwracalnej komendzie, nie w CMD apki.

Least privilege i aktualizacje

  • Kontener USER nextjs, read-only rootfs jeśli dajesz radę (tmpfs na cache).
  • Brak Dockera socket w kontenerze web.
  • Obrazy z pinowaniem digest, nie :latest na produkcji.
  • Patch OS i image w oknie, nie „kiedy zdążę”.

Przykład apki Next, która musi przeżyć produkcję, nie tylko Vercel preview: Księgowy AI.

FAQ

Czy certbot w kontenerze Nginx?

Może. Prostsze: certbot na hoście albo Cloudflare Origin Cert. Ważne, żeby odnowienie nie wymagało ręcznego SSH o 3 w nocy.

Czy standalone jest obowiązkowe?

Nie, ale mniejszy image i brak node_modules na produkcji to mniej powierzchni. Alternatywa: node .next/standalone jak wyżej.

Czy mogę wystawić Postgres na 5432 „tylko na chwilę”?

Nie. VPN albo SSH tunnel. „Na chwilę” zostaje na kwartał — ten sam antywzorzec co w security WP.

Podsumowanie

Bezpieczny Next na VPS to: nie-root, prywatna sieć Docker, sekrety poza obrazem, Nginx z limitami, Cloudflare jako tarcza, backup z testem restore, health + Sentry, deploy po SHA z rollbackiem. Reszta to wygoda. Root + npm start na 0.0.0.0:3000 to nie produkcja — to laboratorium wystawione do internetu.