opened image

Переносим Next.js проект из PM2 в Docker: защита от критических уязвимостей

Что случилось и почему это важно

 

В декабре 2025 года мир Next.js и React потрясла серия критических уязвимостей. Если Вы используете Next.js - эта статья для Вас.

 

CVE-2025-55182 (React2Shell)

 

Уровень опасности: CVSS 10.0 из 10.0 (максимальный)

Это уязвимость в React Server Components. Злоумышленник может Выполнить любой код на Вашем сервере, просто отправив специально сформированный HTTP-запрос. Без пароля, без авторизации - просто запрос, и Ваш сервер взломан.

Что затронуто:

  • react-server-dom-* версий 19.0.0, 19.1.0, 19.1.1, 19.2.0
  • Next.js 15.x и 16.x (App Router)
  • Next.js начиная с версии 14.3.0-canary.77

Исправлено в:

  • React: 19.0.1, 19.1.2, 19.2.1
  • Next.js: 14.x stable, 15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7, 16.0.7

 

CVE-2025-66478

 

Это та же уязвимость, но специфичная для Next.js. По сути — дубликат CVE-2025-55182, но для понимания: если вы на Next.js с App Router - Вы были уязвимы.

 

CVE-2025-55183 (Source Code Exposure)

 

Уровень опасности: Средний

Позволяет получить исходный код Ваших Server Actions. То есть злоумышленник может увидеть Вашу бизнес-логику. Если Вы случайно захардкодили пароль в коде - его тоже увидят.

Исправлено в: Next.js 15.0.7+, 15.1.11+, 15.2.8+, 15.3.8+, 15.4.10+, 15.5.9+, 16.0.10+

 

Версии 14.x и 13.x не получили исправление этой уязвимости.

 

CVE-2025-55184 (Denial of Service)

 

Уровень опасности: Высокий

Denial of Service (DoS) - специальный запрос вешает Ваш сервер, и он перестаёт отвечать на любые запросы. Сайт просто падает.

Исправлено в: Next.js 13.3+, 14.2.35+, 15.0.7+, 15.1.11+, 15.2.8+, 15.3.8+, 15.4.10+, 15.5.9+, 16.0.10+

 

Зачем переносить в Docker?

 

Вы спросите: "Я обновил Next.js, уязвимости закрыты. Зачем мне Docker?"

Отличный вопрос. Вот зачем:

  1. Изоляция - если завтра найдут новую уязвимость и Ваш сервер взломают, злоумышленник окажется внутри контейнера, а не на Вашем хосте. Это как комната внутри комнаты.
  2. Минимум лишнего - в контейнере только то, что нужно для работы приложения. Меньше софта = меньше потенциальных дыр.
  3. Быстрое обновление - нашли уязвимость? Пересобрали контейнер, перезапустили. Всё. Не нужно SSH на сервер и ручные танцы с бубном.
  4. Одинаковое окружение - "у меня локально работает" больше не аргумент. В контейнере всё одинаково: и у Вас, и на сервере.

 

Как сейчас выглядит Ваш проект (с PM2)

 

Допустим, у Вас типичная ситуация:

 

Переносим Next.js проект из PM2 в Docker защита от критических уязвимостей 1


И Вы запускаете это так:

npm run build
pm2 start ecosystem.config.js

 

Ваш ecosystem.config.js выглядит примерно так:

module.exports = {
  apps: [{
    name: 'my-app',
    script: 'node_modules/next/dist/bin/next',
    args: 'start',
    instances: 2,
    exec_mode: 'cluster'
  }]
}

 

Сейчас мы шаг за шагом перенесём это в Docker.

 

Шаг 1: Обновите Next.js до безопасной версии

 

Прежде чем что-то контейнеризировать, убедитесь что у Вас безопасная версия:

Проверяем текущую версию

npm list next


Обновляем до последней безопасной версии

npm install next@latest react@latest react-dom@latest

 

Безопасные версии (исправлены все уязвимости):

 

ВеткаМинимальная безопасная версия
16.0.x16.0.10
15.5.x15.5.9
15.4.x15.4.10
15.3.x15.3.8
15.2.x15.2.8
15.1.x15.1.11
15.0.x15.0.7
14.x14.2.35 (только DoS-фикс)

 

React (react-server-dom-*): 19.0.1, 19.1.2, 19.2.1

 

Важно: Версии 14.x получили только исправление DoS (CVE-2025-55184). Уязвимость утечки исходного кода (CVE-2025-55183) в них не исправлена. Рекомендуется обновиться до 15.x.

 

