Деплой на VPS из GitHub Actions по SSH — самый простой способ выкладывать проект автоматически: вы отправляете коммит в ветку main, GitHub сам загружает файлы на сервер, переключает приложение на новую версию и проверяет, что оно отвечает. Если новая версия не поднялась, сервер возвращается к предыдущей, а задача в GitHub помечается как упавшая. Ниже — вся схема: отдельный ключ и пользователь для деплоя, секреты, workflow, каталоги релизов с откатом и две среды — рабочая и тестовая.

Проверено на серверах с Ubuntu 24.04 и 26.04: шаги workflow выполнялись из отдельного контейнера с теми же версиями ssh и rsync, что в образе GitHub ubuntu-24.04, синтаксис workflow проверен actionlint 1.7.12. Вместо 203.0.113.10 подставьте адрес своего сервера, вместо example.com — свой домен.

Что нужно заранее

  • Сервер с Ubuntu, на который вы входите по ключу и можете выполнять sudo — см. первую настройку сервера.
  • Репозиторий на GitHub, в котором вы можете менять настройки (Settings).
  • Приложение, которое на сервере запускается как служба systemd. Ниже для примера — простая служба; как так запустить Next.js, описано в статье «Деплой Next.js на VPS».

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

  1. GitHub Actions — встроенная в GitHub система, которая по событию (например, push в ветку) запускает команды на временной виртуальной машине, раннере.
  2. Раннер подключается к серверу по SSH отдельным ключом и копирует файлы в каталог incoming с помощью rsync — передаются только изменения.
  3. Скрипт на сервере копирует incoming в новый каталог releases/<дата>-<коммит>, переключает на него ссылку current и перезапускает службу.
  4. Скрипт ждёт, пока приложение ответит на проверочный адрес. Не ответило — ссылка возвращается на прошлый релиз, задача падает.

Старые релизы остаются на диске, поэтому откат — это переключение ссылки, а не новая сборка.

Шаг 1. Пользователь для деплоя на сервере

Раннер не должен входить ни как root, ни как ваш администратор. Создайте отдельного пользователя deploy без пароля и без права sudo:

sudo useradd -m -s /bin/bash deploy
sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
sudo install -d -o deploy -g deploy /srv/app /srv/app/prod /srv/app/test

Если по статье о первой настройке вы уже назвали deploy своего администратора с sudo, возьмите для деплоя другое имя, например ci, и подставляйте его везде ниже.

В /srv/app/prod будет рабочая версия (ветка main), в /srv/app/test — тестовая (ветка dev). Для каждой среды — файл настроек скрипта выкладки:

sudo tee /srv/app/prod/release.conf > /dev/null <<'EOF'
SERVICE=app@prod.service
HEALTH_URL=http://127.0.0.1:8080/healthz
KEEP=5
EOF
sudo tee /srv/app/test/release.conf > /dev/null <<'EOF'
SERVICE=app@test.service
HEALTH_URL=http://127.0.0.1:8081/healthz
KEEP=3
EOF
echo 'PORT=8080' | sudo tee /srv/app/prod/app.env
echo 'PORT=8081' | sudo tee /srv/app/test/app.env
sudo chown deploy:deploy /srv/app/*/release.conf /srv/app/*/app.env

KEEP — сколько последних релизов хранить; HEALTH_URL — адрес, по которому скрипт проверяет, что приложение живо.

Шаг 2. Служба systemd для приложения

Одна служба-шаблон обслуживает обе среды: в имени app@prod.service часть после @ подставляется вместо %i. Для примера служба отдаёт файлы релиза встроенным в Python веб-сервером — замените ExecStart на запуск своего приложения.

sudo tee /etc/systemd/system/app@.service > /dev/null <<'EOF'
[Unit]
Description=Приложение (%i)
After=network.target

[Service]
User=deploy
Group=deploy
EnvironmentFile=/srv/app/%i/app.env
WorkingDirectory=/srv/app/%i/current
ExecStart=/usr/bin/python3 -m http.server ${PORT} --bind 127.0.0.1
Restart=on-failure

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable app@prod.service app@test.service

Запускать службы сейчас не нужно: каталога current ещё нет, его создаст первый деплой.

Шаг 3. Разрешить перезапуск службы без пароля

Пользователю deploy нужно одно действие с правами root — перезапустить свою службу. Разрешим ровно эти две команды:

echo 'deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart app@prod.service, /usr/bin/systemctl restart app@test.service' \
  | sudo tee /etc/sudoers.d/deploy
sudo chmod 440 /etc/sudoers.d/deploy
sudo visudo -cf /etc/sudoers.d/deploy

Последняя команда должна ответить parsed OK. Ошибка в файле sudoers может сломать sudo целиком, поэтому проверяйте его всегда.

Проверка: sudo -l -U deploy показывает две разрешённые команды. Любая другая команда через sudo от имени deploy получит отказ — в Ubuntu 24.04 это sudo: a password is required, в 26.04, где вместо sudo работает sudo-rs, — I'm sorry deploy. I'm afraid I can't do that.

Шаг 4. Отдельный SSH-ключ для деплоя

На своём компьютере создайте ключ ed25519 без парольной фразы — раннер не сможет её ввести:

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

Получится два файла: deploy_key (закрытый, пойдёт в секреты GitHub) и deploy_key.pub (открытый, пойдёт на сервер). Этот ключ используется только для деплоя: если он утечёт, его отзывают удалением одной строки, не трогая ваши личные ключи. Как устроены SSH-ключи, подробно описано в статье про вход по ключу.

Скопируйте открытый ключ на сервер под своим пользователем с sudo (здесь и дальше admin — замените на своё имя, а 22746 — на свой порт SSH; если порт стандартный, пишите 22):

scp -P 22746 deploy_key.pub admin@203.0.113.10:

И на сервере добавьте его пользователю deploy, приписав в начале слово restrict:

echo "restrict $(cat deploy_key.pub)" | sudo tee -a /home/deploy/.ssh/authorized_keys > /dev/null
sudo chown deploy:deploy /home/deploy/.ssh/authorized_keys
sudo chmod 600 /home/deploy/.ssh/authorized_keys
rm deploy_key.pub

restrict запрещает с этим ключом проброс портов, проброс агента и терминал: выполнять команды и работать rsync можно, открыть интерактивную сессию или туннель — нет. Проверка:

ssh -i deploy_key -p 22746 deploy@203.0.113.10 'whoami; sudo -n -l'

Ответ — deploy и список из двух разрешённых команд.

Шаг 5. Закрепить ключ сервера: known_hosts

При первом подключении SSH спрашивает, доверять ли ключу сервера. Раннер ответить не может, поэтому в интернете часто советуют StrictHostKeyChecking=no — так проверка выключается совсем, и раннер отправит код любому, кто ответит на этот адрес. Правильно — заранее сохранить ключ сервера в секрет и требовать точного совпадения.

На своём компьютере:

ssh-keyscan -p 22746 -t ed25519 203.0.113.10 > known_hosts_deploy
ssh-keygen -lf known_hosts_deploy

Файл получится из одной строки вида [203.0.113.10]:22746 ssh-ed25519 AAAA… (на порту 22 — без скобок и порта), вторая команда покажет его отпечаток SHA256:…. ssh-keyscan записывает ключ того, кто ответил, поэтому сверьте отпечаток с настоящим — на самом сервере, через уже проверенное подключение или веб-консоль хостинга:

ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

Строки SHA256:… должны совпасть. Если сервер переустановят, ключ сменится — тогда повторите этот шаг.

Шаг 6. Секреты в репозитории

В репозитории откройте Settings → Secrets and variables → Actions → New repository secret и добавьте:

Секрет Значение
DEPLOY_HOST 203.0.113.10
DEPLOY_PORT 22746 (или 22)
DEPLOY_USER deploy
DEPLOY_SSH_KEY всё содержимое файла deploy_key, со строками BEGIN и END
DEPLOY_KNOWN_HOSTS содержимое known_hosts_deploy

Значения секретов GitHub скрывает в журналах и не показывает даже владельцу репозитория. После сохранения удалите deploy_key со своего компьютера или перенесите в менеджер паролей.

Шаг 7. Скрипт выкладки с релизами и откатом

Положите в репозиторий файл deploy/release.sh. Он выполняется на сервере от имени deploy:

#!/usr/bin/env bash
# Выкладка релиза и откат.
#   release.sh deploy   /srv/app/prod <версия>
#   release.sh rollback /srv/app/prod
set -euo pipefail

ACTION="${1:?укажите deploy или rollback}"
APP_DIR="${2:?укажите каталог приложения, например /srv/app/prod}"
cd "$APP_DIR"
source ./release.conf              # SERVICE, HEALTH_URL, KEEP
mkdir -p releases shared

# Атомарно переключить current на релиз и перезапустить службу
switch_to() {
  ln -sfn "releases/$1" current.new
  mv -T current.new current
  sudo -n systemctl restart "$SERVICE"
}

# Подождать, пока приложение ответит на проверочный адрес (до 30 секунд)
healthy() {
  for _ in $(seq 1 15); do
    curl -fs -o /dev/null --max-time 3 "$HEALTH_URL" && return 0
    sleep 2
  done
  return 1
}

case "$ACTION" in
deploy)
  VERSION="${3:?укажите версию, например хеш коммита}"
  NEW="$(date -u +%Y%m%d-%H%M%S)-${VERSION:0:7}"
  PREV="$(basename "$(readlink current)" 2>/dev/null || true)"

  cp -a incoming "releases/$NEW"
  [ -e shared/.env ] && ln -sfn "$APP_DIR/shared/.env" "releases/$NEW/.env"
  # Сборка или установка зависимостей, если нужна, например:
  # (cd "releases/$NEW" && npm ci --omit=dev)

  echo "==> переключаемся на $NEW"
  switch_to "$NEW"
  if ! healthy; then
    echo "==> $NEW не отвечает на $HEALTH_URL" >&2
    if [ -n "$PREV" ]; then
      switch_to "$PREV"
      rm -rf "releases/$NEW"
      echo "==> вернули $PREV, $NEW удалён" >&2
    fi
    exit 1
  fi
  echo "==> $NEW работает"

  # Оставить KEEP последних релизов
  ls -1 releases | sort | head -n -"${KEEP:-5}" | while read -r old; do
    rm -rf "releases/$old"
  done
  ;;
rollback)
  CUR="$(basename "$(readlink current)")"
  PREV="$(ls -1 releases | sort | grep -x -B1 "$CUR" | head -n1)"
  if [ -z "$PREV" ] || [ "$PREV" = "$CUR" ]; then
    echo "==> релиза старше $CUR нет" >&2
    exit 1
  fi
  echo "==> откат: $CUR -> $PREV"
  switch_to "$PREV"
  if ! healthy; then
    switch_to "$CUR"
    echo "==> $PREV не отвечает, оставили $CUR" >&2
    exit 1
  fi
  echo "==> $PREV работает"
  ;;
*)
  echo "неизвестное действие: $ACTION" >&2
  exit 1
  ;;
esac

Что здесь важно:

  • Атомарное переключение. ln -sfn создаёт новую ссылку рядом, mv -T подменяет current одной операцией — нет момента, когда ссылки нет.
  • Общие файлы. То, что не должно меняться от релиза к релизу (.env с паролями, загруженные пользователями файлы), храните в shared/ и подключайте ссылкой.
  • Сборка. Если приложению нужны зависимости или сборка на сервере, впишите команду на место комментария. Тяжёлую сборку лучше делать в самом workflow и загружать готовый результат.
  • Проверочный адрес. У приложения должен быть адрес, который отвечает кодом 200, только когда всё работает. В примере это файл healthz в корне репозитория; в настоящем приложении — обработчик /healthz, который заодно проверяет подключение к базе.

Шаг 8. Workflow: деплой при push в main и dev

Файл .github/workflows/deploy.yml:

name: deploy

