Пока сайт обновляют, перетаскивая файлы по 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одной командой, и закреплённый отпечаток сервера. - Миграции базы — обратно совместимые и после бэкапа.
