Руководства

Автодеплой сайта на VPS: из Git через GitHub Actions без простоя

Автодеплой сайта на VPS: из Git через GitHub Actions без простоя

Пока сайт обновляют, перетаскивая файлы по SFTP, рано или поздно случается одно и то же: половина файлов уже новая, половина ещё старая, и посетители минуту видят ошибку. Или кто-то правит файл прямо на сервере, и следующая выкладка стирает его правку. Автоматический деплой решает обе проблемы: код живёт в Git, на сервер он попадает одной командой или сам после пуша, новая версия включается мгновенно, а откат на предыдущую занимает секунды. Ниже — рабочая схема для PHP-сайта на VPS (Laravel, Symfony, свой код; для WordPress — с оговорками), скрипт деплоя и настройка GitHub Actions.

Схема: релизы и переключение ссылки

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

/var/www/app/
├── releases/
│   ├── 20261030-120000/
│   ├── 20261030-153000/
│   └── 20261030-170500/   ← новая версия
├── shared/
│   ├── .env               ← настройки и пароли, не в Git
│   └── storage/           ← загрузки пользователей, логи
└── current → releases/20261030-170500

Веб-сервер смотрит в current. Переключение ссылки — одна атомарная операция: в любой момент посетитель видит либо целиком старую версию, либо целиком новую, и никогда — смесь. Откат — переключить ссылку на предыдущую папку.

В папке shared лежит всё, что не должно меняться при выкладке: файл с паролями и настройками, загруженные пользователями файлы, логи. В каждый релиз они подключаются ссылками.

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

Выкладку делает отдельный пользователь без пароля и без полных прав администратора:

sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /var/www/app/{releases,shared/storage}
sudo chown -R deploy:www-data /var/www/app

Ему разрешается одна команда с правами root — перезагрузить PHP-FPM после переключения. Файл /etc/sudoers.d/deploy (редактируйте через sudo visudo -f /etc/sudoers.d/deploy):

deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload php8.3-fpm

Версию PHP подставьте свою. Вход для пользователя deploy — только по SSH-ключу. Как настроить ключи и закрыть вход по паролю — в статье «Как настроить VPS после покупки».

Шаг 2. Доступ сервера к репозиторию

Сервер должен уметь скачать код. Для приватного репозитория создайте на сервере ключ только для чтения:

sudo -u deploy ssh-keygen -t ed25519 -f /home/deploy/.ssh/repo_key -N ""
sudo -u deploy cat /home/deploy/.ssh/repo_key.pub

Добавьте открытый ключ в настройки репозитория как Deploy key без права записи (в GitHub: Settings → Deploy keys). Такой ключ даёт доступ только к одному репозиторию и только на чтение. И в /home/deploy/.ssh/config:

Host github.com
    IdentityFile ~/.ssh/repo_key
    IdentitiesOnly yes

Сразу добавьте ключ GitHub в известные хосты, иначе первый автоматический запуск остановится на вопросе «доверять ли серверу»:

sudo -u deploy sh -c 'ssh-keyscan github.com >> ~/.ssh/known_hosts'
sudo -u deploy ssh -T git@github.com    # должно ответить приветствием

Шаг 3. Скрипт деплоя

Файл /home/deploy/deploy.sh:

#!/bin/bash
set -euo pipefail

APP=/var/www/app
REPO=git@github.com:you/your-app.git
BRANCH=main
KEEP=5
REL=$APP/releases/$(date +%Y%m%d-%H%M%S)

echo "→ Скачиваю $BRANCH в $REL"
git clone --depth 1 --branch "$BRANCH" "$REPO" "$REL"

echo "→ Подключаю общие файлы"
ln -s $APP/shared/.env "$REL/.env"
rm -rf "$REL/storage" && ln -s $APP/shared/storage "$REL/storage"

echo "→ Зависимости"
cd "$REL"
composer install --no-dev --optimize-autoloader --no-interaction

# Миграции базы, если они есть в проекте, например для Laravel:
# php artisan migrate --force

echo "→ Переключаю версию"
ln -sfn "$REL" $APP/current.tmp && mv -T $APP/current.tmp $APP/current
sudo /usr/bin/systemctl reload php8.3-fpm

echo "→ Удаляю старые релизы, оставляю $KEEP"
ls -1dt $APP/releases/*/ | tail -n +$((KEEP+1)) | xargs -r rm -rf

echo "✓ Готово: $(basename "$REL")"
sudo -u deploy chmod +x /home/deploy/deploy.sh
sudo -u deploy /home/deploy/deploy.sh

Несколько деталей, на которых обычно спотыкаются:

  • set -euo pipefail — скрипт останавливается на первой ошибке. Если упала установка зависимостей, ссылка не переключится, и сайт продолжит работать на старой версии.
  • Переключение через mv -T — атомарная замена ссылки. Обычный ln -sfn поверх существующей ссылки на долю секунды удаляет её.
  • Перезагрузка PHP-FPM — нужна из-за кэша OPcache: без неё PHP какое-то время может выполнять файлы старого релиза.
  • Миграции базы выполняются до переключения. Делайте их обратно совместимыми (сначала добавить столбец, удалить старый — в следующем релизе): иначе старая версия, которая ещё работает несколько секунд, упадёт на новой схеме. Перед крупными миграциями — бэкап базы («База данных на VPS»).

Шаг 4. Nginx смотрит в current

root /var/www/app/current/public;

location ~ \.php$ {
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    fastcgi_param DOCUMENT_ROOT $realpath_root;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}

Важно использовать $realpath_root, а не $document_root: так PHP получает реальный путь к папке релиза, а не путь через ссылку, и кэш путей не путает версии. После правки: nginx -t && systemctl reload nginx.

Шаг 5. Откат

Если новая версия сломалась — переключаемся на предыдущую:

cd /var/www/app
PREV=$(ls -1dt releases/*/ | sed -n 2p)
ln -sfn "/var/www/app/$PREV" current.tmp && mv -T current.tmp current
sudo systemctl reload php8.3-fpm

