- Опубликовано: 17 июл 2026
- 13
Деплой без FTP в 2026-м: git pull, хуки и простой CI/CD на своём VPS
Часть 13 из 14 · Серия «Сервер в бою»
Признавайтесь: у скольких из вас прямо сейчас в FileZilla сохранено подключение к боевому серверу? Деплой перетаскиванием файлов дожил до 2026-го в прекрасной форме — и каждый, кто им пользуется, знает этот букет ощущений: «а какие файлы я менял?», «а точно все залил?», посетители, поймавшие сайт в момент, когда половина файлов новая, а половина старая, и главный ужас — «сломалось, срочно вернуть как было!» — а «как было» не существует нигде, кроме вашей памяти.
Смешное в том, что инструмент, решающий все эти проблемы, у вас уже есть. Код ведь и так лежит в Git (а если нет — начните с этого, серьёзно): полная история, атомарные переключения, откат на любую точку одной командой. Осталось заметить очевидное: Git — это не только контроль версий, это ещё и транспорт. Сегодня пройдём путь от git pull вместо FTP до выкатки одной командой с автоматическим откатом — четырьмя уровнями, каждый из которых самодостаточен: остановиться можно на любом, и это уже будет лучше FileZilla на порядок.
Уровень 1: git pull вместо FTP — минимальный переход
Схема: код живёт на GitHub/GitLab (или вашем Gitea), сервер имеет к репозиторию доступ только на чтение, деплой — это «сервер забрал свежий код». Настройка на десять минут.
На сервере, от пользователя сайта (не от root — восьмая статья объясняла, почему владельцы файлов не должны плыть):
sudo -u site1 -i
ssh-keygen -t ed25519 -f ~/.ssh/deploy_key -N ""
cat ~/.ssh/deploy_key.pub
Публичный ключ добавляем в репозиторий как deploy key с правами read-only — это важная деталь: даже полный взлом сервера (от которого мы застраховались изоляцией) не даст злоумышленнику права писать в ваш репозиторий. Дальше:
git clone [email protected]:you/site1.git /var/www/site1
И весь деплой отныне:
cd /var/www/site1 && git pull
Что мы получили одним махом: файлы всегда в согласованном состоянии («залил не всё» больше не существует как явление); полная история выкаток (git log); возможность посмотреть, чем прод отличается от репозитория (git status — и любая «правка по-быстрому прямо на сервере» становится видимой, что дисциплинирует лучше регламентов); и примитивный, но работающий откат — git checkout на предыдущий коммит. Уже на этом уровне FTP проигрывает всухую.
Два обязательных штриха безопасности. Первый: каталог .git не должен быть доступен из веба. У Laravel и большинства фреймворков корень сайта — public/, и проблема не возникает; если же root смотрит в корень репозитория — закройте явно:
location ~ /\.(git|env) { deny all; }
Открытый .git — это скачиваемая копия всех ваших исходников вместе с историей; сканеры проверяют его на каждом встречном домене. Второй штрих: .env и прочие секреты в репозитории не живут — они лежат на сервере рядом и в .gitignore. К этому ещё вернёмся на уровне 3.
Уровень 2: pull — это не деплой. Скрипт
Быстро выяснится, что забрать код — полдела: надо поставить зависимости, прогнать миграции и — помните обещания? — сбросить кэши, которые мы так старательно настраивали. Пропущенный шаг = «я же исправил, почему не работает?!» (opcache, девятая статья) или «на сайте старая страница» (FastCGI cache). Деплой — это последовательность, а последовательности мы уже договорились оформлять скриптами со всей гигиеной. deploy.sh для типичного Laravel-сайта:
#!/bin/bash
set -euo pipefail
cd /var/www/site1
php artisan down --retry=15 # аккуратная заглушка на время работ
git pull origin main
composer install --no-dev --optimize-autoloader --no-interaction
php artisan migrate --force
php artisan config:cache && php artisan route:cache && php artisan view:cache
sudo /usr/bin/systemctl reload php8.3-fpm # сброс opcache
sudo /usr/local/bin/purge-fastcgi-cache.sh # rm -rf кэша nginx, статья 11
php artisan up
curl -fsS https://site1.ru > /dev/null # смок-тест: сайт жив?
echo "Deployed: $(git rev-parse --short HEAD)"
(Точечные sudo для пользователя сайта разрешаются через sudoers на конкретные команды — не общий доступ.) Благодаря set -e скрипт останавливается на первой ошибке: упал composer — миграции не поедут, кэш не сбросится, и вы разбираетесь с проблемой, а не с её последствиями. Выкатка превратилась в одну команду с предсказуемым содержимым — собственно, заголовок статьи выполнен. Но у этой схемы остаются два врождённых дефекта: пока крутится composer, сайт стоит на заглушке (или, без заглушки, работает на полусобранной версии), а откат по-прежнему требует соображать под давлением. Лечится оба — сменой структуры.
Уровень 3: релизы и симлинк — откат без слёз
Идея старая как Capistrano и по-прежнему лучшая в классе: новую версию собираем полностью в сторонке и переключаемся на неё мгновенно.
/var/www/site1/
├── releases/
│ ├── 20260612-101502/ # прошлые релизы, целиком
│ ├── 20260703-183012/
│ └── 20260704-094455/ # только что собранный
├── shared/
│ ├── .env # секреты — вне релизов
│ └── storage/ # загрузки пользователей, логи
└── current -> releases/20260704-094455/ # nginx смотрит СЮДА
Nginx в конфиге указывает root на current/public — и никогда не меняется. Деплой теперь выглядит так: клонируем код в новый каталог releases/<время>, симлинками подключаем shared/.env и shared/storage (то, что живёт между релизами: секреты и пользовательские данные), собираем всё — composer, кэши фреймворка, миграции — пока старая версия спокойно обслуживает посетителей, и в финале:
ln -sfn /var/www/site1/releases/20260704-094455 /var/www/site1/current
sudo systemctl reload php8.3-fpm
Переключение симлинка атомарно: ни одной секунды «полусобранного» состояния, заглушка не нужна вовсе. А откат — вот ради чего всё затевалось — это то же самое движение в обратную сторону:
ln -sfn /var/www/site1/releases/20260703-183012 /var/www/site1/current
sudo systemctl reload php8.3-fpm
Три секунды, ноль размышлений, работает в три ночи и в состоянии паники — прошлый релиз лежит на диске целиком собранный, его не надо восстанавливать, в него надо просто вернуться. Оформите оба действия скриптами (deploy.sh / rollback.sh), старые релизы чистите, оставляя последние пять (ls -1 releases | head -n -5 | xargs rm -rf в конце деплоя — привет, пятая статья).
Одна честная звёздочка — миграции. Код откатывается симлинком, база — нет: обратные миграции на проде опасны (down-методы редко тестируют, а DROP COLUMN уносит данные). Взрослое правило: миграции пишутся обратно-совместимыми — новая колонка появляется на релиз раньше, чем код перестаёт работать без неё; старая удаляется на релиз позже, чем код перестал её трогать. Тогда откат кода на шаг назад безопасен всегда. И железное правило прямо из вашей статьи про бэкапы: дамп базы — перед каждым деплоем с миграциями, в скрипт, автоматически.
Уровень 4: убираем человека — хуки и простой CI/CD
Осталось перестать заходить на сервер вообще. Слово «CI/CD» звучит как отдельная профессия, но для одного VPS сводится к вопросу: «кто и когда запускает deploy.sh?» Три ответа по возрастанию цивилизованности:
Вариант А: git push прямо на сервер. На VPS заводится bare-репозиторий с хуком post-receive, который запускает деплой; у вас локально — второй remote (git remote add prod ...), и выкатка — это git push prod main. Красиво, минималистично, никаких третьих сторон. Минус: сборка (composer, а тем более npm build) происходит на боевом VPS и ест его память и CPU.
Вариант Б: webhook. Код живёт на GitHub/GitLab, на сервере маленький слушатель (готовый webhook из репозиториев Ubuntu), GitHub дёргает URL при пуше в main — слушатель запускает deploy.sh. Плюс: рабочий процесс не меняется, деплоит сам факт пуша.
Вариант В: GitHub Actions / GitLab CI — и вот это уже настоящий CI/CD. Пайплайн на стороне Git-платформы прогоняет тесты, собирает фронтенд у себя (npm build на VPS с гигабайтом памяти — известный способ устроить OOM в самый неподходящий момент; пусть этим давится бесплатный раннер), и лишь потом по SSH запускает деплой на сервере. Прод получает готовый артефакт и не тратит ни мегабайта на сборку. Для команды больше одного человека — единственный по-настоящему правильный вариант, и это по-прежнему один YAML-файл на полстраницы, а не «отдельная профессия».
И финальный штрих дисциплины, мостик к нашим статьям про Git: выкатывайте теги, а не «что было в main». git tag v1.4.2 && git push --tags — и деплой-скрипт собирает именно тег. Релизы получают имена, changelog пишется сам, а фраза «откатись на v1.4.1» однозначна для всех участников — включая вас самих через полгода.
Грабли деплоя: сводная таблица
| Грабля | Симптом | Лечение |
|---|---|---|
Открытый .git в вебруте |
Исходники сайта утекают сканерам | root в public/ или deny all на /\.git |
.env в репозитории |
Пароли от базы в истории Git навсегда | Секреты — в shared/, в .gitignore; утекли — менять |
| Деплой от root | Чехарда владельцев, «Permission denied» через раз | Всё от пользователя сайта + точечные sudoers |
| Забытый сброс кэшей | «Я же исправил!» / старые страницы | reload php-fpm + purge FastCGI — шаги скрипта, не памяти |
| npm build на слабом VPS | OOM ровно в момент выкатки | Собирать в CI, на сервер — артефакт |
| Правки прямо на проде | git pull конфликтует, изменения теряются | git status перед деплоем; правка на проде = коммит немедленно |
| Обратные миграции при откате | Потерянные колонки и данные | Обратно-совместимые миграции + дамп перед деплоем |
| Деплой в час пик «на минутку» | Минутка, как обычно, не минутка | Симлинк-схема — и деплой в любое время безопасен |
Что в итоге
Проверим по заголовку. Без FTP — да: транспортом стал Git, и «залил не всё» вымерло как класс. Одной командой — да: ./deploy.sh (или просто git push prod main), внутри которой зависимости, миграции, сброс всех кэшей серии и смок-тест. Откат без слёз — да: предыдущий релиз лежит собранным, возврат — это перекинуть симлинк за три секунды. И бонусом — дисциплина, которая приходит бесплатно: история выкаток, именованные теги, видимость любых «правок по-быстрому». Вечер настройки — против многих лет перетаскивания файлов с замиранием сердца.
Серия подходит к финалу, и остался последний большой страх владельца VPS — переезд. Новый провайдер дешевле, старый тариф тесен, или (вспомните шестую статью) вы выбрали «путь Б» вместо release-upgrade — а сайт должен переехать так, чтобы посетители ничего не заметили. В заключительной статье соберём весь чек-лист миграции без даунтайма: снижение TTL заранее, rsync с репетицией, генеральный прогон переключения DNS — и красиво закольцуем серию с того, с чего всё начиналось: с бэкапов, которые реально спасают.
- 1 Сервер упал в три ночи: разбор полётов без паники
- 2 Логи, которые говорят: учимся читать journalctl, nginx и syslog
- 3 Fail2ban и брутфорс: как перестать кормить ботов
- 4 Мониторинг для одного сервера: Uptime Kuma, Netdata и здравый смысл
- 5 Диск закончился внезапно: куда деваются гигабайты на VPS
- 6 Обновления без страха: apt upgrade, который ничего не сломает
- 7 Свой VPN на VPS за полчаса: WireGuard без магии
- 8 Несколько сайтов на одном VPS: делим ресурсы без драки
- 9 Тормозит сайт — виноват не хостинг: тюним nginx и PHP-FPM
- 10 MySQL на маленьком VPS: почему база съедает всю память
- 11 Кэширование на пальцах: Redis, FastCGI cache и когда хватит просто заголовков
- 12 Cron, systemd-таймеры и скрипты: автоматизируем рутину сервера
- 13 Деплой без FTP в 2026-м: git pull, хуки и простой CI/CD на своём VPS вы здесь
- 14 Переезд на другой VPS без даунтайма: чек-лист миграции скоро
Была статья полезной: