Перейти к содержимому

25. Деплой в Docker

Представь, что твоё приложение — это сложный механизм: часы с десятками шестерёнок, пружин и колёсиков. Ты собрал его у себя на столе, всё работает идеально. Но стоит передать его другому человеку — что-то идёт не так. Другая версия Node.js, другие переменные окружения, другой Linux. Docker решает эту проблему: ты упаковываешь весь механизм вместе с инструментами, столом и освещением в один ящик — и он работает везде одинаково! 📦


Без Docker │ С Docker
─────────────────────────────────────┼──────────────────────────────────────
"У меня работает!" 🤷 │ Работает везде одинаково ✅
Node.js 18 на сервере, 20 локально │ Одна версия Node.js везде ✅
Ручная установка зависимостей │ Всё в образе ✅
Сложное масштабирование │ docker scale --replicas=5 ✅
Зависимость от ОС сервера │ Изолированный контейнер ✅
Нет воспроизводимых сборок │ Один образ = один результат ✅
Откат версии — боль │ docker pull myapp:v1.2.3 ✅

Docker особенно полезен когда:

  • Деплоишь на VPS (DigitalOcean, Hetzner, AWS EC2) — полный контроль
  • Нужен Kubernetes — оркестрация контейнеров
  • Несколько сервисов — Next.js + PostgreSQL + Redis в одном compose
  • CI/CD в компании — единый артефакт для всех окружений

Это первое, что нужно добавить в next.config.mjs перед написанием Dockerfile:

next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
// ☝️ Без этого Docker-образ будет весить 1+ ГБ!
// С этим — 100-200 МБ.
// Почему это работает?
// Next.js анализирует, какие файлы из node_modules реально нужны,
// и копирует только их. Плюс создаёт self-contained сервер
// в .next/standalone/server.js — без npm install!
};
export default nextConfig;

Что происходит после next build с standalone:

.next/
├── standalone/ ← весь нужный код + минимум зависимостей
│ ├── server.js ← запускаем это!
│ ├── node_modules/ ← только нужные пакеты (не все 847!)
│ └── .next/
│ └── server/ ← серверные компоненты
├── static/ ← статические ассеты (CSS, JS, изображения)
└── ...
# С standalone: node_modules/ = ~30 МБ в образе!

🏗️ Multi-stage build — секрет маленьких образов

Заголовок раздела «🏗️ Multi-stage build — секрет маленьких образов»

Multi-stage build — это когда сборка происходит в несколько этапов, и в финальный образ попадает только то, что нужно для запуска:

Этап 1: deps Устанавливаем зависимости
Этап 2: builder Собираем приложение (next build)
Этап 3: runner Только то, что нужно для запуска

Без multi-stage: в образ попали бы все инструменты сборки, исходники, dev-зависимости. С ним — только production-артефакты.


# Dockerfile
# ─── Этап 1: Установка зависимостей ───────────────────────────────────────────
FROM node:20-alpine AS deps
# Устанавливаем libc6-compat для совместимости с некоторыми npm-пакетами
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Копируем файлы с зависимостями
COPY package.json package-lock.json* ./
# Устанавливаем зависимости (только production в финальном образе)
RUN npm ci
# ─── Этап 2: Сборка приложения ────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
# Копируем зависимости из предыдущего этапа
COPY --from=deps /app/node_modules ./node_modules
# Копируем исходный код
COPY . .
# Переменные окружения для сборки (не секретные!)
# Секретные передаются через --build-arg или --secret
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Сборка Next.js
RUN npm run build
# ─── Этап 3: Production runner ────────────────────────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Создаём непривилегированного пользователя (безопасность!)
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Копируем статические ассеты
COPY --from=builder /app/public ./public
# Настраиваем права для standalone
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Копируем standalone-сервер (с нужными node_modules)
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# Копируем статику отдельно (она не входит в standalone)
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Переключаемся на непривилегированного пользователя
USER nextjs
# Указываем порт (документация для Docker)
EXPOSE 3000
# Переменные окружения для runtime
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Запуск standalone-сервера
CMD ["node", "server.js"]

