- Опубликовано: 29 июл 2026
- 17
Деплой без FTP на виртуальном хостинге: Git, Composer и безопасный откат
Часть 9 из 14 · Серия «Хостинг без магии»
Новая версия сайта готова. Разработчик открывает FTP-клиент, выделяет несколько тысяч файлов и нажимает «Загрузить с заменой».
Первые файлы уже обновились, остальные ещё передаются. В этот момент посетитель открывает сайт. Новый контроллер пытается вызвать класс из новой версии пакета, но каталог vendor обновился только наполовину. Вместо страницы появляется ошибка 500.
Разработчик повторяет загрузку. Затем ещё раз передаёт несколько файлов, которые FTP-клиент пропустил из-за разрыва соединения. Сайт открывается, но без стилей: папка public/build осталась от предыдущей версии.
Наконец, код и зависимости совпали. Однако новая версия уже выполнила миграцию базы данных, а старый код, который разработчик пытается вернуть, не понимает изменившуюся структуру таблиц.
Обычное обновление сайта превратилось в аварию.
Проблема здесь не в PHP-фреймворке и не обязательно в виртуальном хостинге. Проблема в самом подходе:
Файлы копировались непосредственно поверх работающего сайта без зафиксированной версии, определённого порядка действий и подготовленного способа отката.
В предыдущей статье мы разобрали, какие возможности нужны современному PHP-проекту: SSH, Composer, Node.js, cron, Git и отдельная публичная директория. Теперь используем эти инструменты по назначению и соберём воспроизводимый деплой.
Деплой — это не загрузка файлов
Загрузка кода на сервер — только один из этапов.
Полный деплой современного PHP-приложения обычно включает:
- выбор конкретной версии кода;
- получение этой версии из репозитория;
- установку PHP-зависимостей;
- сборку CSS и JavaScript;
- применение миграций базы данных;
- очистку старых кэшей;
- создание production-кэшей;
- переключение сайта на новую версию;
- проверку основных функций;
- откат при неудаче.
Symfony в официальной документации перечисляет примерно тот же набор типовых задач: получение кода, установку зависимостей, миграции, очистку и прогрев кэша. Там же отмечается недостаток ручной передачи через FTP: во время обновления нет нормального контроля над состоянием работающего приложения.
Хороший деплой должен отвечать на четыре вопроса:
- какая версия сейчас работает;
- какие команды выполнялись при её установке;
- завершились ли все шаги успешно;
- как вернуть предыдущую версию.
Если ответы приходится восстанавливать по памяти разработчика и журналу FTP-клиента, процесса деплоя фактически нет.
Почему FTP плохо подходит для обновления приложения
FTP полезен для разовой передачи изображения, архива или текстового файла. Но как основной механизм публикации современного проекта он создаёт несколько системных проблем.
Сайт оказывается между двумя версиями
Файлы обновляются последовательно.
Пока передача не закончилась, на сервере находится смесь:
- новых PHP-классов;
- старых шаблонов;
- частично обновлённых зависимостей;
- старой frontend-сборки;
- нового
composer.json; - предыдущего каталога
vendor.
Посетитель может попасть на сайт именно в этом промежуточном состоянии.
Нельзя точно определить опубликованную версию
В Git каждый рабочий набор файлов связан с конкретным коммитом:
7fd124a Fix checkout validation
После ручной загрузки по FTP точной версии может не существовать. Один файл взят из последнего коммита, другой исправлен прямо на сервере, третий не загрузился, четвёртый остался от предыдущего релиза.
Повторная передача не гарантирует одинаковый результат
FTP-клиент может:
- пропустить скрытые файлы;
- изменить права;
- передать не все каталоги;
- сравнивать файлы только по дате;
- прервать соединение;
- спросить о замене уже после начала операции.
Результат зависит не только от исходного кода, но и от истории ручных действий.
Откат приходится выполнять тем же способом
Чтобы вернуть прошлую версию, нужно найти старую копию проекта и снова загрузить тысячи файлов поверх работающего сайта.
Если база данных уже изменилась, одного возврата файлов может быть недостаточно.
Основа нормального деплоя: код нельзя смешивать с данными
Перед подключением Git проект следует разделить на три категории.
1. Код и описание зависимостей
Эти файлы хранятся в репозитории:
app/
config/
database/migrations/
resources/
routes/
src/
templates/
composer.json
composer.lock
package.json
package-lock.json
Для каждого коммита их содержимое должно быть определено заранее.
2. Секреты окружения
Они существуют на сервере, но не должны попадать в репозиторий:
.env
.env.prod.local
закрытые ключи
пароли внешних сервисов
production-токены
Laravel отдельно предупреждает, что .env не следует добавлять в систему контроля версий: разные окружения используют разные настройки, а утечка репозитория раскроет содержащиеся в файле секреты.
3. Изменяемые данные
Они появляются уже во время работы сайта:
загруженные пользователями изображения
документы
логи
экспорты
временные файлы
содержимое файловых сессий
Такие данные нельзя удалять при публикации нового релиза или возвращать к состоянию старого коммита.
Для Laravel к ним относится прежде всего часть каталога storage, для Symfony — содержимое изменяемых runtime-каталогов, а для CMS — папка пользовательских загрузок.
Правило простое:
Git управляет кодом, но не production-секретами и не пользовательскими данными.
Что должно быть зафиксировано в репозитории
Деплой становится воспроизводимым только тогда, когда репозиторий содержит не приблизительное описание проекта, а точные входные данные для его сборки.
composer.lock
В production выполняется:
composer install
Команда устанавливает версии, зафиксированные в composer.lock.
Команда:
composer update
подбирает новые версии пакетов и изменяет lock-файл. Это действие выполняют во время разработки, проверяют тестами и только затем коммитят результат.
Для production Composer поддерживает флаги --no-dev и --optimize-autoloader: первый исключает зависимости для разработки, второй создаёт оптимизированную карту классов.
package-lock.json
Для frontend-зависимостей используется тот же принцип.
Вместо обычного:
npm install
в сценарии деплоя предпочтительнее:
npm ci
npm ci требует существующий lock-файл, не изменяет его и останавливается с ошибкой, если package.json и package-lock.json противоречат друг другу. Команда предназначена в том числе для автоматизированных сборок и деплоя.
Миграции
Изменения структуры базы должны храниться в виде миграций:
database/migrations/
migrations/
Ручное добавление столбца через phpMyAdmin без соответствующей миграции создаёт отдельную, нигде не описанную версию базы.
На следующем сервере или при восстановлении из резервной копии повторить такое изменение будет сложно.
Команды сборки
В composer.json и package.json должны находиться рабочие сценарии:
{
"scripts": {
"build": "vite build"
}
}
Деплой не должен зависеть от инструкции вида:
Запусти примерно те же команды, что мы выполняли в прошлый раз, только сначала удали какой-то кэш.
Production-сервер не должен быть редактором кода
Перед первым деплоем через Git полезно принять жёсткое правило:
Файлы приложения не редактируются непосредственно на production-хостинге.
Не следует открывать контроллер в файловом менеджере, исправлять одну строку и забывать перенести изменение обратно в репозиторий.
Такая правка создаёт локальное изменение:
git status --short
M app/Http/Controllers/OrderController.php
Следующий деплой либо остановится из-за конфликта, либо уничтожит изменение.
Правильная последовательность выглядит так:
правка локально
↓
тестирование
↓
commit
↓
push
↓
deploy
Даже срочное исправление должно пройти через репозиторий. Это занимает на несколько минут больше, но после аварии остаётся точный коммит, который можно повторить и откатить.
Как предоставить хостингу доступ к закрытому репозиторию
Для открытого репозитория достаточно обычного git clone.
Для закрытого проекта серверу нужны учётные данные. Передавать на production личный пароль разработчика или токен с доступом ко всем репозиториям — плохая идея.
Для одного проекта удобно использовать deploy key — отдельный SSH-ключ, привязанный к конкретному репозиторию. Закрытая часть остаётся на сервере, а публичная добавляется в настройки репозитория. GitHub позволяет сделать такой ключ только для чтения; доступ на запись для обычного деплоя не требуется.
Ключ создаётся на хостинге:
ssh-keygen \
-t ed25519 \
-C "deploy@example.com" \
-f ~/.ssh/example_deploy
Публичная часть выводится командой:
cat ~/.ssh/example_deploy.pub
Она добавляется в настройки репозитория как deploy key без разрешения записи.
Для выбора ключа создаётся ~/.ssh/config:
Host github-example
HostName github.com
User git
IdentityFile ~/.ssh/example_deploy
IdentitiesOnly yes
После этого репозиторий клонируется через созданный псевдоним:
git clone git@github-example:owner/project.git
Если используется несколько закрытых репозиториев, для каждого можно создать отдельный ключ и отдельную запись Host. GitHub также указывает, что один deploy key привязывается к одному репозиторию.
Как это устроено на Siteko. Git доступен в консоли на тарифах для проектов с SSH. Закрытый репозиторий можно подключить по deploy-ключу, а для GitHub в панели предусмотрен модуль импорта. Composer установлен глобально, Node.js подключается через nvm, символические ссылки внутри аккаунта разрешены.
Первый вариант: простой деплой в рабочий каталог
Начнём со схемы, которую можно настроить практически на любом виртуальном хостинге с SSH.
Проект находится в постоянном каталоге:
/home/user/www/example.com/project/
Домен указывает на:
/home/user/www/example.com/project/public/
При обновлении Git заменяет файлы непосредственно в этом каталоге.
Это не zero-downtime deployment: на несколько секунд или минут сайт переводится в режим обслуживания. Зато схема понятна, не требует сложной структуры релизов и подходит большинству небольших проектов.
Первый запуск проекта
Переходим в каталог сайта:
cd ~/www/example.com
Клонируем репозиторий:
git clone \
--branch main \
git@github-example:owner/project.git \
project
Переходим в проект:
cd project
Создаём production-конфигурацию:
cp .env.example .env
nano .env
Устанавливаем PHP-зависимости:
composer install \
--no-dev \
--prefer-dist \
--optimize-autoloader \
--no-interaction
Если frontend собирается на хостинге:
npm ci
npm run build
Для первого запуска Laravel могут понадобиться:
php artisan key:generate
php artisan migrate --force
php artisan storage:link
php artisan optimize
storage:link обычно выполняется один раз. Повторять создание ссылки во время каждого обновления необязательно.
Перед обновлением: обязательная проверка
Переходим в рабочий каталог:
cd ~/www/example.com/project
Проверяем текущий коммит:
git rev-parse --short HEAD
Проверяем состояние файлов:
git status --short
Команда не должна ничего вывести.
Если появились изменённые или неизвестные файлы, деплой лучше остановить:
M app/Services/PaymentService.php
?? public/test.php
Сначала нужно понять:
- кто изменил файл;
- должно ли изменение попасть в репозиторий;
- можно ли удалить неизвестный файл;
- не является ли он результатом взлома;
- не хранит ли приложение изменяемые данные внутри каталога кода.
Автоматически выполнять git reset --hard, не посмотрев на список изменений, опасно: команда удалит локальные правки.
Получаем новую версию, но пока не публикуем её
Загружаем информацию о новых коммитах:
git fetch --prune origin
Смотрим, что изменится:
git log \
--oneline \
--decorate \
HEAD..origin/main
Пример:
91ad733 Fix payment callback validation
e0c45b2 Add order status index
73a80f1 Update frontend dependencies
Можно отдельно посмотреть изменённые файлы:
git diff \
--stat \
HEAD..origin/main
Если среди них есть миграции, composer.lock или package-lock.json, мы заранее понимаем, какие дополнительные шаги понадобятся.
Переводим сайт в режим обслуживания
Для Laravel:
php artisan down --retry=60
Режим обслуживания возвращает посетителям ответ 503, пока выполняется обновление. Laravel также позволяет заранее отрисовать страницу обслуживания, чтобы её показ не зависел от загрузки Composer-пакетов во время обновления. После завершения сайт возвращается командой php artisan up.
Если в проекте есть шаблон errors::503, можно использовать:
php artisan down \
--render="errors::503" \
--retry=60
Для Symfony или другого фреймворка можно:
- использовать собственный maintenance-флаг;
- временно переключить корень сайта на статическую страницу;
- создать поддерживаемый проектом файл блокировки;
- включить режим обслуживания через панель или конфигурацию приложения.
Важно, чтобы страница обслуживания не зависела от файлов, которые сейчас заменяются.
Обновляем код через Git
Когда рабочий каталог не содержит локальных изменений:
git pull --ff-only origin main
git pull сначала получает данные с удалённого репозитория, а затем интегрирует их в текущую ветку. Флаг --ff-only запрещает создавать неожиданный merge-коммит на production: если локальная история разошлась с удалённой, команда остановится.
Другой воспроизводимый вариант:
git fetch origin main
git reset --hard origin/main
Он приводит каталог точно к состоянию удалённой ветки, но безвозвратно удаляет любые локальные изменения. Использовать его следует только тогда, когда production-каталог считается полностью заменяемым и проверка git status уже выполнена.
Устанавливаем PHP-зависимости
После обновления кода:
composer install \
--no-dev \
--prefer-dist \
--optimize-autoloader \
--no-interaction
Даже если composer.lock в этом релизе не изменился, выполнение команды обычно безопасно: Composer проверит установленный набор и внесёт только необходимые изменения.
Не следует добавлять:
--ignore-platform-reqs
Этот флаг заставляет Composer игнорировать несовместимую версию PHP или отсутствующее расширение. Проект может установиться, но затем завершиться ошибкой уже при открытии сайта.
Если Composer сообщает о несовместимости окружения, нужно исправлять окружение или зависимости, а не скрывать проверку.
Собираем frontend
Если production-ресурсы собираются непосредственно на хостинге:
npm ci
npm run build
Обычно результат появляется в каталоге:
public/build/
Возможны и другие стратегии:
- собрать frontend на компьютере разработчика и добавить готовый результат в релиз;
- собрать его в GitHub Actions или GitLab CI;
- передавать на хостинг готовый архив;
- хранить production-сборку в репозитории.
Главное — выбрать один способ. Плохая схема выглядит так:
иногда public/build коммитится,
иногда собирается на сервере,
иногда копируется по FTP
После нескольких обновлений становится непонятно, как была создана текущая версия ресурсов.
Очищаем старые кэши
После замены кода в Laravel:
php artisan optimize:clear
Команда удаляет кэши, созданные командами оптимизации. Затем, когда конфигурация и миграции уже применены, production-кэши можно создать заново:
php artisan optimize
Laravel рекомендует включать optimize в процесс production-деплоя: команда кэширует конфигурацию, маршруты, события и представления.
Для Symfony типовая production-команда выглядит так:
APP_ENV=prod APP_DEBUG=0 \
php bin/console cache:clear
Официальная документация Symfony также относит очистку и прогрев кэша к стандартным шагам деплоя.
Применяем миграции
Для Laravel:
php artisan migrate --force
Для Symfony с Doctrine:
APP_ENV=prod \
php bin/console doctrine:migrations:migrate \
--no-interaction
Флаг --force в Laravel нужен потому, что фреймворк запрашивает подтверждение перед выполнением потенциально опасной команды в production.
Но автоматический запуск миграций требует дисциплины.
Безопасная миграция
Schema::table('orders', function (Blueprint $table) {
$table->string('external_id')->nullable();
});
Она добавляет новый необязательный столбец. Старый код продолжит работать, даже если новая версия будет откачена.
Опасная миграция
Schema::table('orders', function (Blueprint $table) {
$table->dropColumn('status');
});
После её выполнения предыдущая версия приложения может перестать работать.
Изменение типа большого столбца, удаление данных или переименование поля также может:
- надолго заблокировать таблицу;
- завершиться по таймауту;
- сделать откат кода невозможным;
- потребовать отдельного преобразования данных.
Поэтому перед миграцией нужно понимать не только команду запуска, но и последствия конкретного изменения.
Возвращаем сайт и проверяем результат
После успешного выполнения всех шагов:
php artisan up
Затем проверяем не только главную страницу.
Минимальный список:
php artisan about
php artisan migrate:status
Для проектов, где доступен health-маршрут:
curl -fsS https://example.com/up
Laravel может предоставлять маршрут /up, возвращающий успешный ответ, если приложение загрузилось без исключения. При необходимости к health-проверке можно добавить соединение с базой и другие проверки проекта.
После этого следует вручную проверить критический сценарий:
- вход в личный кабинет;
- оформление заказа;
- отправку формы;
- загрузку файла;
- работу административной панели;
- отправку письма;
- создание фонового задания.
И посмотреть журнал:
tail -n 100 storage/logs/laravel.log
Отсутствие ошибки на главной странице ещё не означает, что весь деплой прошёл успешно.
Полная последовательность простого деплоя
Для Laravel она может выглядеть так:
cd ~/www/example.com/project
git status --short
git fetch --prune origin
git log --oneline HEAD..origin/main
php artisan down --retry=60
git pull --ff-only origin main
composer install \
--no-dev \
--prefer-dist \
--optimize-autoloader \
--no-interaction
npm ci
npm run build
php artisan optimize:clear
php artisan migrate --force
php artisan optimize
php artisan up
Эту последовательность не нужно копировать вслепую.
Например:
- если frontend собирается в CI, команды npm не нужны;
- если в проекте нет миграций, соответствующий шаг ничего не изменит;
- если используется Symfony, Artisan-команды заменяются консолью Symfony;
- если проект не поддерживает maintenance mode, нужен другой механизм;
- если работает постоянная очередь, после деплоя её workers нужно перезапустить.
Laravel предупреждает, что долгоживущие queue workers не замечают изменения кода, пока не будут перезапущены. Для таких процессов предусмотрена команда php artisan queue:restart.
На виртуальном хостинге Siteko очередь из предыдущей статьи запускается через cron с --stop-when-empty: процесс обрабатывает задания и завершается. Постоянного worker-процесса там нет, поэтому отдельный перезапуск обычно не требуется.
Автоматизируем последовательность в deploy.sh
Когда ручной процесс несколько раз успешно проверен, его можно перенести в сценарий.
Создадим файл за пределами публичной директории:
nano ~/deploy-example.sh
Пример:
#!/usr/bin/env bash
set -Eeuo pipefail
APP_DIR="$HOME/www/example.com/project"
BRANCH="main"
STATE_DIR="$HOME/.deploy/example.com"
mkdir -p "$STATE_DIR"
cd "$APP_DIR"
echo "Проверяем рабочий каталог..."
if [ -n "$(git status --porcelain)" ]; then
echo "ОШИБКА: в production-каталоге есть локальные изменения."
git status --short
exit 1
fi
OLD_COMMIT="$(git rev-parse HEAD)"
echo "Текущая версия: $OLD_COMMIT"
echo "$OLD_COMMIT" > "$STATE_DIR/previous-commit"
echo "Получаем новую версию..."
git fetch --prune origin "$BRANCH"
NEW_COMMIT="$(git rev-parse "origin/$BRANCH")"
if [ "$OLD_COMMIT" = "$NEW_COMMIT" ]; then
echo "Новых коммитов нет."
exit 0
fi
echo "Новая версия: $NEW_COMMIT"
echo "Включаем режим обслуживания..."
php artisan down --retry=60
restore_access() {
php artisan up >/dev/null 2>&1 || true
}
trap restore_access EXIT
echo "Обновляем код..."
git reset --hard "$NEW_COMMIT"
echo "Устанавливаем PHP-зависимости..."
composer install \
--no-dev \
--prefer-dist \
--optimize-autoloader \
--no-interaction
if [ -f package-lock.json ]; then
echo "Собираем frontend..."
npm ci
npm run build
fi
echo "Обновляем приложение..."
php artisan optimize:clear
php artisan migrate --force
php artisan optimize
echo "Возвращаем сайт..."
php artisan up
trap - EXIT
echo "$NEW_COMMIT" > "$STATE_DIR/current-commit"
echo "Деплой завершён успешно."
Делаем файл исполняемым:
chmod 700 ~/deploy-example.sh
Запускаем:
~/deploy-example.sh
Сценарий останавливается при первой ошибке благодаря:
set -Eeuo pipefail
Однако автоматизация не превращает рискованную миграцию в безопасную. Она лишь гарантирует одинаковую последовательность команд.
Что ещё следует добавить в реальный сценарий
В зависимости от проекта:
- резервное копирование базы;
- проверку свободного места;
- проверку версии PHP;
composer check-platform-reqs;- запуск автоматических тестов;
- отдельный журнал деплоя;
- уведомление разработчику;
- проверку health-маршрута;
- удаление maintenance mode при ошибке;
- блокировку от одновременного запуска двух деплоев.
Например, защититься от параллельного запуска можно через flock:
flock -n \
"$HOME/.deploy/example.com.lock" \
"$HOME/deploy-example.sh"
Если предыдущий деплой ещё выполняется, второй не начнёт заменять те же файлы.
Как откатить простой деплой
Сценарий сохранил предыдущий коммит:
~/.deploy/example.com/previous-commit
Посмотрим его:
cat ~/.deploy/example.com/previous-commit
Допустим, это:
7fd124a86cf...
Возвращаем код:
cd ~/www/example.com/project
php artisan down --retry=60
git reset --hard 7fd124a86cf
composer install \
--no-dev \
--prefer-dist \
--optimize-autoloader \
--no-interaction
npm ci
npm run build
php artisan optimize:clear
php artisan optimize
php artisan up
Но здесь есть важное ограничение:
Откат Git возвращает код, но не возвращает базу данных.
Автоматически выполнять:
php artisan migrate:rollback
после каждого неудачного деплоя опасно.
Миграция могла:
- удалить столбец с данными;
- объединить несколько полей;
- преобразовать значения;
- создать данные, которые уже используют посетители;
- входить в одну группу с другими миграциями;
- не иметь корректного обратного действия.
Иногда безопаснее оставить новую структуру базы и вернуть только совместимый код. Иногда требуется восстановить резервную копию. Решение зависит от конкретной миграции.
Безопасные миграции для возможности отката
Если проект должен обновляться без длительного простоя, структуру базы меняют в несколько этапов.
Допустим, поле name нужно заменить на display_name.
Релиз 1
Добавляем новое поле, не удаляя старое:
name
display_name nullable
Новый код умеет работать с обоими полями.
Релиз 2
Переносим существующие данные:
display_name = name
Приложение начинает записывать значение в новое поле, но сохраняет совместимость со старым.
Релиз 3
Код перестаёт использовать name.
Релиз 4
Только после проверки удаляется старый столбец.
Такая схема медленнее одной большой миграции, но предыдущая версия приложения может работать с базой во время отката.
Главный принцип:
Сначала расширяем структуру, затем переводим на неё код и только потом удаляем старое.
Почему простой деплой всё равно не полностью безопасен
Даже с maintenance mode остаётся один недостаток: рабочий каталог изменяется на месте.
Последовательность выглядит так:
старый код
↓
частично обновлённый код
↓
новые зависимости
↓
новая сборка
↓
готовый релиз
Посетители видят страницу обслуживания, но сам каталог некоторое время остаётся неполным.
Если Composer завершится с ошибкой, для восстановления придётся снова устанавливать старые зависимости. Если frontend-сборка не пройдёт, старый public/build уже может быть удалён командой npm ci или последующим build-процессом.
Для небольшого сайта этот риск приемлем. Для более важного проекта лучше готовить следующую версию в отдельной папке.
Второй вариант: отдельный каталог для каждого релиза
Релизная схема выглядит так:
/home/user/www/example.com/
├── current -> releases/20260721103042
├── releases/
│ ├── 20260715142010/
│ ├── 20260718110533/
│ └── 20260721103042/
├── shared/
│ ├── .env
│ └── storage/
└── deploy.sh
Домен направлен на:
/home/user/www/example.com/current/public/
current — символическая ссылка на активный релиз.
Новая версия полностью собирается в отдельном каталоге:
releases/20260721103042/
Пока установка Composer, сборка frontend и проверки не завершены, посетители продолжают работать с предыдущим релизом.
Только после успешной подготовки ссылка current переключается на новую папку.
Что хранится в shared
Каталог shared содержит данные, которые должны переживать смену релизов:
shared/
├── .env
└── storage/
├── app/
├── framework/
└── logs/
В каждом релизе создаются ссылки:
release/.env -> shared/.env
release/storage -> shared/storage
Поэтому:
- настройки не копируются в каждый релиз;
- пользовательские загрузки не исчезают;
- логи остаются общими;
- откат к предыдущему коду не откатывает файлы пользователей.
Для других фреймворков набор общих каталогов будет отличаться.
Например, не все кэши следует сохранять между релизами. Нужно разделять:
- пользовательские данные;
- логи;
- временный framework-кэш;
- скомпилированные файлы;
- загруженные документы.
Подготовка shared-каталогов
Создаём структуру:
APP_ROOT="$HOME/www/example.com"
mkdir -p "$APP_ROOT/releases"
mkdir -p "$APP_ROOT/shared/storage/app/public"
mkdir -p "$APP_ROOT/shared/storage/framework/cache"
mkdir -p "$APP_ROOT/shared/storage/framework/sessions"
mkdir -p "$APP_ROOT/shared/storage/framework/views"
mkdir -p "$APP_ROOT/shared/storage/logs"
Создаём production-конфигурацию:
nano "$APP_ROOT/shared/.env"
chmod 600 "$APP_ROOT/shared/.env"
Корень домена направляем на:
/home/user/www/example.com/current/public
На Siteko произвольный Document Root и символические ссылки внутри аккаунта поддерживаются, поэтому такая структура может использоваться без переноса index.php и изменения стандартной структуры фреймворка.
Подготавливаем новый релиз
Создаём идентификатор:
RELEASE_ID="$(date +%Y%m%d%H%M%S)"
Определяем путь:
APP_ROOT="$HOME/www/example.com"
RELEASE_DIR="$APP_ROOT/releases/$RELEASE_ID"
Клонируем код:
git clone \
--depth 1 \
--branch main \
git@github-example:owner/project.git \
"$RELEASE_DIR"
Подключаем .env:
ln -s \
"$APP_ROOT/shared/.env" \
"$RELEASE_DIR/.env"
Заменяем каталог storage ссылкой:
rm -rf "$RELEASE_DIR/storage"
ln -s \
"$APP_ROOT/shared/storage" \
"$RELEASE_DIR/storage"
Переходим в релиз:
cd "$RELEASE_DIR"
Устанавливаем зависимости:
composer install \
--no-dev \
--prefer-dist \
--optimize-autoloader \
--no-interaction
Собираем frontend:
npm ci
npm run build
Проверяем окружение:
composer check-platform-reqs
php artisan about
Готовим кэши:
php artisan optimize:clear
php artisan optimize
В этот момент сайт всё ещё обслуживается из предыдущего релиза.
Миграции в релизной схеме
Здесь возникает тонкий момент.
Если выполнить миграцию до переключения ссылки:
php artisan migrate --force
новая структура базы начнёт работать вместе со старым кодом.
Если выполнить её после переключения, новый код на несколько секунд будет работать со старой структурой базы.
Zero-downtime deployment возможен только тогда, когда миграции совместимы с обеими версиями приложения.
Если совместимость не гарантирована, лучше честно включить короткий maintenance mode:
php artisan down --retry=60
php artisan migrate --force
А затем переключить релиз и вернуть сайт.
Отдельные каталоги уменьшают время недоступности, потому что Composer и npm уже завершили работу. В режиме обслуживания остаются только миграция и смена ссылки.
Атомарно переключаем current
Запоминаем текущий релиз:
readlink -f "$APP_ROOT/current"
Создаём временную ссылку:
ln -s \
"$RELEASE_DIR" \
"$APP_ROOT/current.next"
Перемещаем её на место current:
mv -Tf \
"$APP_ROOT/current.next" \
"$APP_ROOT/current"
На Linux операция переименования внутри одной файловой системы выполняется как единое переключение: запрос увидит либо старую, либо новую ссылку, а не наполовину скопированный каталог.
Если в доступном окружении mv не поддерживает -T, используется более простой вариант:
ln -sfn \
"$RELEASE_DIR" \
"$APP_ROOT/current"
После переключения:
cd "$APP_ROOT/current"
php artisan up
Затем выполняются health-проверки.
Откат релизной схемы
Предположим, предыдущая версия находится здесь:
/home/user/www/example.com/releases/20260718110533
Откат кода:
APP_ROOT="$HOME/www/example.com"
PREVIOUS_RELEASE="$APP_ROOT/releases/20260718110533"
ln -s \
"$PREVIOUS_RELEASE" \
"$APP_ROOT/current.next"
mv -Tf \
"$APP_ROOT/current.next" \
"$APP_ROOT/current"
Сайт снова использует предыдущий каталог.
Повторно запускать Composer и npm не нужно: старый релиз уже содержит свои зависимости и frontend-сборку.
В этом главное преимущество релизной схемы:
Откат кода — это переключение одной ссылки, а не повторная сборка повреждённого рабочего каталога.
Ограничение базы данных остаётся прежним. Если новый релиз выполнил несовместимую миграцию, одна смена ссылки проблему не решит.
Удаляем старые релизы
Хранить все версии бесконечно не нужно.
Например, оставим пять последних:
ls -1dt \
"$APP_ROOT"/releases/* |
tail -n +6 |
xargs -r rm -rf
Перед удалением следует убедиться, что:
- каталог
currentне указывает на удаляемый релиз; - предыдущая стабильная версия сохранена;
- пользовательские данные действительно находятся в
shared; - в старых релизах нет единственной копии загруженных файлов;
- резервная копия базы существует отдельно.
Обычно достаточно хранить несколько последних релизов и более долгосрочные резервные копии.
Git-деплой не заменяет резервное копирование
Git хранит код, но не содержит:
- production-базу;
- пользовательские загрузки;
.env;- почтовые ящики;
- DNS-записи;
- закрытые ключи;
- данные сторонних сервисов.
Поэтому команда:
git reset --hard HEAD~1
не является полноценным восстановлением сайта.
Перед изменением структуры базы нужна резервная копия. Перед крупным обновлением полезно сохранить:
базу данных
shared/storage
production-конфигурацию
текущий commit или release ID
Причём резервная копия должна находиться не только внутри того же аккаунта, который обновляется.
Этой теме будет посвящена двенадцатая часть серии.
Нужен ли автоматический деплой после каждого push
Технически репозиторий может отправить webhook, после которого сервер автоматически запустит deploy.sh.
Но автоматическая публикация каждого коммита из main подходит не всем проектам.
До production должны быть проверены:
- тесты;
- Composer-зависимости;
- frontend-сборка;
- синтаксис;
- миграции;
- статический анализ;
- критические пользовательские сценарии.
Более безопасные варианты:
- запускать deploy вручную после проверки;
- публиковать только Git-теги;
- использовать отдельную production-ветку;
- требовать подтверждение перед production;
- сначала разворачивать версию на тестовом поддомене.
Например:
feature branch
↓
pull request
↓
tests
↓
main
↓
staging
↓
production tag
↓
deploy
Чем важнее сайт, тем меньше production должен зависеть от одного случайного git push.
Деплой по ветке или по тегу
Обновление из ветки:
git fetch origin main
git reset --hard origin/main
подходит для небольшого проекта с простой моделью публикации.
Но ветка — движущаяся цель. Сегодня main указывает на один коммит, завтра — на другой.
Git-тег фиксирует конкретный релиз:
git tag -a v1.4.0 -m "Release 1.4.0"
git push origin v1.4.0
На сервере:
git fetch --tags
git checkout v1.4.0
Преимущества тегов:
- версия релиза имеет понятное имя;
- случайный новый commit не публикуется автоматически;
- проще вести журнал изменений;
- проще откатиться на
v1.3.2; - production соответствует конкретному неизменяемому состоянию.
Для релизной схемы каталог можно назвать по версии:
releases/v1.4.0/
или сочетать дату и commit:
releases/20260721-91ad733/
Какие проверки выполнять перед переключением
Минимальный smoke test можно выполнить ещё в каталоге нового релиза:
php -l public/index.php
composer check-platform-reqs
php artisan about
php artisan migrate:status
Дополнительно:
php artisan route:list >/dev/null
php artisan config:show app >/dev/null
Для Symfony:
APP_ENV=prod APP_DEBUG=0 \
php bin/console about
APP_ENV=prod APP_DEBUG=0 \
php bin/console lint:container
После переключения проверяется HTTP:
curl \
--fail \
--silent \
--show-error \
https://example.com/up
Если команда завершилась с ошибкой, сценарий может автоматически вернуть предыдущую ссылку.
Но health-маршрут должен проверять не только то, что index.php способен вывести ответ. Для критического проекта полезно проверить:
- соединение с основной базой;
- доступность файлового хранилища;
- правильность production-конфигурации;
- обязательный внешний сервис;
- возможность чтения кэша.
Не следует при этом превращать health-check в тяжёлый тест, который сам создаёт нагрузку.
Что делать с очередями во время деплоя
Постоянный worker загружает код в память при запуске. Если файлы на диске обновились, работающий процесс может продолжать выполнять старую версию классов.
Для Laravel после публикации новой версии используется:
php artisan queue:restart
Команда просит workers завершить текущее задание и корректно перезапуститься. Для автоматического запуска нового процесса обычно нужен Supervisor или другой менеджер процессов.
Для Symfony Messenger:
php bin/console messenger:stop-workers
Документация Symfony также рекомендует останавливать workers при каждом деплое, чтобы они загрузили новый код.
Если очередь запускается через cron:
php artisan queue:work \
--stop-when-empty \
--max-time=50
процесс сам завершается. Следующий cron-запуск уже загрузит новую версию приложения.
Что нельзя хранить внутри release-каталога
Следует особенно внимательно проверить:
public/uploads/
public/images/users/
storage/app/public/
var/uploads/
runtime/files/
Если пользовательские файлы находятся внутри каталога релиза, после смены current они внезапно «исчезнут»: новая версия просто не содержит старых загрузок.
Такие каталоги переносятся в shared и подключаются символической ссылкой.
Например:
ln -s \
"$APP_ROOT/shared/uploads" \
"$RELEASE_DIR/public/uploads"
Перед внедрением схемы нужно составить полный список данных, которые приложение создаёт само.
Грабли деплоя без FTP
| Грабля | Симптом | Решение |
|---|---|---|
| Код исправляют прямо на сервере | git pull останавливается из-за локальных изменений |
Все изменения проводить через commit и push |
.env добавлен в Git |
Production-пароли попадают в репозиторий | Исключить секреты и хранить их отдельно |
composer.lock не закоммичен |
На каждом сервере устанавливаются разные пакеты | Зафиксировать и публиковать lock-файл |
На production выполняется composer update |
Во время деплоя неожиданно обновляются зависимости | Использовать composer install |
Выполняется npm install без единой стратегии |
Меняется lock-файл или дерево зависимостей | Использовать зафиксированный lock и npm ci |
git pull создаёт merge на сервере |
Production получает уникальную историю | Использовать --ff-only или точный commit |
| Миграция удаляет поле сразу | После отката старый код падает | Делать изменения базы в совместимых этапах |
| Откат возвращает только Git | Код старый, база и загрузки остались новыми | Планировать откат данных отдельно |
| Пользовательские файлы лежат в release | После переключения исчезают изображения | Вынести изменяемые данные в shared |
| Composer запускается после переключения current | Посетители видят релиз без vendor |
Полностью собирать релиз до переключения |
| Frontend не собран | Новый PHP-код работает со старыми JS и CSS | Включить build в обязательные шаги |
| Кэши не очищены | Приложение использует старые маршруты или настройки | Выполнять команды очистки и оптимизации |
| Два деплоя запущены одновременно | Команды заменяют файлы друг у друга | Добавить блокировку через flock |
| Deploy key имеет доступ на запись | Компрометация сервера позволяет изменить репозиторий | Использовать ключ только для чтения |
| Хранятся все релизы | Заканчиваются диск и inode | Оставлять несколько последних версий |
| Проверяется только главная страница | Ошибка обнаруживается при первом заказе | Выполнять health-check и критический smoke test |
Какой вариант выбрать
Простой деплой в одном каталоге
Подходит, если:
- сайт может быть недоступен несколько минут;
- обновления выполняются нечасто;
- проект небольшой;
- миграции простые;
- восстановление Composer занимает немного времени;
- процесс только начинает автоматизироваться.
Преимущества:
- легко понять;
- легко настроить;
- мало каталогов и ссылок;
- подходит почти любому SSH-хостингу.
Недостатки:
- рабочий каталог временно находится в промежуточном состоянии;
- откат требует повторной установки зависимостей;
- ошибка Composer может увеличить простой;
- frontend собирается внутри активного проекта.
Отдельный каталог для каждого релиза
Подходит, если:
- сайт важен для бизнеса;
- обновления выполняются регулярно;
- нужен быстрый откат;
- Composer или npm работают долго;
- проект имеет понятное разделение кода и данных;
- хостинг поддерживает symlink и произвольный Document Root.
Преимущества:
- релиз собирается отдельно от работающего сайта;
- переключение занимает доли секунды;
- предыдущая версия уже готова к возврату;
- легко определить активный релиз;
- не смешиваются зависимости разных версий.
Недостатки:
- сложнее первоначальная настройка;
- нужно управлять
shared; - старые релизы расходуют inode и диск;
- миграции всё равно требуют отдельной стратегии;
- не каждый проект изначально готов к неизменяемым release-каталогам.
Как это применить на Siteko
Для простой схемы достаточно:
- разместить проект через Git;
- направить домен на
project/public; - создать
.env; - выполнить Composer;
- подключить Node.js через nvm при необходимости;
- добавить cron;
- запускать проверенный deploy-сценарий по SSH.
Для релизной схемы:
- корень домена направляется на
current/public; - код хранится в
releases; .envи изменяемые данные выносятся вshared;currentпереключается символической ссылкой;- несколько предыдущих релизов сохраняются для отката.
Необходимые для обеих схем инструменты — SSH, Git, глобальный Composer, Node.js через nvm, произвольный Document Root и symlink внутри аккаунта — подробно разобраны в восьмой статье серии.
Постоянные Supervisor-workers на виртуальном хостинге не предполагаются. Если проект требует Horizon, нескольких постоянно работающих очередей, WebSocket-сервера или собственных демонов, процесс деплоя уже следует строить на VDS.
Что в итоге
Переход с FTP на Git решает не только проблему медленной загрузки файлов.
Он создаёт точку отсчёта:
этот сайт работает на commit 91ad733
Composer и lock-файл воспроизводят PHP-зависимости. npm ci и frontend-сценарий воспроизводят клиентскую сборку. Миграции описывают изменения базы. Maintenance mode защищает посетителей от промежуточного состояния. Health-check показывает, что приложение действительно запустилось.
Но сам по себе Git ещё не делает деплой безопасным.
Для этого нужны:
- чистый production-каталог;
- фиксированный порядок команд;
- отделение кода от данных;
- проверка каждого шага;
- совместимые миграции;
- резервная копия;
- заранее проверенный откат.
Для небольшого проекта достаточно обновления в одном каталоге с коротким maintenance mode. Для более важного сайта лучше готовить отдельные релизы и переключать current только после успешной сборки.
Главный принцип можно сформулировать так:
Новая версия должна сначала стать готовым приложением и только потом стать работающим сайтом.
Следующая часть серии будет посвящена ситуации, когда этот процесс всё же завершился ошибкой: сайт отвечает кодом 500, показывает пустую страницу или перестаёт открываться после переноса. Разберём, где искать причину до обращения в поддержку — от логов и версии PHP до .env, прав, Document Root и отсутствующих расширений.
- 1 Как выбрать хостинг для сайта и не купить себе вторую работу
- 2 Виртуальный хостинг, VPS или облако: что действительно нужно вашему сайту
- 3 Гигабайты ничего не решают: как читать характеристики тарифа хостинга
- 4 «Безлимитный» хостинг: что заканчивается раньше дискового пространства
- 5 Домен подключён, а сайт не открывается: DNS без мистики
- 6 HTTPS включён, а браузер всё равно ругается: SSL без паники
- 7 Письма с сайта пропадают: SMTP, SPF, DKIM и DMARC на пальцах
- 8 Современный PHP-хостинг: зачем Laravel и Symfony нужны SSH, Composer, Node.js, cron и отдельная public-директория
- 9 Деплой без FTP на виртуальном хостинге: Git, Composer и безопасный откат вы здесь
- 10 Ошибка 500 после загрузки сайта: где искать причину до обращения в поддержку скоро
- 11 Сайт тормозит на хостинге: виноват тариф, код, база или внешний сервис скоро
- 12 Бэкап есть — восстановить нельзя: проверяем резервные копии до аварии скоро
- 13 Переезд на другой хостинг без потери сайта, писем и заказов скоро
- 14 Когда виртуальный хостинг стал тесен: пора на VPS или ещё можно остаться скоро
Была статья полезной: