Деплой на 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».
Как устроен деплой
- GitHub Actions — встроенная в GitHub система, которая по событию (например, push в ветку) запускает команды на временной виртуальной машине, раннере.
- Раннер подключается к серверу по SSH отдельным ключом и копирует файлы в каталог
incomingс помощьюrsync— передаются только изменения. - Скрипт на сервере копирует
incomingв новый каталогreleases/<дата>-<коммит>, переключает на него ссылкуcurrentи перезапускает службу. - Скрипт ждёт, пока приложение ответит на проверочный адрес. Не ответило — ссылка возвращается на прошлый релиз, задача падает.
Старые релизы остаются на диске, поэтому откат — это переключение ссылки, а не новая сборка.
Шаг 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.
- Приложение на Python, которое выкладывает такой workflow, — FastAPI и Django на VPS; копирование файлов — rsync.