Шаг 15 · CI/CD · 35 минут

Автодеплой на VPS через GitHub Actions

«Зайти по SSH, стянуть код, перезапустить контейнер» — нормальный процесс, пока не начинает повторяться по три раза в день. Автоматизируем его через git push.

Как устроен пайплайн

Магии нет: на пуш в ветку main GitHub Actions подключается к VPS по SSH и выполняет там те же команды, что и вы руками — git pull, пересборку контейнера, проверку статуса. Разница только в поводе запуска — событие в репозитории вместо вашего желания в конце дня. Сервер уже должен быть настроен, а приложение запущено через Docker Compose (см. гайд по Docker и базовый деплой на VPS) — автодеплой автоматизирует только шаг обновления.

Отдельный SSH-ключ для CI

Личный ключ разработчика для CI — плохая идея: он даёт доступ ко всему, что доступно человеку. Генерируем отдельную пару только для деплоя (-N "" — без парольной фразы, CI не введёт её интерактивно):

ssh-keygen -t ed25519 -f ~/.ssh/deploy_key -C "github-actions-deploy" -N ""

Публичную часть добавляем на сервере в authorized_keys пользователя для деплоя:

cat ~/.ssh/deploy_key.pub | ssh user@your-vps "cat >> ~/.ssh/authorized_keys"

Приватный ключ никуда не выкладывается — он уходит только в секреты репозитория.

Секреты в GitHub

Settings → Secrets and variables → Actions → New repository secret, четыре значения:

  • HOST — IP или домен VPS;
  • USERNAME — пользователь для деплоя, не обязательно root (см. безопасность SSH);
  • SSH_PRIVATE_KEY — приватный ключ целиком, включая строки BEGIN/END;
  • SSH_PORT — если порт нестандартный.

GitHub маскирует значения секретов в логах job, но это не повод писать их текстом в самом workflow-файле — он лежит в репозитории открыто.

Никогда не вставляйте приватный ключ, пароль или токен прямо в YAML workflow-файла — только через ${{ secrets.ИМЯ }}. Если ключ уже попал в историю коммитов, считайте его скомпрометированным и перевыпускайте.

Workflow-файл

Создаём .github/workflows/deploy.yml: триггер и команды для VPS по SSH. Код на runner GitHub тянуть не нужно — сборка идёт на сервере.

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script: |
            cd /var/www/myapp
            git pull origin main
            docker compose up -d --build

appleboy/ssh-action избавляет от ручного вызова ssh-agent внутри job; альтернатива — «сырой» ssh -i, но это лишний шаг ради того же результата.

Что происходит на сервере при деплое

Блок script — тот же ручной процесс: git pull подтягивает код, docker compose up -d --build (см. Docker Compose) пересобирает образ и перезапускает контейнер. Стоит добавить и docker compose ps — статус попадёт в лог job, и если контейнер не поднялся, это видно сразу в Actions, а не при следующем заходе на сервер.

Откат при ошибке

Автодеплой ускоряет доставку не только удачных изменений, но и багов — откатываться нужно так же быстро:

git log --oneline -5
git checkout <commit-hash>
docker compose up -d --build

Надёжнее — перед пересборкой тегировать рабочий образ (docker tag myapp:latest myapp:previous), тогда откат — это запуск готового myapp:previous без ожидания новой сборки.

Проверка после деплоя

Контейнер может подняться (статус Up) и при этом отдавать 500-ю ошибку — процесс жив, а приложение нет. Добавьте в конец workflow проверку health-эндпоинта:

- name: Health check
  run: |
    sleep 5
    curl -f https://your-domain.ru/health || exit 1

-f у curl превращает HTTP-ошибку в код возврата, который завалит job — о падении узнаёте от GitHub, а не от пользователя в поддержке. Эту же проверку стоит вынести и в постоянный мониторинг.

Типовые ошибки

  • Неверные права на authorized_keys или .ssh — SSH молча отклоняет ключ; нужно chmod 600 ~/.ssh/authorized_keys и chmod 700 ~/.ssh.
  • Пользователь для деплоя не в группе dockerdocker compose падает с ошибкой доступа к сокету при живом SSH-подключении.
  • Кеш слоёв Docker подсовывает старый код после --build — помогает docker compose build --no-cache или проверка .dockerignore.
  • Опечатка в имени секрета (SSH_PRIVATE_KEY vs SSH_PRIVATEKEY) — GitHub подставит пустую строку, и workflow упадёт без внятной причины.

Что дальше

Деплой теперь запускается сам, но узнавать о падении сервиса по звонку от клиента всё ещё не хочется: убедимся, что после автодеплоя сервис жив — настроим мониторинг →