Разберём каждый этап детально:

# Почему node:20-alpine, а не node:20?
# alpine — минимальный Linux на базе Alpine (5 МБ vs 900 МБ для Debian)
# Итого: node:20-alpine = ~170 МБ, node:20 = ~1 ГБ
# apk add libc6-compat — некоторые npm-пакеты собраны под glibc,
# а Alpine использует musl libc. Этот пакет обеспечивает совместимость.
# npm ci vs npm install:
# npm ci — строго по lock-файлу, быстрее, идемпотентно
# npm install — может обновлять версии, медленнее
# addgroup/adduser — запуск от непривилегированного пользователя
# Если контейнер взломают, у хакера не будет root-прав!
# NEXT_TELEMETRY_DISABLED=1 — отключаем телеметрию Next.js в образе

.dockerignore
# Зависимости (переустановим внутри контейнера)
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Сборка Next.js
.next
out
# Git
.git
.gitignore
# Документация
README.md
CHANGELOG.md
docs/
# Тесты
__tests__
*.test.ts
*.test.tsx
*.spec.ts
*.spec.tsx
coverage/
.nyc_output
# IDE и редакторы
.vscode
.idea
*.swp
*.swo
# Переменные окружения (НИКОГДА не копируем!)
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# CI/CD
.github
.gitlab-ci.yml
# Локальные скрипты
Makefile
docker-compose*.yml
# OS
.DS_Store
Thumbs.db

Без .dockerignore каждый COPY . . скопирует всё, включая node_modules (500 МБ!), что многократно замедлит сборку.


Окно терминала
# ──── Сборка образа ────────────────────────────────────────────────────────────
# Базовая сборка
docker build -t my-next-app .
# С тегом версии
docker build -t my-next-app:1.0.0 .
docker build -t my-next-app:latest .
# С аргументами сборки (для build-time переменных)
docker build \
--build-arg NEXT_PUBLIC_API_URL=https://api.my-app.com \
-t my-next-app .
# Посмотреть размер образа
docker image ls my-next-app
# ──── Запуск контейнера ────────────────────────────────────────────────────────
# Базовый запуск
docker run -p 3000:3000 my-next-app
# С переменными окружения
docker run \
-p 3000:3000 \
-e DATABASE_URL=postgresql://user:pass@host:5432/db \
-e NEXTAUTH_SECRET=my-secret \
my-next-app
# Из .env файла
docker run \
-p 3000:3000 \
--env-file .env.production \
my-next-app
# В фоне
docker run -d \
--name my-next-app \
-p 3000:3000 \
--restart unless-stopped \
--env-file .env.production \
my-next-app
# ──── Управление ───────────────────────────────────────────────────────────────
# Статус контейнеров
docker ps
docker ps -a # включая остановленные
# Логи
docker logs my-next-app
docker logs -f my-next-app # следить в реальном времени
# Зайти внутрь контейнера
docker exec -it my-next-app sh
# Остановить/удалить
docker stop my-next-app
docker rm my-next-app
# Очистка (удаляет неиспользуемые образы, контейнеры, сети)
docker system prune -a

В Docker есть несколько способов передавать переменные:

Окно терминала
# Способ 1: Флаг -e (одна переменная)
docker run -e DATABASE_URL=postgresql://... my-next-app
# Способ 2: --env-file (файл с переменными)
docker run --env-file .env.production my-next-app
# Способ 3: В docker-compose.yml (рекомендуется для разработки)
# см. секцию ниже
# Способ 4: Docker Secrets (для production!)
echo "postgresql://user:password@db:5432/mydb" | docker secret create db_url -
docker service create \
--secret db_url \
--name my-next-app \
my-next-app:latest
Окно терминала
# .env.production — НЕ коммитить в Git!
DATABASE_URL=postgresql://user:[email protected]:5432/mydb
NEXTAUTH_SECRET=very-long-random-string-at-least-32-characters
NEXTAUTH_URL=https://my-app.com
STRIPE_SECRET_KEY=sk_live_...
REDIS_URL=redis://redis:6379