# main → /srv/app/prod (example.com), dev → /srv/app/test (test.example.com)
on:
  push:
    branches: [main, dev]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: deploy-${{ github.ref_name }}
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    env:
      APP_DIR: ${{ github.ref_name == 'main' && '/srv/app/prod' || '/srv/app/test' }}
      SITE_URL: ${{ github.ref_name == 'main' && 'https://example.com' || 'https://test.example.com' }}
    steps:
      - uses: actions/checkout@v7

      # Здесь может быть сборка: npm ci && npm run build и т. п.

      - name: Настроить SSH
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
          HOST: ${{ secrets.DEPLOY_HOST }}
          PORT: ${{ secrets.DEPLOY_PORT }}
          SSH_USER: ${{ secrets.DEPLOY_USER }}
        run: |
          install -d -m 700 ~/.ssh
          printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
          cat > ~/.ssh/config <<EOF
          Host server
            HostName $HOST
            Port $PORT
            User $SSH_USER
            IdentityFile ~/.ssh/deploy_key
            IdentitiesOnly yes
            BatchMode yes
            StrictHostKeyChecking yes
          EOF

      - name: Загрузить файлы
        run: |
          ssh server "mkdir -p $APP_DIR/incoming"
          rsync -az --delete --exclude .git --exclude .github ./ "server:$APP_DIR/incoming/"

      - name: Переключить релиз
        run: ssh server "bash $APP_DIR/incoming/deploy/release.sh deploy $APP_DIR $GITHUB_SHA"

      - name: Проверить сайт снаружи
        run: curl -fsS --retry 5 --retry-delay 3 --retry-all-errors -o /dev/null "$SITE_URL/healthz"

Разберём неочевидное:

  • concurrency ставит деплои одной ветки в очередь: два push подряд не будут выкладываться одновременно. cancel-in-progress: false не даёт оборвать деплой на середине.
  • permissions: contents: read — токен GitHub в этой задаче может только читать код.
  • Секреты передаются через env, а не вставляются прямо в команду: так спецсимволы в значении не сломают скрипт.
  • printf '%s\n' добавляет перевод строки в конце ключа. Без него OpenSSH отказывается читать ключ с ошибкой error in libcrypto.
  • ~/.ssh/config задаёт короткое имя server с адресом, портом и ключом — дальше достаточно ssh server. Нестандартный порт указывается здесь один раз. StrictHostKeyChecking yes и BatchMode yes запрещают подключаться к серверу с незнакомым ключом и что-либо спрашивать.
  • actions/checkout@v7 — актуальная основная версия на октябрь 2026 года, работает на Node.js 24. rsync и ssh в образе ubuntu-24.04 уже установлены.
  • $APP_DIR в командах ssh подставляется на раннере, до отправки на сервер, — так и задумано. Поэтому actionlint с shellcheck показывает на этих строках подсказку SC2029: её можно не исправлять.
  • Последний шаг проверяет сайт снаружи — через домен, Nginx и HTTPS. Скрипт на сервере проверяет только само приложение.

Отправьте коммит в dev, затем в main и откройте вкладку Actions. В журнале шага «Переключить релиз» будет:

==> переключаемся на 20261011-112455-e2f7ec6
==> 20261011-112455-e2f7ec6 работает

Проверка на сервере:

ls /srv/app/prod/releases
readlink /srv/app/prod/current
curl -s http://127.0.0.1:8080/healthz

Рядом лежат каталоги релизов, current указывает на последний, приложение отвечает.

Если новая версия не поднялась

Если приложение после переключения не отвечает 30 секунд, скрипт сам возвращает прошлый релиз и завершается с ошибкой — задача в GitHub краснеет:

==> переключаемся на 20261011-112458-b87c621
==> 20261011-112458-b87c621 не отвечает на http://127.0.0.1:8080/healthz
==> вернули 20261011-112455-e2f7ec6, 20261011-112458-b87c621 удалён
Error: Process completed with exit code 1.

Сайт при этом продолжает работать на прошлой версии. Каталог неудачного релиза скрипт удаляет, чтобы потом не откатиться на него по ошибке. Причину покажет журнал службы: journalctl -u app@prod -n 50.

Откат на предыдущую версию

Быстрый откат на сервере — когда новая версия запустилась, но работает неправильно. Войдите на сервер своим пользователем и выполните скрипт от имени deploy:

sudo -u deploy bash /srv/app/prod/current/deploy/release.sh rollback /srv/app/prod
==> откат: 20261011-112455-e2f7ec6 -> 20261011-112452-eb6d1cd
==> 20261011-112452-eb6d1cd работает

