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

Деплой Next.js с GitLab CI/CD: От настройки сервера до автоматизации

Константин Потапов
30 мин

Полный гайд по настройке автоматического деплоя Next.js приложения на собственный сервер через GitLab CI/CD. PM2 для zero-downtime, Nginx Proxy Manager для управления доменами, secrets management и multi-environment setup.

Деплой Next.js с GitLab CI/CD: От настройки сервера до автоматизации

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?

  1. Блокировки зарубежных сервисов — Vercel, Netlify и другие американские платформы регулярно блокируются или работают нестабильно на территории России. Это критично для production-приложений.

  2. Отсутствие российских аналогов — полноценных российских аналогов Vercel пока не существует (или я о таких не знаю). Те платформы, что есть, либо дороже, либо не предоставляют такой же уровень удобства.

  3. Полный контроль — ваша инфраструктура, ваши правила. Никаких внезапных изменений тарифов, лимитов или блокировок.

  4. Предсказуемая стоимость — фиксированная цена VPS vs. pay-per-use с неожиданными счетами при всплесках трафика.

  5. Гибкость — можно настроить окружение под свои нужды: 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

Почему именно эта связка?

  1. GitLab CI — встроенный в GitLab, бесплатный для self-hosted, мощный YAML DSL
  2. PM2 — production process manager с cluster mode для zero-downtime reload
  3. Nginx Proxy Manager — удобный web-интерфейс для управления reverse proxy, SSL сертификатами и множественными доменами на одном IP
  4. 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 деплоя

  1. Developer пушит код в ветку main или develop
  2. GitLab CI запускает pipeline:
    • Install — установка зависимостей с кэшированием
    • Lint — проверка кода (ESLint, TypeScript)
    • Test — запуск тестов (опционально)
    • Build — production сборка Next.js
    • Deploy — деплой на сервер через SSH
  3. Server получает новый код и:
    • Бэкапит текущую версию
    • Устанавливает зависимости
    • Собирает приложение (или копирует артефакт)
    • Выполняет pm2 reload для zero-downtime перезапуска
    • Проверяет health endpoint
  4. 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 status

SSH 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

Первичная настройка

  1. Откройте http://YOUR_SERVER_IP:81 в браузере
  2. Войдите с дефолтными credentials:
    • Email: admin@example.com
    • Password: changeme
  3. Смените email и пароль при первом входе

Добавление Proxy Host для Next.js

В веб-интерфейсе NPM:

  1. Hosts → Proxy Hosts → Add Proxy Host

  2. 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
  3. 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
  4. 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;
  1. 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 reload
  • instances: 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. Бывают:

  1. Shared Runners — предоставляются GitLab.com бесплатно (лимит 400 минут/месяц)
  2. Specific Runners — ваш собственный раннер на любом сервере
  3. 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_HOSTIP или домен сервера
SSH_USERdeployer
DEPLOY_PATH/var/www/potapov.me
NODE_ENVproduction

Безопасность: Включайте "Protected" для production переменных (доступны только в защищенных ветках) и "Masked" для секретов (не показываются в логах).

Для multi-environment добавляем переменные с Environment scope:

  • production переменные доступны только при деплое в production
  • staging переменные доступны только при деплое в 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:

  1. Install — устанавливает зависимости с кэшированием для ускорения последующих запусков
  2. Lint — проверяет код на ошибки стиля
  3. Build — собирает Next.js приложение и сохраняет артефакты
  4. Deploy — подключается по SSH, создает бэкап, синхронизирует код, устанавливает зависимости, перезапускает PM2
  5. Health Check — проверяет доступность приложения через /api/health
  6. 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_SHA

2. 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:e2e

3. Условный деплой с approval

deploy_production:
  stage: deploy
  when: manual  # Требует ручного подтверждения
  only:
    - main

Часть 4: Secrets Management

Безопасное управление секретами — критически важная часть CI/CD.

Уровни секретов

  1. GitLab CI/CD Variables — для инфраструктурных секретов (SSH ключи, API токены)
  2. Environment Variables на сервере — для application secrets (.env файлы)
  3. 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  # Ручной деплой в production

Workflow:

  1. Разработка → коммит в feature/* ветку → создание MR
  2. Review → мерж в develop → автоматический деплой на staging
  3. Тестирование на staging → мерж develop в main
  4. Ручное подтверждение деплоя в 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-hostedCoolify или Dokploy
Микросервисы с DockerTraefik + 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 status

2. 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@server

3. 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 решение дает полный контроль и уверенность в доступности ваших приложений.

Следующие шаги

  1. Docker-ization — упаковка приложения в Docker для воспроизводимости
  2. Database migrations — автоматизация миграций БД в pipeline
  3. E2E тесты — добавление Playwright/Cypress тестов
  4. Performance budgets — лимиты на размер бандла и Core Web Vitals
  5. CDN integration — раздача статики через CloudFlare/BunnyCDN (российские CDN: CDN.ru, EdgeCenter)

Полезные ссылки


Вопросы? Напишите мне в Telegram или через форму обратной связи.