Важно: переменные, начинающиеся с NEXT_PUBLIC_, встраиваются в сборку во время next build. Их нельзя изменить в runtime! Для runtime-переменных используй серверные переменные окружения.


docker-compose.yml
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- '3000:3000'
environment:
DATABASE_URL: postgresql://postgres:password@db:5432/mydb
REDIS_URL: redis://redis:6379
NEXTAUTH_SECRET: local-dev-secret-not-for-production
NEXTAUTH_URL: http://localhost:3000
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: mydb
ports:
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- '6379:6379'
volumes:
- redis_data:/data
command: redis-server --appendonly yes
volumes:
postgres_data:
redis_data:
Окно терминала
# Запустить всё одной командой
docker compose up -d
# Посмотреть логи всех сервисов
docker compose logs -f
# Пересобрать образ (после изменений в коде)
docker compose up -d --build
# Остановить
docker compose down
# Удалить вместе с данными
docker compose down -v

Nginx стоит перед Next.js и занимается:

  • Терминацией SSL
  • Кэшированием статики
  • Rate limiting
  • Балансировкой нагрузки
nginx.conf
events {
worker_connections 1024;
}
http {
# Кэширование статики
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=STATIC:10m inactive=7d use_temp_path=off;
upstream nextjs {
server app:3000;
# Для нескольких инстансов:
# server app1:3000;
# server app2:3000;
# server app3:3000;
}
server {
listen 80;
server_name my-app.com www.my-app.com;
# Редирект на HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name my-app.com www.my-app.com;
ssl_certificate /etc/letsencrypt/live/my-app.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/my-app.com/privkey.pem;
# Заголовки безопасности
add_header X-Frame-Options SAMEORIGIN;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000" always;
# Статические ассеты Next.js — кэшируем агрессивно
location /_next/static {
proxy_cache STATIC;
proxy_pass http://nextjs;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /static {
proxy_cache STATIC;
proxy_pass http://nextjs;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Всё остальное — проксируем в Next.js
location / {
proxy_pass http://nextjs;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
}
}
}
# docker-compose с Nginx + Certbot (Let's Encrypt)
version: '3.8'
services:
nginx:
image: nginx:alpine
ports:
- '80:80'
- '443:443'
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- certbot_certs:/etc/letsencrypt:ro
- certbot_www:/var/www/certbot:ro
depends_on:
- app
restart: unless-stopped
certbot:
image: certbot/certbot
volumes:
- certbot_certs:/etc/letsencrypt
- certbot_www:/var/www/certbot
command: certonly --webroot --webroot-path=/var/www/certbot --email [email protected] --agree-tos --no-eff-email -d my-app.com -d www.my-app.com
app:
build: .
environment:
DATABASE_URL: postgresql://postgres:password@db:5432/mydb
db:
image: postgres:16-alpine
# ...
volumes:
certbot_certs:
certbot_www:

Health checks позволяют Docker знать, что контейнер действительно работает (не просто запущен):

# Добавляем в Dockerfile перед CMD
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
// app/api/health/route.ts — эндпоинт для health check
import { NextResponse } from 'next/server';
export async function GET() {
try {
// Можно проверить подключение к БД
// await db.query('SELECT 1');
return NextResponse.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
memory: process.memoryUsage(),
});
} catch (error) {
return NextResponse.json(
{ status: 'error', error: String(error) },
{ status: 503 }
);
}
}
# Health check в docker-compose
services:
app:
build: .
healthcheck:
test: ['CMD', 'wget', '-qO-', 'http://localhost:3000/api/health']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s