Каждый следующий вызов откатывает ещё на один релиз назад, пока они есть; когда старше нет, скрипт ответит релиза старше … нет. Если прошлый релиз не отвечает на проверочный адрес, скрипт вернёт текущий и завершится с ошибкой.

Откат через git — когда ошибку нужно убрать из истории, чтобы следующий деплой её не вернул:

git revert <хеш-коммита>
git push

git revert создаёт новый коммит, отменяющий изменения, а workflow выкладывает его как обычную версию. Быстрый откат на сервере не меняет репозиторий: следующий push в main снова выложит код из ветки, поэтому после него всё равно исправьте или отмените коммит.

Отдельный тестовый сервер: environments

Если тестовая среда живёт на другом сервере, удобнее не вычислять адреса в workflow, а завести в GitHub environments — наборы секретов и переменных под каждую среду (Settings → Environments). Создайте production и test, положите в каждую свои DEPLOY_* и переменные APP_DIR, SITE_URL и замените начало задачи:

jobs:
  deploy:
    runs-on: ubuntu-24.04
    environment:
      name: ${{ github.ref_name == 'main' && 'production' || 'test' }}
      url: ${{ vars.SITE_URL }}
    env:
      APP_DIR: ${{ vars.APP_DIR }}
      SITE_URL: ${{ vars.SITE_URL }}

Секреты с теми же именами из environment заменяют секреты репозитория, остальные шаги не меняются. Для production можно включить обязательное подтверждение деплоя человеком и разрешить его только из ветки main. Ограничение: по документации GitHub на бесплатном тарифе environments доступны только в публичных репозиториях; для приватных нужен GitHub Pro или Team.

rsync или git pull

Можно не загружать файлы с раннера, а выполнять на сервере git pull. Тогда серверу нужен доступ к репозиторию (отдельный deploy key только на чтение в настройках репозитория), сборка идёт на самом сервере, а откат — это git checkout старого коммита с повторной сборкой. Вариант с rsync проще в правах: сервер не ходит в GitHub, а собирать можно на раннере, где не жалко памяти и процессора. Каталоги релизов дают откат без пересборки в обоих случаях.

Если не работает

Что видно в журнале Что делать
No ED25519 host key is known for [203.0.113.10]:22746, Host key verification failed Пустой или неверный DEPLOY_KNOWN_HOSTS, другой порт или переустановленный сервер. Повторите шаг 5 с правильным портом
Load key "…/deploy_key": error in libcrypto Ключ скопирован не полностью. Вставьте в секрет весь файл, включая строки -----BEGIN и -----END
Permission denied (publickey) Открытого ключа нет в /home/deploy/.ssh/authorized_keys, неверные права (700 на .ssh, 600 на файл) или в DEPLOY_USER другое имя
Шаг висит, затем Connection timed out Порт SSH закрыт файрволом. Адреса раннеров GitHub меняются, поэтому порт должен быть открыт для всех: sudo ufw allow 22746/tcp
sudo: a password is required или I'm afraid I can't do that Команда в скрипте не совпадает со строкой в /etc/sudoers.d/deploy символ в символ: проверьте имя службы и путь /usr/bin/systemctl
PTY allocation request failed при входе ключом деплоя Так и задумано: restrict запрещает терминал. Для работы на сервере входите своим ключом
не отвечает на http://127.0.0.1:8080/healthz Приложение не стартовало: journalctl -u app@prod -n 50. Проверьте, что адрес в release.conf совпадает с портом приложения

Что дальше

  • Деплой Next.js на VPS: Node.js, systemd, Nginx и HTTPS — как запустить приложение, которое выкладывает этот workflow.
  • Первая настройка сервера на Ubuntu — файрвол, fail2ban и обновления до первого деплоя.
  • Вход на сервер по SSH-ключу — как устроены ключи и что делать, если не пускает.
  • Если приложение работает в контейнерах — Docker Compose в продакшене: обновление стека без простоя.
  • Свой Git-сервер с CI на собственном VPS вместо GitHub — Forgejo в Docker с Actions.
  • Если не хочется писать workflow и скрипты выкладки — своя платформа с деплоем по git push: Coolify на VPS.
  • Приложение на Python, которое выкладывает такой workflow, — FastAPI и Django на VPS; копирование файлов — rsync.