Docker для фронтенда
Docker устраняет главную проблему разработки: окружение у всех разное. Приложение, собранное и запущенное в контейнере, ведёт себя одинаково на ноутбуке, в CI и в production. Разберём, как собрать образ SPA с multi-stage build, раздать статику через nginx и опубликовать образ в registry.
Содержание
- Зачем фронтендеру Docker
- Image и container
- Базовая структура Dockerfile
- Multi-stage build
- nginx: раздача статики и SPA fallback
- Переменные окружения
- .dockerignore
- Non-root и healthcheck
- docker-compose для разработки
- Публикация образа в registry
- Лучшие практики
- Антипаттерны
Зачем фронтендеру Docker
- Одинаковое окружение. Версия Node, системные библиотеки, права — всё зафиксировано в образе. «У меня работает» исчезает.
- Консистентный деплой. Один образ катится на VPS, Kubernetes, Fly.io, Railway — без пересборки под платформу.
- Preview для PR. CI собирает образ каждой ветки и поднимает его — ревьюеры видят изменения в реальном окружении.
- Изоляция. Разные версии Node в одном проекте, зависимости не конфликтуют с системой.
Для статического SPA Docker не обязателен — хватит CDN. Он нужен, когда появляются SSR, API-роуты, WebSocket, БД и управляемые окружения.
Image и container
- Image — неизменяемый шаблон: слои файловой системы, код, команда запуска. Его можно хранить, передавать и версионировать.
- Container — запущенный экземпляр image: изолированный процесс со своей файловой системой и сетью.
Слои image — ключ к скорости: Docker кэширует неизменённые слои и пересобирает только изменившиеся. Поэтому порядок инструкций в Dockerfile критичен (см. ниже).
Базовая структура Dockerfile
Минимальный Dockerfile для SPA:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS production
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Что здесь важно:
node:20-alpine— лёгкий образ Node (~50 МБ против ~350 МБ у полного).COPY package*.jsonдоCOPY .— слой с зависимостями не пересобирается, пока не меняетсяpackage-lock.json(кэш слоёв).npm ciвместоnpm install— ставит ровно версии из lock-файла, быстрее и воспроизводимо.nginx:1.27-alpine— финальный образ раздаёт статику, Node в нём нет.
Multi-stage build
Multi-stage позволяет не тащить инструменты сборки в production-образ:
# Этап 1: сборка
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Этап 2: production — только результат сборки
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
Результат: финальный образ содержит только собранный dist/ и nginx. node_modules, исходники и инструменты сборки в него не попадают.
Размер: builder-этап ~400 МБ, production-образ ~25 МБ. Это быстрее выкатка, меньше поверхность атаки.
nginx: раздача статики и SPA fallback
Голый nginx отдаёт файлы, но SPA с client-side роутингом ломается на прямых переходах: /about — это не файл, а маршрут в React Router. Нужен fallback на index.html.
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA fallback: несуществующий путь → index.html
location / {
try_files $uri $uri/ /index.html;
}
# Кэш для статики с хэшем в имени файла
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Практичные дополнения:
# gzip
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
# проксирование API на бэкенд
location /api/ {
proxy_pass http://api:3000;
proxy_set_header Host $host;
}
SPA fallback (строка try_files) — самая частая причина «работает локально, 404 на деплое».
Переменные окружения
Переменные окружения во фронтенде бывают двух видов.
Build-time (вшиваются в бандл)
VITE_*, NEXT_PUBLIC_* подставляются при сборке:
FROM node:20-alpine AS builder
ARG VITE_API_URL=https://api.example.com
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
Изменить их можно только пересборкой образа. Это безопасно только для несекретных значений — они попадают в публичный JS.
Runtime (задаются при запуске контейнера)
docker run -e API_URL=https://api.example.com -p 8080:80 my-app
Приложение читает их через window.env, потому что после сборки подставить их в бандл нельзя:
<!-- nginx -->
<script>
window.env = { API_URL: "${API_URL}" };
</script>
# nginx подставляет значение ENV в шаблон
location /config.js {
default_type application/javascript;
set_by_lua $api_url 'return os.getenv("API_URL") or ""';
return 200 'window.env = { API_URL: "$api_url" };';
}
Плюс runtime-переменных: один образ деплоится на staging и production без пересборки — меняются только переменные. Для этого nginx запускается через шаблон с подстановкой (envsubst или lua).
.dockerignore
Контекст сборки передаётся в демон Docker целиком. node_modules и dist в нём — десятки тысяч файлов и гигабайты трафика.
node_modules
dist
.git
.gitignore
.env
.env.*
*.log
Dockerfile
.dockerignore
Результат: быстрая сборка, меньше мусора в контексте и риск случайного копирования секретов из .env.
Non-root и healthcheck
Non-root пользователь
Процессы в контейнере по умолчанию работают от root — если контейнер скомпрометируют, у атакующего будут root-права в хосте (при плохой изоляции). Правило: запускать приложение от непривилегированного пользователя.
FROM node:20-alpine AS builder
RUN npm ci && npm run build
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
# официальный nginx-образ уже запускается от не-root пользователя nginx
EXPOSE 80
Для Node-приложений: USER node после установки зависимостей и корректных прав на каталоги.
Healthcheck
HEALTHCHECK — команда, которую Docker выполняет периодически. Оркестратор (Kubernetes, Docker Swarm) перезапускает контейнеры, у которых healthcheck не проходит.
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -qO- http://localhost/healthz || exit 1
location = /healthz {
access_log off;
return 200 "ok";
}
Без healthcheck оркестратор не знает, что под мёртв, и будет слать на него трафик.
docker-compose для разработки
docker-compose.yml описывает все сервисы проекта одним файлом:
services:
web:
build:
context: .
dockerfile: Dockerfile
target: builder
command: npm run dev
volumes:
- .:/app
- /app/node_modules
ports:
- "3000:3000"
environment:
- API_URL=http://api:3001
api:
build: ./backend
ports:
- "3001:3001"
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
Преимущества:
- Одна команда
docker compose upподнимает фронтенд, API и БД. - Порт
3000:3000пробрасывает локальный порт в контейнер. - Volume
.:/appмонтирует исходники — горячая перезагрузка в dev. - Сеть между сервисами по имени (
http://api:3001).
Публикация образа в registry
Собранный образ публикуется в registry (Docker Hub, GHCR, ECR), чтобы его можно было скачать на сервер или в оркестратор.
# собрать с тегом
docker build -t ghcr.io/myorg/my-app:1.2.0 .
# запустить локально
docker run -p 8080:80 -e API_URL=https://api.example.com ghcr.io/myorg/my-app:1.2.0
# авторизоваться и запушить
docker login ghcr.io -u USERNAME
docker push ghcr.io/myorg/my-app:1.2.0
Версионирование:
docker tag ghcr.io/myorg/my-app:1.2.0 ghcr.io/myorg/my-app:latest
docker push ghcr.io/myorg/my-app:latest
latest — плавающий тег для быстрых деплоев; для production катится точная версия (1.2.0), чтобы можно было откатиться.
Лучшие практики
1. Multi-stage build всегда
Production-образ — только результат сборки, без инструментов разработки.
2. Порядок COPY в Dockerfile
package*.json → npm ci → остальной код. Зависимости кэшируются, пока не меняется lock-файл.
3. Пиннинг версий
node:20-alpine — лучше, чем node:latest: образы обновляются, а сборка должна быть воспроизводимой. Полный пиннинг по digest — для критичных систем.
4. Runtime-переменные вместо пересборки
Один образ на все окружения, конфигурация — через ENV.
5. Non-root пользователь
Запускай процессы от непривилегированного пользователя.
6. Healthcheck обязателен
Без него оркестратор не видит, что контейнер мёртв.
7. .dockerignore
Не передавай в контекст node_modules, dist, .git и .env.
Антипаттерны
1. Один этап, всё в одном образе
# ❌ Плохо: 500 МБ образ с node_modules и исходниками
FROM node:20
COPY . .
RUN npm ci
RUN npm run build
CMD ["npx", "serve", "dist"]
2. COPY всего до установки зависимостей
# ❌ Плохо: кэш слоёв не работает, npm ci пересобирается на каждую правку
COPY . .
RUN npm ci
3. .env в контексте сборки
# ❌ Плохо: секреты попадают в образ и registry
COPY .env .
4. Запуск от root
# ❌ Плохо: процесс с максимальными правами
FROM node:20
CMD ["node", "server.js"]
5. nginx без SPA fallback
# ❌ Плохо: прямой переход на /about отдаёт 404
location / { root /usr/share/nginx/html; }
6. «Плавающий» latest в production
# ❌ Плохо: нельзя откатиться к конкретной версии
docker run ghcr.io/myorg/my-app:latest
Ключевые тезисы для интервью
- Image — шаблон, container — запущенный процесс с изоляцией файловой системы и сети.
- Multi-stage build: builder (node) → production (nginx), в финале только
dist/. COPY package*.jsonдо кода — слои кэшируются, пока не меняется lock-файл.npm ci— воспроизводимая установка из lock-файла.- SPA fallback в nginx —
try_files $uri $uri/ /index.html. - Build-time переменные (
VITE_*) вшиваются в бандл; runtime — через ENV при старте. .dockerignoreисключаетnode_modules,dist,.git,.envиз контекста сборки.- Non-root пользователь и
HEALTHCHECK— базовые требования безопасности и оркестрации. docker-compose upподнимает фронтенд, API и БД одной командой.- Пиннинг версий и точные теги вместо
latest— для откатов и воспроизводимости.
Заключение
Docker для фронтенда — это консистентные окружения и консистентный деплой. Ключевые практики: multi-stage build, кэширование слоёв, runtime-переменные, non-root и healthcheck. Освоив их, вы сможете катить одно и то же приложение на любой платформе без сюрпризов.
Ключевое для Middle+ разработчика:
- Собирать лёгкий production-образ через multi-stage.
- Настраивать nginx: статика, SPA fallback, кэш, прокси API.
- Различать build-time и runtime-переменные.
- Публиковать образы с точными тегами и откатываться.
- Использовать docker-compose для локальной разработки и preview.