# ✅ Оптимизированный Dockerfile — приёмы:
# 1. Используй alpine вместо full
FROM node:20-alpine # ~170 МБ vs ~1 ГБ для debian
# 2. Один RUN вместо многих (меньше слоёв)
# ❌ Плохо:
RUN apk add --no-cache curl
RUN apk add --no-cache git
RUN apk add --no-cache bash
# ✅ Хорошо:
RUN apk add --no-cache curl git bash
# 3. Очищай кэш пакетного менеджера
RUN apk add --no-cache curl \
&& rm -rf /var/cache/apk/*
# 4. Копируй только нужные файлы
COPY package.json package-lock.json ./ # Сначала зависимости
RUN npm ci # Устанавливаем
COPY . . # Потом исходники
# 5. Multi-stage build (уже обсудили)
# 6. .dockerignore — не копируй node_modules и .next

Пример анализа размеров:

Окно терминала
# Просмотр слоёв образа
docker history my-next-app
# Анализ образа утилитой dive
docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
wagoodman/dive:latest my-next-app
# Размеры наших образов:
# Без standalone + без multi-stage: 1.2 ГБ 😱
# Без standalone + с multi-stage: 600 МБ
# Со standalone + с multi-stage: 150 МБ ✅
# Со standalone + distroless: 95 МБ ✅✅

Окно терминала
# На локальной машине: собираем и пушим образ
docker build -t username/my-next-app:latest .
docker push username/my-next-app:latest
# На сервере: тянем и запускаем
ssh user@your-vps-ip
# Устанавливаем Docker (один раз)
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Создаём файл с переменными окружения
nano /home/user/app/.env.production
# DATABASE_URL=...
# NEXTAUTH_SECRET=...
# Запускаем контейнер
docker run -d \
--name my-next-app \
--restart unless-stopped \
-p 3000:3000 \
--env-file /home/user/app/.env.production \
username/my-next-app:latest
# Обновление приложения (zero-downtime с Nginx)
docker pull username/my-next-app:latest
docker stop my-next-app
docker rm my-next-app
docker run -d \
--name my-next-app \
--restart unless-stopped \
-p 3000:3000 \
--env-file /home/user/app/.env.production \
username/my-next-app:latest

.github/workflows/docker.yml
name: Build and Deploy
on:
push:
branches: [main]
tags: ['v*']
env:
REGISTRY: docker.io
IMAGE_NAME: username/my-next-app
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build-and-push
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
docker pull username/my-next-app:main
docker stop my-next-app || true
docker rm my-next-app || true
docker run -d \
--name my-next-app \
--restart unless-stopped \
-p 3000:3000 \
--env-file /home/deploy/app/.env.production \
username/my-next-app:main
docker image prune -f

Kubernetes (k8s) — это оркестратор контейнеров. Когда приложение достаточно большое, что одного Docker-compose не хватает:

k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: next-app
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: next-app
template:
metadata:
labels:
app: next-app
spec:
containers:
- name: next-app
image: username/my-next-app:v1.0.0
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: production
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: app-secrets
key: database-url
resources:
requests:
cpu: '250m'
memory: '256Mi'
limits:
cpu: '500m'
memory: '512Mi'
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
name: next-app-service
spec:
selector:
app: next-app
ports:
- port: 80
targetPort: 3000
type: ClusterIP
---
# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: next-app-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
rules:
- host: my-app.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: next-app-service
port:
number: 80
tls:
- hosts:
- my-app.com
secretName: next-app-tls
Окно терминала
# Базовые команды kubectl
kubectl apply -f k8s/
kubectl get pods -n production
kubectl get deployments -n production
kubectl rollout status deployment/next-app -n production
# Rolling update (zero-downtime!)
kubectl set image deployment/next-app \
next-app=username/my-next-app:v1.1.0 \
-n production
# Откат
kubectl rollout undo deployment/next-app -n production
# Масштабирование
kubectl scale deployment/next-app --replicas=5 -n production

Размер проекта │ Рекомендуется
──────────────────┼──────────────────────────────────────────
Хобби / MVP │ Vercel (бесплатно, zero-config)
Стартап │ Vercel Pro ИЛИ Docker + DigitalOcean
Средний бизнес │ Docker + docker-compose + VPS
Корпорация │ Docker + Kubernetes (GKE/EKS/AKS)

Главные правила Docker для Next.js:

  1. Всегда output: 'standalone' в next.config.mjs
  2. Multi-stage build — builder → runner
  3. Alpine-образы — меньше размер
  4. Непривилегированный пользователь — безопасность
  5. Health checks — Docker знает когда контейнер готов
  6. .dockerignore — не копируй лишнее