TL;DR (Краткое резюме)
Проблема: Vercel/Netlify блокируются в России, российских аналогов нет.
Решение: Self-hosted деплой на своем сервере через GitLab CI/CD.
Что получим:
- Автоматический деплой по push в GitLab
- Zero-downtime через PM2 cluster mode
- SSL из коробки через Nginx Proxy Manager
- Управление несколькими доменами на одном IP
- Полный контроль и предсказуемая стоимость
Минимальная связка для хобби-проекта:
GitLab CI → git pull → npm ci → npm run build → PM2 reload → готовоПолная связка для production:
- Бэкапы + Health checks + Мониторинг + Rollback + Multi-environment
Альтернативы: Coolify, Dokploy, CapRover (если не хочется настраивать вручную).
Время на настройку: 1-2 часа для базового варианта, 4-6 часов для полного стека.
Введение
В 2025 году многие разработчики выбирают самостоятельный деплой (self-hosted) вместо PaaS-решений вроде Vercel или Netlify. Особенно это актуально для российских разработчиков и компаний.
Почему self-hosted, а не Vercel/Netlify?
-
Блокировки зарубежных сервисов — Vercel, Netlify и другие американские платформы регулярно блокируются или работают нестабильно на территории России. Это критично для production-приложений.
-
Отсутствие российских аналогов — полноценных российских аналогов Vercel пока не существует (или я о таких не знаю). Те платформы, что есть, либо дороже, либо не предоставляют такой же уровень удобства.
-
Полный контроль — ваша инфраструктура, ваши правила. Никаких внезапных изменений тарифов, лимитов или блокировок.
-
Предсказуемая стоимость — фиксированная цена VPS vs. pay-per-use с неожиданными счетами при всплесках трафика.
-
Гибкость — можно настроить окружение под свои нужды: custom Node.js версии, системные зависимости, интеграция с корпоративной инфраструктурой.
Недостатки:
- Требуется время на настройку и поддержку
- Ответственность за безопасность и обновления
- Необходимость разбираться в DevOps практиках
Пример из практики: Этот личный сайт (potapov.me) работает именно по описанной ниже схеме — GitLab CI/CD + собственный сервер + PM2 + Nginx Proxy Manager. Всё стабильно, быстро и под полным контролем.
Disclaimer: В этой статье описан идеальный кейс с полным набором практик (мониторинг, алерты, health checks, rollback и т.д.). На своем хобби-проекте я не внедрил все эти фичи — не хочу уделять много ресурсов поддержке личного сайта.
У меня работает базовая связка: GitLab CI → git pull на сервере → npm ci → npm run build → PM2 reload → готово. Этого достаточно для хобби-проекта. Но если вы делаете production-приложение для бизнеса или клиентов, то описанные практики помогут построить надежную инфраструктуру.
Используйте эту статью как checklist и внедряйте только то, что нужно вам.
В этой статье я покажу, как настроить надежный CI/CD пайплайн для Next.js приложения на базе GitLab CI, который обеспечит:
- Zero-downtime deployment через PM2 cluster mode
- Multi-environment setup (staging/production)
- Безопасное управление секретами через GitLab Variables
- Автоматический rollback при ошибках
- Мониторинг и алерты для контроля состояния
- Кэширование зависимостей для ускорения сборки
Архитектура решения
Компоненты системы
┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐
│ GitLab │─────▶│ GitLab Runner│─────▶│ Server Cluster │
│ (Git + CI) │ │ (Build) │ │ │
└─────────────┘ └──────────────┘ │ ┌────────────────┐ │
│ │ │ Nginx Proxy │ │
│ 1. Push code │ │ Manager (NPM) │ │
│ 2. Trigger pipeline │ └────────┬───────┘ │
│ 3. Build & test │ │ │
│ 4. Deploy via SSH ─────────────────┼──────────┤ │
│ 5. Health check │ ┌────────▼───────┐ │
│ 6. Rollback if needed │ │ Next.js App │ │
│ │ │ (PM2 Cluster) │ │
│ │ └────────────────┘ │
└────────────────────────────────────┴──────────────────────┘
1 External IP
Multiple Apps/Servers
Почему именно эта связка?
- GitLab CI — встроенный в GitLab, бесплатный для self-hosted, мощный YAML DSL
- PM2 — production process manager с cluster mode для zero-downtime reload
- Nginx Proxy Manager — удобный web-интерфейс для управления reverse proxy, SSL сертификатами и множественными доменами на одном IP
- Ubuntu 24.04 — LTS релиз с долгосрочной поддержкой
Почему Nginx Proxy Manager, а не просто Nginx?
Если у вас один внешний IP-адрес и несколько серверов/приложений (как в моем случае — целый кластер), то управлять через конфиги Nginx становится неудобно. Nginx Proxy Manager (NPM) решает это:
- Web-интерфейс для управления доменами и прокси
- Автоматические SSL сертификаты через Let's Encrypt
- Легкое добавление новых доменов без редактирования конфигов
- Поддержка proxy hosts, streams, редиректов
- Access lists и защита от ботов из коробки
Для простых случаев (1 сервер, 1 домен) можно использовать обычный Nginx — принципы те же.
Workflow деплоя
- Developer пушит код в ветку
mainилиdevelop - GitLab CI запускает pipeline:
- Install — установка зависимостей с кэшированием
- Lint — проверка кода (ESLint, TypeScript)
- Test — запуск тестов (опционально)
- Build — production сборка Next.js
- Deploy — деплой на сервер через SSH
- Server получает новый код и:
- Бэкапит текущую версию
- Устанавливает зависимости
- Собирает приложение (или копирует артефакт)
- Выполняет
pm2 reloadдля zero-downtime перезапуска - Проверяет health endpoint
- Rollback автоматически выполняется при ошибках
Часть 1: Подготовка сервера
Требования к железу
Для комфортной работы Next.js приложения рекомендую:
- CPU: 2 vCPU (минимум 1 vCPU, но сборка будет медленной)
- RAM: 2 GB (минимум 1 GB + 2 GB swap для сборки)
- Диск: 20 GB SSD (10 GB минимум для кода, node_modules, .next, логов)
- ОС: Ubuntu 24.04 LTS
- Сеть:
- Входящие: 22 (SSH), 80 (HTTP), 443 (HTTPS)
- Исходящие: 443 (npm, git, certbot)
Важно: Если у вас меньше 2 GB RAM, обязательно настройте swap минимум 2 GB. Сборка Next.js с Turbopack требовательна к памяти.
Шаг 1: Базовая настройка сервера и безопасность
Подключаемся к серверу по SSH и обновляем систему:
# Обновление системы
sudo apt update && sudo apt upgrade -y
# Установка базовых пакетов
sudo apt install -y git curl ufw fail2ban build-essential
# Настройка firewall
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw statusSSH Hardening (укрепление безопасности)
Важно для production: Эти настройки защитят ваш сервер от несанкционированного доступа. Обязательно настройте перед тем, как открывать сервер в интернет.
# 1. Создаем SSH ключ на локальной машине (если еще нет)
# Выполняйте на ЛОКАЛЬНОЙ машине, не на сервере!
ssh-keygen -t ed25519 -C "your_email@example.com"
# 2. Копируем публичный ключ на сервер
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@your_server_ip
# 3. Проверяем, что можем зайти по ключу
ssh user@your_server_ip
# 4. Отключаем вход по паролю (только SSH ключи)
sudo nano /etc/ssh/sshd_config
# Найдите и измените эти строки:
# PasswordAuthentication no
# PubkeyAuthentication yes
# PermitRootLogin no
# ChallengeResponseAuthentication no
# 5. Перезапускаем SSH
sudo systemctl restart sshd
# 6. НЕ ЗАКРЫВАЙТЕ текущую сессию! Откройте новое окно терминала
# и проверьте, что можете зайти по ключуFail2Ban для защиты от брутфорса
# Установка (уже установлен выше)
sudo apt install -y fail2ban
# Создаем локальную конфигурацию
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
# Настраиваем базовую защиту SSH
sudo tee /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
destemail = your_email@example.com
sendername = Fail2Ban
[sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
backend = %(sshd_backend)s
EOF
# Запускаем Fail2Ban
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
# Проверяем статус
sudo fail2ban-client status
sudo fail2ban-client status sshdЧто делает Fail2Ban:
- Анализирует логи SSH
- При 5 неудачных попытках входа за 10 минут банит IP на 1 час
- Защищает от брутфорс-атак
Настройка Firewall (UFW)
Более детальная конфигурация портов:
# Сбрасываем правила (если нужно начать с чистого листа)
sudo ufw --force reset
# Политика по умолчанию: запретить все входящие, разрешить исходящие
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Разрешаем SSH (ВАЖНО: сделайте это до enable!)
sudo ufw allow 22/tcp comment 'SSH'
# Разрешаем HTTP/HTTPS для NPM или Nginx
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'
# Если используете NPM, разрешаем админку (только для вашего IP!)
# sudo ufw allow from YOUR_IP to any port 81 comment 'NPM Admin'
# Ограничение скорости подключений SSH (защита от DDoS)
sudo ufw limit 22/tcp
# Включаем firewall
sudo ufw enable
# Проверяем правила
sudo ufw status numberedОсторожно! Перед ufw enable убедитесь, что разрешили порт SSH (22), иначе потеряете доступ к серверу!
Шаг 2: Установка Node.js 20 LTS
Next.js 15 требует Node.js >= 18.17, рекомендуется 20 LTS:
# Установка Node.js 20 через NodeSource
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
# Проверка версий
node -v # должно быть >= v20.x
npm -v # должно быть >= 10.x
# Настройка npm для production
npm config set loglevel errorАльтернатива: Можно использовать nvm (Node Version Manager) для управления версиями Node.js, особенно если нужно несколько версий на одном сервере.
Шаг 3: Установка и настройка PM2
PM2 — production process manager с поддержкой cluster mode для zero-downtime deployment.
# Установка PM2 глобально
sudo npm install -g pm2
# Настройка автозапуска PM2 при перезагрузке
pm2 startup systemd -u $USER --hp $HOME
# Команда выведет строку с sudo — выполните её
# Проверка systemd сервиса
sudo systemctl status pm2-$USERПочему PM2, а не systemd напрямую?
- Cluster mode с автоматической балансировкой нагрузки
- Graceful reload без простоя (плавное переключение между инстансами)
- Встроенный мониторинг (CPU, RAM, logs)
- Автоматический рестарт при падении
- Log rotation из коробки
- Простой API для управления процессами
Шаг 4: Настройка Nginx Proxy Manager
Nginx Proxy Manager (NPM) — это web-интерфейс для управления Nginx как reverse proxy. Особенно удобен, когда у вас один внешний IP и несколько приложений/серверов.
Установка через Docker Compose
# Создаем директорию для NPM
mkdir -p ~/nginx-proxy-manager
cd ~/nginx-proxy-manager
# Создаем docker-compose.yml
cat > docker-compose.yml << 'EOF'
version: '3.8'
services:
app:
image: 'jc21/nginx-proxy-manager:latest'
restart: unless-stopped
ports:
- '80:80' # HTTP
- '443:443' # HTTPS
- '81:81' # Admin UI
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
environment:
DB_SQLITE_FILE: "/data/database.sqlite"
EOF
# Запускаем NPM
docker-compose up -d
# Проверяем статус
docker-compose psПервичная настройка
- Откройте
http://YOUR_SERVER_IP:81в браузере - Войдите с дефолтными credentials:
- Email:
admin@example.com - Password:
changeme
- Email:
- Смените email и пароль при первом входе
Добавление Proxy Host для Next.js
В веб-интерфейсе NPM:
-
Hosts → Proxy Hosts → Add Proxy Host
-
Details tab:
- Domain Names:
potapov.me,www.potapov.me - Scheme:
http - Forward Hostname / IP:
localhost(или IP сервера с Next.js) - Forward Port:
3000 - ✅ Cache Assets
- ✅ Block Common Exploits
- ✅ Websockets Support
- Domain Names:
-
SSL tab:
- ✅ SSL Certificate: Request a new SSL Certificate
- ✅ Force SSL
- ✅ HTTP/2 Support
- ✅ HSTS Enabled
- Email: ваш email для Let's Encrypt
- ✅ I Agree to the Let's Encrypt Terms of Service
-
Advanced (опционально):
# Кастомные настройки для Next.js
location /_next/static/ {
proxy_cache_valid 200 60m;
add_header Cache-Control "public, immutable";
}
location /api/health {
access_log off;
}
# Таймауты для SSR
proxy_read_timeout 60s;
proxy_connect_timeout 60s;- Save
NPM автоматически:
- Настроит reverse proxy на ваше приложение
- Получит SSL сертификат от Let's Encrypt
- Настроит автоматическое обновление сертификатов
- Включит редирект с HTTP на HTTPS
Преимущества NPM в моем случае:
У меня один внешний IP-адрес и целый кластер серверов с разными приложениями. NPM позволяет:
- Управлять всеми доменами из одного места
- Добавлять новые приложения за минуту через UI
- Не редактировать конфиги вручную
- Автоматически управлять SSL для всех доменов
- Настраивать access lists, rate limiting, редиректы
Если у вас простая конфигурация (1 сервер, 1-2 домена), можете использовать обычный Nginx — принципы проксирования те же.
Альтернатива: обычный Nginx
Если предпочитаете классический Nginx, пример конфигурации:
# /etc/nginx/sites-available/potapov.me
server {
listen 80;
server_name potapov.me www.potapov.me;
location / {
proxy_pass http://localhost:3000;
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;
proxy_read_timeout 60s;
}
}Затем используйте Certbot для SSL:
sudo certbot --nginx -d potapov.me -d www.potapov.meШаг 6: Создание структуры каталогов
# Создаем каталог для приложения
sudo mkdir -p /var/www/potapov.me
sudo chown -R $USER:$USER /var/www/potapov.me
# Создаем каталоги для логов и бэкапов
mkdir -p /var/www/potapov.me/{logs,backups}
# Создаем .env файл (заполним позже через GitLab CI)
touch /var/www/potapov.me/.envЧасть 2: Настройка PM2 Ecosystem
PM2 Ecosystem файл — это конфигурация для управления приложением в production.
Ecosystem файл хранится в репозитории и задает параметры запуска: количество инстансов, переменные окружения, пути к логам, лимиты памяти и прочее.
Создаем ecosystem.config.cjs в корне проекта:
// ecosystem.config.cjs
module.exports = {
apps: [
{
name: 'potapov-me',
cwd: '/var/www/potapov.me',
script: 'node_modules/next/dist/bin/next',
args: 'start -p 3000',
// Environment variables
env: {
NODE_ENV: 'production',
PORT: 3000,
},
// Cluster mode для zero-downtime reload
exec_mode: 'cluster',
instances: 2, // или 'max' для использования всех CPU ядер
// Auto-restart settings
autorestart: true,
max_restarts: 10,
min_uptime: '10s',
max_memory_restart: '500M', // Рестарт при превышении памяти
// Logging
out_file: './logs/pm2-out.log',
error_file: './logs/pm2-error.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
merge_logs: true,
// Graceful shutdown
kill_timeout: 5000,
wait_ready: true,
listen_timeout: 10000,
},
],
// Deployment configuration (опционально, используем GitLab CI)
deploy: {
production: {
user: 'deployer',
host: 'potapov.me',
ref: 'origin/main',
repo: 'git@gitlab.com:username/potapov.me.git',
path: '/var/www/potapov.me',
'post-deploy': 'npm ci && npm run build && pm2 reload ecosystem.config.cjs --env production',
},
},
};Ключевые параметры:
exec_mode: 'cluster'— запуск нескольких инстансов для балансировки и zero-downtime reloadinstances: 2— количество процессов (рекомендую 2-4 для типичного VPS)max_memory_restart— автоматический рестарт при утечках памятиwait_ready— ждать сигнала готовности от приложения (требуетprocess.send('ready')в коде)kill_timeout— время на graceful shutdown (завершение текущих запросов)
Pro tip: Для Next.js в production режиме достаточно 2-4 инстансов. Больше не всегда = быстрее, особенно на малоресурсных VPS.
Добавляем health check endpoint
Создаем API route для проверки здоровья приложения:
// app/api/health/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const health = {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: process.env.NODE_ENV,
};
return NextResponse.json(health, { status: 200 });
}Этот endpoint используется для:
- Проверки доступности после деплоя
- Мониторинга через внешние сервисы (UptimeRobot, Pingdom)
- Load balancer health checks
Часть 3: GitLab CI/CD Pipeline
Теперь настроим автоматический деплой через GitLab CI.
Архитектура CI/CD
GitLab CI использует Runners — агенты, которые выполняют задачи из pipeline. Бывают:
- Shared Runners — предоставляются GitLab.com бесплатно (лимит 400 минут/месяц)
- Specific Runners — ваш собственный раннер на любом сервере
- Group/Project Runners — для группы проектов или конкретного репозитория
Для этого гайда используем Shared Runners GitLab.com для сборки и деплой на ваш сервер через SSH. Если нужно больше контроля — настройте Specific Runner на своем сервере.
Шаг 1: Настройка SSH доступа для CI
GitLab CI будет подключаться к серверу по SSH для деплоя. Настроим SSH ключи:
# На сервере: создаем пользователя для деплоя (опционально)
sudo adduser deployer
sudo usermod -aG sudo deployer
# Добавляем в sudoers для выполнения команд без пароля (если нужно)
# Для NPM обычно не требуется, так как он работает через Docker
# Для обычного Nginx:
# echo "deployer ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload nginx, /usr/bin/pm2" | sudo tee /etc/sudoers.d/deployer
# Переключаемся на deployer
su - deployer
# Создаем SSH ключи (на локальной машине или в GitLab CI/CD Settings)
ssh-keygen -t ed25519 -C "gitlab-ci@potapov.me" -f ~/.ssh/gitlab_ci_ed25519
# Копируем публичный ключ на сервер
# На сервере (под deployer):
mkdir -p ~/.ssh
chmod 700 ~/.ssh
nano ~/.ssh/authorized_keys
# Вставляем содержимое gitlab_ci_ed25519.pub
chmod 600 ~/.ssh/authorized_keysШаг 2: Добавление секретов в GitLab
Переходим в GitLab: Settings → CI/CD → Variables и добавляем:
| Ключ | Значение | Защищено | Маскировано |
|---|---|---|---|
SSH_PRIVATE_KEY | Содержимое gitlab_ci_ed25519 | ✅ | ✅ |
SSH_HOST | IP или домен сервера | ✅ | ❌ |
SSH_USER | deployer | ✅ | ❌ |
DEPLOY_PATH | /var/www/potapov.me | ❌ | ❌ |
NODE_ENV | production | ❌ | ❌ |
Безопасность: Включайте "Protected" для production переменных (доступны только в защищенных ветках) и "Masked" для секретов (не показываются в логах).
Для multi-environment добавляем переменные с Environment scope:
productionпеременные доступны только при деплое в productionstagingпеременные доступны только при деплое в staging
Шаг 3: Создание .gitlab-ci.yml
Создаем файл .gitlab-ci.yml в корне проекта:
# .gitlab-ci.yml
# GitLab CI/CD pipeline для деплоя Next.js приложения
# Глобальные настройки
image: node:20-alpine
# Стадии pipeline
stages:
- install
- lint
- test
- build
- deploy
- healthcheck
- rollback
# Кэширование node_modules для ускорения
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
- .npm/
# Переменные окружения
variables:
npm_config_cache: '$CI_PROJECT_DIR/.npm'
ARTIFACT_COMPRESSION_LEVEL: 'fast'
CACHE_COMPRESSION_LEVEL: 'fast'
# Шаблон для job с Node.js
.node_job:
before_script:
- node -v
- npm -v
# === STAGE: Install Dependencies ===
install_dependencies:
extends: .node_job
stage: install
script:
- echo "Installing dependencies..."
- npm ci --prefer-offline --no-audit
artifacts:
paths:
- node_modules/
expire_in: 1 hour
only:
- main
- develop
- merge_requests
# === STAGE: Lint ===
lint:
extends: .node_job
stage: lint
dependencies:
- install_dependencies
script:
- echo "Running ESLint..."
- npm run lint
only:
- main
- develop
- merge_requests
# === STAGE: Test (опционально) ===
# Раскомментируйте, если есть тесты
# test:
# extends: .node_job
# stage: test
# dependencies:
# - install_dependencies
# script:
# - echo "Running tests..."
# - npm run test
# coverage: '/Statements\s*:\s*(\d+\.\d+)%/'
# only:
# - main
# - develop
# - merge_requests
# === STAGE: Build ===
build:
extends: .node_job
stage: build
dependencies:
- install_dependencies
script:
- echo "Building Next.js application..."
- npm run build
artifacts:
paths:
- .next/
- public/
expire_in: 1 hour
only:
- main
- develop
# === STAGE: Deploy to Production ===
deploy_production:
stage: deploy
image: alpine:latest
dependencies:
- build
before_script:
# Установка SSH клиента
- apk add --no-cache openssh-client bash git
# Настройка SSH
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- ssh-keyscan -H $SSH_HOST >> ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
script:
- echo "Deploying to production server..."
# Создаем бэкап текущей версии
- |
ssh $SSH_USER@$SSH_HOST "
cd $DEPLOY_PATH &&
if [ -d .next ]; then
echo 'Creating backup...' &&
BACKUP_NAME=backup-\$(date +%Y%m%d-%H%M%S) &&
mkdir -p backups &&
tar -czf backups/\$BACKUP_NAME.tar.gz .next package.json package-lock.json &&
echo 'Backup created: '\$BACKUP_NAME &&
# Оставляем только последние 5 бэкапов
cd backups &&
ls -t | tail -n +6 | xargs -r rm &&
cd ..
fi
"
# Деплоим через git pull на сервере
- echo "Deploying via git pull..."
- |
ssh $SSH_USER@$SSH_HOST "
cd $DEPLOY_PATH &&
git fetch origin &&
git reset --hard origin/main &&
echo 'Code updated from git'
"
# Устанавливаем зависимости, билдим и перезапускаем PM2
- |
ssh $SSH_USER@$SSH_HOST "
cd $DEPLOY_PATH &&
echo 'Installing dependencies...' &&
npm ci --prefer-offline &&
echo 'Building application...' &&
npm run build &&
echo 'Reloading PM2...' &&
pm2 reload ecosystem.config.cjs --update-env &&
echo 'Waiting for app to start...' &&
sleep 5 &&
pm2 status
"
- echo "Deployment completed successfully!"
environment:
name: production
url: https://potapov.me
only:
- main
# === STAGE: Deploy to Staging ===
deploy_staging:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client bash git
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY_STAGING" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- ssh-keyscan -H $SSH_HOST_STAGING >> ~/.ssh/known_hosts
script:
- echo "Deploying to staging server..."
- |
ssh $SSH_USER_STAGING@$SSH_HOST_STAGING "
cd $DEPLOY_PATH_STAGING &&
git fetch origin &&
git reset --hard origin/develop &&
npm ci &&
npm run build &&
pm2 reload ecosystem.config.staging.js --update-env
"
environment:
name: staging
url: https://staging.potapov.me
only:
- develop
# === STAGE: Health Check ===
healthcheck_production:
stage: healthcheck
image: alpine:latest
before_script:
- apk add --no-cache curl
script:
- echo "Checking application health..."
- |
for i in {1..10}; do
if curl -f -s https://potapov.me/api/health > /dev/null; then
echo "✅ Health check passed!"
exit 0
else
echo "⏳ Waiting for app to be ready (attempt $i/10)..."
sleep 5
fi
done
echo "❌ Health check failed after 10 attempts"
exit 1
dependencies:
- deploy_production
only:
- main
# === STAGE: Rollback (manual) ===
rollback_production:
stage: rollback
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
- ssh-keyscan -H $SSH_HOST >> ~/.ssh/known_hosts
script:
- echo "Rolling back to previous version..."
- |
ssh $SSH_USER@$SSH_HOST "
cd $DEPLOY_PATH &&
# Находим последний бэкап
LATEST_BACKUP=\$(ls -t backups/*.tar.gz | head -1) &&
if [ -z \"\$LATEST_BACKUP\" ]; then
echo '❌ No backups found!'
exit 1
fi &&
echo 'Restoring from backup: '\$LATEST_BACKUP &&
# Восстанавливаем из бэкапа
tar -xzf \$LATEST_BACKUP &&
# Перезапускаем PM2
pm2 reload ecosystem.config.cjs &&
echo '✅ Rollback completed!'
"
when: manual
only:
- mainЧто делает этот pipeline:
- Install — устанавливает зависимости с кэшированием для ускорения последующих запусков
- Lint — проверяет код на ошибки стиля
- Build — собирает Next.js приложение и сохраняет артефакты
- Deploy — подключается по SSH, создает бэкап, синхронизирует код, устанавливает зависимости, перезапускает PM2
- Health Check — проверяет доступность приложения через /api/health
- Rollback — ручная стадия для отката к предыдущей версии из бэкапа
Оптимизации pipeline
1. Кэширование Docker слоев (если используете Docker)
build_docker:
image: docker:latest
services:
- docker:dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker pull $CI_REGISTRY_IMAGE:latest || true
- docker build --cache-from $CI_REGISTRY_IMAGE:latest -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA2. Parallel jobs для ускорения
# Запускаем lint и test параллельно
lint:
stage: test
script:
- npm run lint
test:unit:
stage: test
script:
- npm run test:unit
test:e2e:
stage: test
script:
- npm run test:e2e3. Условный деплой с approval
deploy_production:
stage: deploy
when: manual # Требует ручного подтверждения
only:
- mainЧасть 4: Secrets Management
Безопасное управление секретами — критически важная часть CI/CD.
Уровни секретов
- GitLab CI/CD Variables — для инфраструктурных секретов (SSH ключи, API токены)
- Environment Variables на сервере — для application secrets (.env файлы)
- HashiCorp Vault / AWS Secrets Manager — для enterprise-решений
Настройка .env через GitLab CI
Добавляем в GitLab Variables:
DATABASE_URL=postgresql://user:pass@localhost:5432/db
NEXT_PUBLIC_API_URL=https://api.potapov.me
SECRET_KEY=your-secret-key
Обновляем deploy stage в .gitlab-ci.yml:
deploy_production:
script:
# ... previous steps ...
# Создаем .env файл из GitLab Variables
- |
ssh $SSH_USER@$SSH_HOST "
cd $DEPLOY_PATH &&
cat > .env << EOF
NODE_ENV=production
DATABASE_URL=$DATABASE_URL
NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
SECRET_KEY=$SECRET_KEY
EOF
"
# Устанавливаем права доступа
- ssh $SSH_USER@$SSH_HOST "chmod 600 $DEPLOY_PATH/.env"Безопасность .env файлов:
- Никогда не коммитьте .env в репозиторий
- Используйте
.env.exampleс пустыми значениями как шаблон - Ограничьте права доступа:
chmod 600 .env - Используйте разные секреты для staging и production
- Ротируйте секреты регулярно
Защита от утечек секретов
# Добавляем маскирование секретов в логах
variables:
GIT_STRATEGY: clone
GIT_DEPTH: 1 # Shallow clone для ускорения
before_script:
# Маскируем чувствительные данные в логах
- export DATABASE_URL="***MASKED***"Часть 5: Мониторинг и алерты
PM2 мониторинг
Проверяем статус приложения:
# Статус всех процессов
pm2 status
# Детальная информация
pm2 show potapov-me
# Мониторинг в реальном времени
pm2 monit
# Логи
pm2 logs potapov-me --lines 100
# Очистка старых логов
pm2 flushНастройка PM2 Plus (опционально)
PM2 Plus — SaaS платформа для мониторинга, алертов и профилирования:
# Регистрация (бесплатный план: 1 сервер, 4 приложения)
pm2 plus
# Подключение к PM2 Plus
pm2 link <secret_key> <public_key>Возможности:
- Реалтайм мониторинг CPU/RAM/Event Loop
- Алерты в Slack/Email при ошибках
- Exception tracking
- Custom metrics
- Performance profiling
Health checks через cron
Создаем скрипт для мониторинга:
# /var/www/potapov.me/scripts/healthcheck.sh
#!/bin/bash
URL="https://potapov.me/api/health"
LOG="/var/www/potapov.me/logs/healthcheck.log"
response=$(curl -s -o /dev/null -w "%{http_code}" $URL)
if [ $response -eq 200 ]; then
echo "$(date): ✅ Health check passed" >> $LOG
else
echo "$(date): ❌ Health check failed (HTTP $response)" >> $LOG
# Отправляем алерт (например, в Telegram)
curl -s -X POST "https://api.telegram.org/bot<TOKEN>/sendMessage" \
-d chat_id=<CHAT_ID> \
-d text="🚨 potapov.me is down! HTTP $response"
# Пытаемся перезапустить PM2
pm2 restart potapov-me
fiДобавляем в crontab:
chmod +x /var/www/potapov.me/scripts/healthcheck.sh
# Проверка каждые 5 минут
crontab -e
*/5 * * * * /var/www/potapov.me/scripts/healthcheck.shИнтеграция с внешними сервисами
UptimeRobot (бесплатно):
- 50 мониторов
- Проверка каждые 5 минут
- Алерты в Email/Slack/Telegram
Healthchecks.io (бесплатно):
- 20 проверок
- Cron monitoring
- Интеграции с 50+ сервисами
Часть 6: Multi-Environment Setup
Настроим окружения staging и production с разными конфигурациями.
Структура окружений
/var/www/
├── potapov.me/ # Production
│ ├── .env
│ ├── ecosystem.config.cjs
│ └── ...
└── staging.potapov.me/ # Staging
├── .env
├── ecosystem.config.cjs (порт 3001)
└── ...
Ecosystem для staging
// ecosystem.config.staging.js
module.exports = {
apps: [{
name: 'potapov-me-staging',
cwd: '/var/www/staging.potapov.me',
script: 'node_modules/next/dist/bin/next',
args: 'start -p 3001',
env: {
NODE_ENV: 'production',
PORT: 3001,
STAGING: 'true',
},
exec_mode: 'cluster',
instances: 1, // Меньше инстансов для staging
autorestart: true,
max_memory_restart: '300M',
}],
};Настройка proxy для staging
В Nginx Proxy Manager добавьте еще один Proxy Host:
- Domain:
staging.potapov.me - Forward to:
localhost:3001(или IP сервера со staging) - SSL: запросите отдельный сертификат для staging
Если используете обычный Nginx:
# /etc/nginx/sites-available/staging.potapov.me
server {
listen 80;
server_name staging.potapov.me;
location / {
proxy_pass http://localhost:3001;
# ... остальные настройки как для production
}
}GitLab CI для multi-environment
Обновляем .gitlab-ci.yml:
# Deploy to staging (ветка develop)
deploy_staging:
stage: deploy
script:
- ssh $SSH_USER@$SSH_HOST "cd /var/www/staging.potapov.me && git pull origin develop && npm ci && npm run build && pm2 reload ecosystem.config.staging.js"
environment:
name: staging
url: https://staging.potapov.me
only:
- develop
# Deploy to production (ветка main)
deploy_production:
stage: deploy
script:
- ssh $SSH_USER@$SSH_HOST "cd /var/www/potapov.me && git pull origin main && npm ci && npm run build && pm2 reload ecosystem.config.cjs"
environment:
name: production
url: https://potapov.me
only:
- main
when: manual # Ручной деплой в productionWorkflow:
- Разработка → коммит в
feature/*ветку → создание MR - Review → мерж в
develop→ автоматический деплой на staging - Тестирование на staging → мерж
developвmain - Ручное подтверждение деплоя в production через GitLab UI
Часть 7: Rollback стратегии
Автоматический rollback при ошибках
Добавляем в .gitlab-ci.yml:
healthcheck_production:
stage: healthcheck
script:
- |
if ! curl -f https://potapov.me/api/health; then
echo "Health check failed! Triggering rollback..."
# Вызываем rollback job
curl -X POST \
-F token=$CI_JOB_TOKEN \
-F ref=main \
-F "variables[TRIGGER_ROLLBACK]=true" \
https://gitlab.com/api/v4/projects/$CI_PROJECT_ID/trigger/pipeline
exit 1
fiРучной rollback через GitLab UI
Pipeline с кнопкой rollback:
rollback_production:
stage: rollback
when: manual
script:
- |
ssh $SSH_USER@$SSH_HOST "
cd $DEPLOY_PATH &&
LATEST_BACKUP=\$(ls -t backups/*.tar.gz | head -1) &&
tar -xzf \$LATEST_BACKUP &&
pm2 reload ecosystem.config.cjs
"Кнопка появится в GitLab → CI/CD → Pipelines → [pipeline] → Rollback
Blue-Green Deployment
Для минимизации простоя используем Blue-Green деплой:
# На сервере: две копии приложения
/var/www/potapov.me-blue/ # Текущая версия
/var/www/potapov.me-green/ # Новая версия
# Nginx переключает между ними
upstream backend {
server localhost:3000; # blue
# server localhost:3001; # green
}Скрипт переключения:
#!/bin/bash
# switch-deployment.sh
CURRENT=$(readlink /var/www/potapov.me)
if [[ $CURRENT == *"blue"* ]]; then
NEW_COLOR="green"
NEW_PORT=3001
else
NEW_COLOR="blue"
NEW_PORT=3000
fi
echo "Switching from $CURRENT to $NEW_COLOR..."
# Деплоим в неактивное окружение
cd /var/www/potapov.me-$NEW_COLOR
git pull
npm ci
npm run build
# Запускаем новую версию
pm2 start ecosystem.config.cjs
# Проверяем health
if curl -f http://localhost:$NEW_PORT/api/health; then
# Обновляем symlink
ln -sfn /var/www/potapov.me-$NEW_COLOR /var/www/potapov.me
# Перезагружаем Nginx
sudo nginx -s reload
echo "✅ Switched to $NEW_COLOR"
else
echo "❌ Health check failed, keeping $CURRENT"
pm2 stop ecosystem.config.cjs
exit 1
fiАльтернативы и расширения
Описанная в статье связка GitLab CI + PM2 + NPM — не единственный вариант. Вот несколько альтернатив для разных сценариев:
Альтернативы Nginx Proxy Manager
Traefik
Traefik — современный reverse proxy с автоматической конфигурацией через Docker labels.
Преимущества:
- Автоматическое обнаружение сервисов через Docker/Kubernetes
- Встроенный Let's Encrypt
- Поддержка HTTP/2, HTTP/3, gRPC
- Мощная система middleware (rate limiting, authentication, etc.)
Недостатки:
- Сложнее в настройке для простых случаев
- Требует Docker для полноценной работы
Когда использовать: Если у вас микросервисная архитектура с Docker/Kubernetes.
Caddy
Caddy — минималистичный веб-сервер с автоматическим HTTPS.
Преимущества:
- Автоматический HTTPS из коробки (Let's Encrypt)
- Простейшая конфигурация (Caddyfile)
- Встроенный reverse proxy
Пример конфигурации:
potapov.me {
reverse_proxy localhost:3000
}Когда использовать: Если хотите максимально простую настройку без веб-интерфейса.
All-in-One решения (альтернативы Vercel)
Если не хотите настраивать CI/CD вручную, есть open-source платформы "из коробки":
Coolify
Coolify — self-hosted альтернатива Vercel/Netlify/Heroku.
Что умеет:
- Git push to deploy (поддержка GitHub, GitLab, Bitbucket)
- Автоматические SSL сертификаты
- Database management (PostgreSQL, MySQL, Redis, MongoDB)
- Автоматические бэкапы
- Встроенный мониторинг
- Web UI для управления
Плюсы:
- Установка за 5 минут
- Поддержка Next.js, Node.js, PHP, Python, Ruby, Go, Rust
- Бесплатный и open-source
Минусы:
- Требует больше ресурсов (Docker overhead)
- Меньше контроля над инфраструктурой
Установка:
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bashИз личного опыта: Тестировал Coolify полгода назад (весна 2025). Концепция прикольная, UI удобный, но платформа оказалась сильно глючной и сырой:
- Деплои периодически падали без внятных ошибок
- База данных несколько раз слетала после обновлений
- Приходилось перезапускать Docker контейнеры вручную
Возможно, сейчас стало лучше (активная разработка), но для критичных проектов пока не рискну. Для экспериментов и некритичных сервисов — вполне можно попробовать.
CapRover
CapRover — PaaS платформа для деплоя приложений одной командой.
Особенности:
- One-click apps (WordPress, Ghost, Grafana, etc.)
- Multi-server deployment
- Load balancing
- Автоматический HTTPS
Когда использовать: Если нужно быстро развернуть несколько приложений на одном сервере.
Dokploy
Dokploy — новый open-source аналог Vercel (появился в 2024).
Что интересного:
- Современный UI (похож на Vercel)
- Поддержка монорепозиториев
- Preview deployments для pull requests
- Edge functions
- Встроенная аналитика
Статус: Активная разработка, но уже можно использовать в production.
GitHub: dokploy/dokploy
Рекомендации по выбору
| Сценарий | Рекомендация |
|---|---|
| Хобби-проект, 1-2 приложения | Базовая связка (GitLab CI + PM2 + NPM/Caddy) |
| Множество приложений на одном сервере | Nginx Proxy Manager или Traefik |
| Хочу "как Vercel", но self-hosted | Coolify или Dokploy |
| Микросервисы с Docker | Traefik + Kubernetes/Docker Swarm |
| Максимальная простота | CapRover |
| Полный контроль и гибкость | Ручная настройка (эта статья) |
Мой выбор для potapov.me: Базовая связка без излишеств — GitLab CI, git pull, PM2, NPM. Работает стабильно, не требует обслуживания, достаточно для хобби-проекта.
Troubleshooting (Решение проблем)
Частые проблемы и решения
1. PM2 не запускается после перезагрузки сервера
Симптомы: После reboot приложение не работает, pm2 status показывает пустой список.
Решение:
# Проверяем статус systemd сервиса PM2
systemctl status pm2-$USER
# Если сервис не активен, настраиваем автозапуск
pm2 startup systemd -u $USER --hp $HOME
# Выполните команду с sudo, которую выведет PM2
# Сохраняем текущий список процессов
pm2 save
# Тестируем
sudo reboot
# После перезагрузки проверяем
pm2 status2. GitLab CI Pipeline падает с "Permission denied" при SSH
Симптомы:
Permission denied (publickey).
fatal: Could not read from remote repository.
Решение:
# 1. Проверьте, что SSH ключ добавлен в GitLab Variables
# Settings → CI/CD → Variables → SSH_PRIVATE_KEY
# 2. Убедитесь, что публичный ключ добавлен на сервере
cat ~/.ssh/authorized_keys
# 3. Проверьте права на .ssh директорию
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
# 4. Проверьте SSH подключение вручную
ssh -i ~/.ssh/gitlab_ci_key user@server
# 5. Добавьте -vvv для диагностики
ssh -vvv user@server3. Health check fails после деплоя
Симптомы: Pipeline падает на стадии healthcheck.
Диагностика:
# 1. Проверяем, что приложение запущено
pm2 status
pm2 logs app-name --lines 50
# 2. Проверяем, слушает ли процесс нужный порт
sudo netstat -tlnp | grep 3000
# или
sudo lsof -i :3000
# 3. Проверяем health endpoint локально
curl http://localhost:3000/api/health
# 4. Проверяем с внешнего IP
curl http://YOUR_IP:3000/api/health
# 5. Проверяем логи Nginx/NPM
docker logs nginx-proxy-manager_app_1Типичные причины:
- Приложение упало при старте (смотрите
pm2 logs) - Неправильный порт в ecosystem.config.cjs
- Health endpoint не отвечает (проверьте код API route)
- Firewall блокирует порт (проверьте
ufw status)
4. Build fails: "FATAL ERROR: Reached heap limit"
Симптомы: Next.js build падает с ошибкой памяти.
Решение:
# 1. Проверьте доступную память
free -h
# 2. Создайте swap файл (если нет)
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# 3. Сделайте swap постоянным
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
# 4. Или увеличьте heap для Node.js в package.json
"scripts": {
"build": "NODE_OPTIONS='--max-old-space-size=2048' next build"
}5. git pull падает с конфликтами или деплой очень медленный
Симптомы: Git жалуется на локальные изменения или деплой занимает > 10 минут.
Решение:
# Вариант 1: Жесткий reset (удаляет все локальные изменения)
ssh user@server "
cd /var/www/app &&
git fetch origin &&
git reset --hard origin/main &&
git clean -fd
"
# Вариант 2: Stash локальных изменений
ssh user@server "
cd /var/www/app &&
git stash &&
git pull origin main
"
# Вариант 3: Если репозиторий большой, используйте shallow clone
# При первичной настройке:
git clone --depth 1 https://gitlab.com/user/repo.git /var/www/app
# При деплое:
ssh user@server "
cd /var/www/app &&
git fetch --depth 1 &&
git reset --hard origin/main
"6. NPM не получает SSL сертификат
Симптомы: "Failed to obtain certificate" в логах NPM.
Решение:
# 1. Проверьте, что домен указывает на ваш IP
dig potapov.me +short
nslookup potapov.me
# 2. Проверьте, что порты 80 и 443 открыты
sudo ufw status
sudo netstat -tlnp | grep ':80\|:443'
# 3. Проверьте логи NPM
docker logs nginx-proxy-manager_app_1 --tail 100
# 4. Попробуйте получить сертификат вручную через certbot
sudo certbot certonly --standalone -d potapov.meПолезные команды для диагностики
# === PM2 ===
pm2 status # Статус всех процессов
pm2 logs app-name --lines 100 # Логи приложения
pm2 monit # Мониторинг в реальном времени
pm2 describe app-name # Детальная информация
pm2 reset app-name # Сброс счетчиков рестартов
# === Проверка портов ===
sudo netstat -tlnp # Все слушающие порты
sudo lsof -i :3000 # Процесс на порту 3000
nc -zv localhost 3000 # Проверка доступности порта
# === Логи системы ===
journalctl -u pm2-$USER -f # Логи PM2 systemd сервиса
journalctl -u nginx -f # Логи Nginx (если не Docker)
tail -f /var/log/syslog # Системные логи
# === Docker (для NPM) ===
docker ps # Запущенные контейнеры
docker logs <container> -f # Логи контейнера
docker exec -it <container> sh # Зайти внутрь контейнера
# === Disk space ===
df -h # Свободное место на дисках
du -sh /var/www/* # Размер директорий
du -sh node_modules .next # Размер артефактов
# === Memory ===
free -h # Память и swap
htop # Интерактивный мониторингЗаключение
Мы настроили полноценный CI/CD пайплайн для Next.js приложения с:
✅ Zero-downtime deployment через PM2 cluster mode
✅ Multi-environment setup (staging/production)
✅ Automated testing и health checks
✅ Secure secrets management через GitLab Variables
✅ Monitoring через PM2 и внешние сервисы
✅ Rollback стратегии для быстрого восстановления
✅ Performance optimization (кэширование, артефакты)
✅ Nginx Proxy Manager для удобного управления доменами на одном IP
Этот подход работает в production: Мой личный сайт potapov.me развернут именно по этой схеме. Один внешний IP, кластер серверов, Nginx Proxy Manager для управления доменами, GitLab CI/CD для автоматического деплоя. Всё стабильно и под полным контролем — никаких зависимостей от американских сервисов, которые могут заблокироваться в любой момент.
Для российских разработчиков
Особенно актуально иметь собственную инфраструктуру, когда:
- Vercel/Netlify блокируются или работают нестабильно
- Российских аналогов с таким же уровнем удобства пока нет
- Нужна предсказуемость и независимость от внешних факторов
Self-hosted решение дает полный контроль и уверенность в доступности ваших приложений.
Следующие шаги
- Docker-ization — упаковка приложения в Docker для воспроизводимости
- Database migrations — автоматизация миграций БД в pipeline
- E2E тесты — добавление Playwright/Cypress тестов
- Performance budgets — лимиты на размер бандла и Core Web Vitals
- CDN integration — раздача статики через CloudFlare/BunnyCDN (российские CDN: CDN.ru, EdgeCenter)
Полезные ссылки
Вопросы? Напишите мне в Telegram или через форму обратной связи.