Откат кода не откатывает базу: если релиз менял схему данных, это нужно предусмотреть отдельно. Поэтому и важны обратно совместимые миграции.

Шаг 6. Деплой после пуша: GitHub Actions

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

ssh-keygen -t ed25519 -f gh_deploy -N ""          # на своём компьютере
ssh-keyscan -t ed25519 IP_СЕРВЕРА                # отпечаток сервера

Открытый ключ gh_deploy.pub добавьте в /home/deploy/.ssh/authorized_keys, ограничив его одной командой:

command="/home/deploy/deploy.sh",no-port-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA... github-actions

С такой строкой этот ключ не даёт доступа к командной строке сервера: при подключении всегда запускается только скрипт деплоя. Даже если ключ утечёт, злоумышленник сможет лишь выложить код из вашего же репозитория.

В настройках репозитория (Settings → Secrets and variables → Actions) добавьте секреты: DEPLOY_KEY — содержимое закрытого ключа gh_deploy, DEPLOY_HOST — IP сервера, KNOWN_HOSTS — вывод ssh-keyscan. И создайте файл .github/workflows/deploy.yml:

name: Deploy
on:
  push:
    branches: [main]

concurrency: production

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy over SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
          echo "${{ secrets.KNOWN_HOSTS }}" > ~/.ssh/known_hosts
          chmod 600 ~/.ssh/id_ed25519
          ssh deploy@${{ secrets.DEPLOY_HOST }}

concurrency не даёт двум выкладкам идти одновременно, если пуши были подряд. Отпечаток сервера в KNOWN_HOSTS защищает от подмены сервера: без него SSH либо откажется подключаться, либо придётся отключать проверку, что небезопасно.

Если в проекте есть тесты, добавьте их отдельной задачей перед деплоем — тогда сломанный код просто не дойдёт до сервера.

Если файрвол ограничивает SSH по IP

Серверы GitHub Actions подключаются с разных адресов, поэтому ограничить SSH списком IP для них неудобно. Варианты: оставить SSH открытым, но только по ключам и с fail2ban; или поставить на сервер собственный исполнитель задач (self-hosted runner) — он сам забирает задания у GitHub, и входящий SSH не нужен. Self-hosted runner используйте только с приватными репозиториями: он выполняет на вашем сервере код из репозитория. Условия использования и тарификации исполнителей уточняйте в документации GitHub — они меняются.

Варианты под другие проекты

  • Статический сайт или фронтенд — собрать в GitHub Actions и отправить готовые файлы через rsync -az --delete в новую папку релиза, затем переключить ссылку.
  • Приложение в Docker — собрать образ, отправить в реестр, а на сервере выполнить docker compose pull && docker compose up -d («Docker на VPS»).
  • WordPress — по этой схеме хорошо выкладываются тема и свои плагины. Ядро и плагины из каталога, которые обновляются через админку, и папку uploads держите в shared или вне Git, иначе выкладка будет откатывать обновления.
  • Без GitHub — те же скрипты работают с GitLab CI или собственным Gitea/Forgejo с их системами задач.

Тестовая копия

Хорошая привычка — отдельный сервер или поддомен для проверки перед выкладкой на рабочий сайт: ветка develop выкладывается на тестовую копию, main — на рабочий сайт. Тестовой копии хватит самого младшего тарифа.

Start в Финляндии для рабочего сайта — $10/мес   Lite для тестовой копии — $6/мес

Цены указаны на момент публикации, оплата у провайдера. Если сайт ещё на обычном хостинге, сначала перенесите его — «Как перенести сайт на VPS без простоя».

Коротко

  • Каждая выкладка — в новую папку релиза, переключение — атомарной заменой ссылки current; откат — переключить ссылку назад.
  • Настройки, пароли и загрузки пользователей — в shared, не в Git.
  • Отдельный пользователь deploy, ключ только на чтение к репозиторию, sudo только на перезагрузку PHP-FPM.
  • В Nginx — $realpath_root, после переключения — перезагрузка PHP-FPM из-за OPcache.
  • Для автодеплоя из GitHub Actions — ключ, ограниченный в authorized_keys одной командой, и закреплённый отпечаток сервера.
  • Миграции базы — обратно совместимые и после бэкапа.