Шаг 2: Настройте next.config.js для Docker

 

Откройте Ваш next.config.js и добавьте одну важную строку: output: 'standalone',, пример next.config.js:

 

Переносим Next.js проект из PM2 в Docker защита от критических уязвимостей 2

 

Что делает output: 'standalone'?

 

Когда Вы запускаете npm run build, Next.js создаст папку .next/standalone с минимальным набором файлов для запуска. Это уменьшит размер Docker-образа в 3-5 раз.

После изменения пересоберите проект:

npm run build

 

Проверьте, что появилась папка .next/standalone:

ls -la .next/standalone/

 

Вы должны увидеть файл server.js - это и есть Ваше приложение в одном файле.

 

Шаг 3: Создайте файл .dockerignore

 

Создайте файл .dockerignore в корне проекта. Он говорит Docker, что НЕ копировать в образ:

node_modules

.env
.env.local
.env.development
.env.production.local

.git
.gitignore

.vscode
.idea

ecosystem.config.js
.pm2

*.log
npm-debug.log*

__tests__
*.test.js
*.test.ts
coverage

.next

.DS_Store

 

Разберем указаные директории, файлы:
node_modules - зависимости, которые установим внутри контейнера
.env* - локальные env-файлы с паролями не должны попасть внутрь образа
.git, .gitignore, .vscode, .idea, .DS_Store - git, редакторы, MacOS
ecosystem.config.js, .pm2 - pm2 нам больше не нужен
*.log, npm-debug.log* - логи
__tests__, *.test.js, *.test.ts, coverage - тесты
.next - результаты сборки (соберем внутри контейнера)

 

Почему это важно?

Если не создать .dockerignore, в Ваш образ попадут все файлы, включая .env с паролями. Любой, кто получит доступ к образу, увидит Ваши секреты.

 

Шаг 4: Создайте Dockerfile

 

Создайте файл Dockerfile (без расширения) в корне проекта:

FROM node:20-alpine AS deps

WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production


FROM node:20-alpine AS builder

WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm ci

ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build


FROM node:20-alpine AS runner

WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN apk add --no-cache dumb-init

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

CMD ["dumb-init", "node", "server.js"]

 

Что тут происходит?

Мы используем multi-stage build - три этапа сборки:

  1. deps - только устанавливаем зависимости
  2. builder - собираем приложение
  3. runner - финальный образ только с тем, что нужно для запуска

В финальный образ НЕ попадают:

  • Исходный код (только скомпилированный)
  • devDependencies
  • Файлы сборки

Это и безопаснее, и образ получается маленький (примерно 150-200 МБ вместо 1+ ГБ).

Также в этом Dockerfile мы используем Alpine образы, которые являються минималистичным Linux, поскольку чем меньше размер изначального образа тем меньше уязвимостей. Также устанавливаем dumb-init, поскольку Node.js плохо работает как главный процесс (PID 1) в контейнере, а dumb-init решает эту проблему. Используем отдельного пользователя чтобы наше приложение не работало от root пользователя.

 

Шаг 5: Создайте docker-compose.yml

 

Docker Compose упрощает запуск. Создайте файл docker-compose.yml:

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: my-nextjs-app
    restart: unless-stopped
    ports:
      - "3000:3000"
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
    environment:
      - NODE_ENV=production
    security_opt:
      - no-new-privileges:true
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

 

Раздел лимитов (deploy: resources: limits:) желательно изменить под мощность Вашего сервера.

 

В этом docker-compose.yml мы собираем образ из текущей директории, указываем порт доступа к проекта - 3000 (при необходимости можна изменить, досттаочно просто изменить первую цифру 3000), также ограничиваем ресурсы (это не обязательно, но рекомендуеться), запрещаем повышение привилегий и делаем проверку здоровя приложения. 

 

Шаг 6: Собираем и запускаем

 

Теперь у Вас должна быть такая структура:

Переносим Next.js проект из PM2 в Docker защита от критических уязвимостей 3

 

ecosystem.config.js можно удалить, поскольку он больше не нужен. 

 

Собираем образ:

docker compose build

 

Первая сборка займёт несколько минут. Последующие будут быстрее благодаря кэшированию.

Запускаем:

docker compose up -d

Флаг -d запускает в фоновом режиме (как pm2).

 

Проверяем что работает:
Смотрим статус:

docker compose ps

Смотрим логи:

docker compose logs -f

Проверяем в браузере:

curl http://localhost:3000

 

Пример корректной работы:

 

Переносим Next.js проект из PM2 в Docker защита от критических уязвимостей 4

 

