Dockerfile для React (Vite) и статической раздачи

Фронтенд (Vite/React) в продакшене - это статические файлы (HTML, JS, CSS). Собираем их в Node-контейнере, раздаём через nginx. Тот же multi-stage, что и для Go, но со своей спецификой.

Базовый Dockerfile

# ---------- build ----------
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# ---------- serve ----------
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Stage 1 (build) - устанавливает зависимости и собирает dist/. Stage 2 (serve) - копирует готовую статику в nginx.

`npm ci` (Clean Install) - устанавливает зависимости строго по `package-lock.json`. Не модифицирует lock-файл, быстрее в CI. Обязательно наличие `package-lock.json` в репозитории. `npm install` может обновить версии - это нежелательно в CI/CD.

SPA routing: проблема 404

React Router обрабатывает маршруты на клиенте. Но если пользователь обновит страницу на /course/docker/why-docker, nginx ищет файл по этому пути - не находит - отдаёт 404.

Решение - настроить nginx отдавать index.html для всех маршрутов:

# nginx.conf
server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    # SPA fallback - все маршруты → index.html
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Кэширование статики
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # Не кэшировать index.html (чтобы обновления доезжали)
    location = /index.html {
        expires -1;
        add_header Cache-Control "no-store, no-cache, must-revalidate";
    }
}

Обновлённый Dockerfile:

FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Без кастомного nginx.conf обновление страницы на вложенном маршруте (`/course/docker/lesson-3`) вернёт 404. Это самый частый баг при контейнеризации SPA.

Переменные окружения в runtime

Vite подставляет import.meta.env.VITE_* переменные при сборке. После npm run build они вшиты в JS-бандл. Проблема: разные окружения (staging, production) требуют разных значений.

Способ 1: Build args (простой)

FROM node:20-alpine AS build
ARG VITE_API_URL=http://localhost:8080
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN VITE_API_URL=${VITE_API_URL} npm run build
# Для staging
docker build --build-arg VITE_API_URL=https://staging.example.com -t frontend:staging .

# Для production
docker build --build-arg VITE_API_URL=https://api.example.com -t frontend:prod .

Минус: нужно пересобирать образ для каждого окружения.

Способ 2: Runtime-подстановка (гибкий)

Создаём entrypoint-скрипт, который подставляет переменные при запуске контейнера:

#!/bin/sh
# docker-entrypoint.sh
# Подставляем переменные окружения в JS-бандл
for file in /usr/share/nginx/html/assets/*.js; do
  sed -i "s|__VITE_API_URL__|${VITE_API_URL}|g" "$file"
done
exec nginx -g "daemon off;"

В коде React используй плейсхолдер:

const API_URL = import.meta.env.VITE_API_URL || '__VITE_API_URL__';
Build args - если одна среда или CI собирает разные образы. Runtime-подстановка - если один образ деплоится в разные среды. Для большинства проектов build args достаточно.

Оптимизация размера образа

# Базовый nginx:alpine           ~40 МБ
# + dist (React/Vite)            ~5-15 МБ
# Итого:                         ~45-55 МБ

Советы по уменьшению:

  1. .dockerignore - исключи node_modules, .git, dist
  2. npm ci --production=false - devDependencies нужны для сборки, но не попадут в runtime (другой stage)
  3. Vite code splitting - build.rollupOptions.output.manualChunks - меньше JS = меньше образ

Gzip и Brotli

nginx может отдавать сжатые файлы. Добавь в nginx.conf:

gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;

Или пересобери статику с pre-compression (vite-plugin-compression) - nginx отдаст готовые .gz файлы без нагрузки на CPU.

Полный production Dockerfile

# ---------- build ----------
FROM node:20-alpine AS build
ARG VITE_API_URL=http://localhost:8080
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN VITE_API_URL=${VITE_API_URL} npm run build

# ---------- serve ----------
FROM nginx:alpine

# Кастомный конфиг для SPA + кэширование + gzip
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Статика из build stage
COPY --from=build /app/dist /usr/share/nginx/html

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Мини-задание

  • Собери Dockerfile для React/Vite приложения с multi-stage
  • Создай nginx.conf с try_files для SPA
  • Проверь: открой вложенный маршрут, обнови страницу - должен работать, а не 404
  • Посмотри размер образа: docker images
  • Попробуй --build-arg VITE_API_URL=https://api.example.com

Итог

  • Multi-stage для фронта: Node (сборка) → nginx (раздача). Образ ~45-55 МБ.
  • nginx.conf с try_files $uri /index.html - обязателен для SPA (React Router).
  • Кэширование: статика на 1 год (immutable), index.html - без кэша (no-store).
  • Переменные: build args (просто) или runtime-подстановка (гибко).
  • npm ci вместо npm install - детерминированная установка по lock-файлу.

Типичная ошибка

Забыть nginx.conf с try_files. Результат: SPA работает при переходе по ссылкам (React Router), но при обновлении страницы (F5) на /course/docker/lesson-3 - 404. Это ловится только при тестировании в контейнере, потому что vite dev обрабатывает все маршруты сам.

Вторая ошибка - npm install вместо npm ci в Dockerfile. npm install может обновить lock-файл, и сборка в CI даст другой результат, чем локально.

Мини-практика (10-15 минут)

Создай минимальное React-приложение и контейнеризируй его:

# 1. Создай проект
npm create vite@latest /tmp/docker-frontend - --template react
cd /tmp/docker-frontend
npm install

# 2. Напиши Dockerfile (multi-stage: node → nginx)
# 3. Напиши nginx.conf с try_files

# 4. Собери и запусти
docker build -t frontend-test .
docker run --rm -p 8080:80 frontend-test

# 5. Проверь SPA routing
# Открой http://localhost:8080
# Перейди по внутренней ссылке
# Обнови страницу (F5) - не должно быть 404

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.