Шаг 7: Основные команды (вместо PM2)

 

Вот шпаргалка. Слева - как было с PM2, справа - как теперь с Docker:

 

ДействиеPM2Docker Compose
Запуститьpm2 start ecosystem.config.jsdocker compose up -d
Остановитьpm2 stop alldocker compose down
Перезапуститьpm2 restart alldocker compose restart
Посмотреть логиpm2 logsdocker compose logs -f
Статусpm2 statusdocker compose ps
Пересобрать после измененийnpm run build && pm2 restart alldocker compose up -d --build

 

Шаг 8: Переменные окружения (секреты)

 

Важно: Никогда не храните пароли в Dockerfile или docker-compose.yml!

Создайте файл .env.production (добавьте его в .gitignore!):

DATABASE_URL=postgresql://user:password@host:5432/db
API_SECRET=your-secret-key

 

Обновите docker-compose.yml:

services:
  app:
    # ... остальное как было ...
    
    env_file:
      - .env.production

 

Шаг 9: Запуск на сервере

 

На Вашем сервере:

1. Установите Docker (Ubuntu/Debian):

apt-get update && apt-get install ca-certificates curl && \
install -m 0755 -d /etc/apt/keyrings && \
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc && \
chmod a+r /etc/apt/keyrings/docker.asc && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null && \
apt-get update && \
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

 

2. Скопируйте проект на сервер (без node_modules и .next):

rsync -avz --exclude 'node_modules' --exclude '.next' --exclude '.git' \
  ./ user@server:/path/to/app/

 

3. Скопируйте .env.production отдельно:

scp .env.production user@server:/path/to/app/

 

4. На сервере:

cd /path/to/app
docker compose up -d --build

 

5. Удалите PM2 (если был установлен глобально):

pm2 kill
pm2 unstartup
npm uninstall -g pm2

 

Что делать при обновлении приложения

 

  1. Внесите изменения в код
  2. Пересоберите и перезапустите:
docker compose up -d --build

 

Docker пересоберёт только изменившиеся слои, это быстро.

 

Что делать при выходе новых уязвимостей

 

 

1. Обновите зависимости:

npm update next react react-dom

 

2. Пересоберите образ:

docker compose build --no-cache
docker compose up -d

 

Флаг --no-cache заставляет пересобрать всё с нуля, включая базовый образ node:20-alpine.

 

Частые проблемы и решения

 

"Приложение не стартует"

 

Смотрите логи:

docker compose logs app

 

"Не могу подключиться к базе данных"

 

Если база данных на том же сервере, используйте host.docker.internal вместо localhost:

DATABASE_URL=postgresql://user:pass@host.docker.internal:5432/db

 

Или добавьте в docker-compose.yml:

services:
  app:
    extra_hosts:
      - "host.docker.internal:host-gateway"

 

"Образ слишком большой"

 

Проверьте что .dockerignore создан и содержит node_modules.

Посмотреть размер образа

docker images | grep nextjs

 

Нормальный размер: 150-300 МБ. Если больше 500 МБ - что-то не так.

 

"После пересборки изменения не применились"

 

Используйте флаг --build:

docker compose up -d --build

 

Если у Вас уже есть Nginx

 

Если на сервере уже крутится Nginx как reverse proxy (например, для SSL), не открывайте порт 3000 наружу.

Измените в docker-compose.yml секцию ports:

services:
  app:
    # ... остальное как было ...
    
    # БЫЛО (доступно всем):
    # ports:
    #   - "3000:3000"
    
    # СТАЛО (только для localhost):
    ports:
      - "127.0.0.1:3000:3000"

 

Теперь контейнер доступен только с этого же сервера (localhost), а не из интернета.

В конфиге Nginx добавьте proxy_pass:

 

server {
    listen 80;
    server_name your-domain.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        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;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_cache_bypass $http_upgrade;
    }
}

Необходимо изменить домен на Ваш.

 

Перезапустите Nginx:

nginx -t && systemctl reload nginx

 

Заключение

 

Вы перенесли проект из PM2 в Docker. Теперь у Вас:

  • Изолированное окружение
  • Приложение работает от непривилегированного пользователя
  • Минимальный образ без лишнего софта
  • Простое обновление одной командой
  • Одинаковое поведение локально и на сервере

При появлении новых уязвимостей вроде CVE-2025-55182 Вам достаточно обновить зависимости и пересобрать образ. Даже если злоумышленник найдёт новую 0-day уязвимость, он окажется внутри контейнера(где будет минимум доступных команд для закрепления и експлуатации сервера), а не на Вашем хосте.