Исходные данные:
VPS Ubuntu 24/Nginx/PHP8.5/MySQL/Laravel/Supervisor, 2 сервера: dev и production
Шаг 1. Начальная настройка проекта
Разворачиваем проекты на серверах из репозитория Gitlab вручную, без CICD, настраиваем Nginx, подключаем СУБД и т.д. Все работает.
Предположим, проект развернут в папке
/var/www/my-project.com/classic-deploy
Сразу решаем, в какой папке будет расположен проект, разворачиваемый через CICD.
Допустим, он будет в папке /var/www/my-project.com/cicd-deploy
Шаг 2. Пользователь для деплоя
2.1 На обеих серверах добавляем пользователя deployer. Через него Gitlab будет делать деплой.
sudo adduser deployer
2.2 Добавляем пользователя deployer в группу www-data
sudo usermod -aG www-data deployerПроверяем, что добавлен в группу
groups deployerв списке должна быть www-data
2.3 Добавляем возможность запуска от имени deployer определенных команд без пароля.
Для этого создаем файл конфига:
sudo visudo -f /etc/sudoers.d/deployДобавляем в него строку:
deployer ALL=(ALL) NOPASSWD: /usr/bin/supervisorctl, /usr/bin/systemctl reload php8.5-fpmПеред добавлвением можно проверить, где находятся файлы
which supervisorctl
which systemctlШаг 3. Настройка SSH-ключей
Логинимся на сервер как пользователь deployer
ssh deployer@ip-address-hereСоздаем новый ключ, без ключевой фразы, для дев-сервера используем префикс dev
ssh-keygen -t ed25519 -C "gitlab-deploy-prod"Появляется папка /home/deployer/.ssh, в ней файлы ключа id_ed25519.pub и id_ed25519.
Имена файлов ключей лучше использовать дефолтные, иначе може возникнуть ошибка при подключении к Gitlab.
Добавляем публичный ключ в authorized_keys
cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keysСодержимое файла публичного ключа добавляем в ключи в репозитории проекта на Gitlab
Settings->Reposirory->Deploy keys->Add new key
В названии ключа указываем сервер и пользователя, что бы не путаться, например, «Production deployer»
Секретный ключ кодируем в base64
base64 -w 0 ~/.ssh/id_ed25519Полученный вывод пока копируем в текстовый файл, он понядобится в следующем шаге.
Проверяем, что ключ работает. Для этого в произвольной папке клонируем репозиторий
mkdir ~/test
cd ~/test
git clone git@gitlab.com:your-project.gitЕсли подключение и клонирование прошло успешно, тестовую папку можно удалить.
Шаг 4. Переменные Gitlab
В Gitlab, в папке проекта открываем Settings->CICD->Variables
Создаем переменные:
PROD_HOST — IP адрес, например, 123.1.123.12
PROD_BASE — путь к папке cicd-проекта, /var/www/my-project.com/cicd-deploy
PROD_APP_URL — публичный URL проекта, для запуска health check, например, https://my-project.com.
PROD_USER — deployer
PROD_SSH_PRIVATE_KEY_B64 — приватный ключ в формате base64 из предыдущего шага
Тип переменной — variable (default), visibility: visible, protect variable — выключено
Для дев-севера аналогично, но с префиксом DEV_
В результате в репозитории есть набор переменных

Результат на данном этапе:
Проект работает по прежнему из папки classic-deploy, на сервере добавлен пользователь deployer.
Шаг 5. Создание структуры проекта для CICD
Допустим, проект, развернутый через CICD будет расположен в папке /var/www/my-project.com/cicd-deploy
В этом случае структура downtime-zero проекта будет следующей
/var/www/my-porject.com/cicd-deploy/
├── current -> releases/20260501-1430-abc1234 ← симлинк на активный релиз
├── releases/
│ ├── 20260430-1731-2b5cc17/
│ ├── 20260501-0900-69025b4/
│ └── 20260501-1430-abc1234/ ← текущий
└── shared/
├── .env
└── storage/5.1 Делаем первый деплой вручную.
Логинимся под root, создаем папку ~/deploy, в нее кладем файл first_deploy.sh
Заменить: BASE — путь к новой папке проекта, OLD_PATH — текущий проект
#!/usr/bin/env bash
umask 0002
set -euo pipefail
BASE=/var/www/my-project.com/cicd-deploy
OLD_PATH=/var/www/my-porject.com/classic-deploy
SHARED=$BASE/shared
TS=$(date +%Y%m%d-%H%M)
REL=$BASE/releases/${TS}-manual
REPO=git@gitlab.com:your-project-repo.git
mkdir -p $BASE/releases
mkdir -p $BASE/shared/storage/{app/public,framework/cache/data,framework/sessions,framework/testing,framework/views,logs}
sudo chown -R deployer:www-data $BASE
sudo find $BASE -type d -exec chmod 2775 {} \;
sudo find $BASE -type f -exec chmod 0664 {} \;
cp -a $OLD_PATH/.env $BASE/shared/.env
cp -a $OLD_PATH/storage/. $BASE/shared/storage/
sudo chown -R deployer:www-data $BASE/shared
sudo find $BASE/shared -type d -exec chmod 2775 {} \;
sudo find $BASE/shared -type f -exec chmod 0664 {} \;
sudo chmod 0640 $BASE/shared/.env
sudo -u deployer git clone --depth 1 --branch develop "$REPO" "$REL"
sudo -u deployer ln -sfn "$SHARED/.env" "$REL/.env"
sudo -u deployer rm -rf "$REL/storage"
sudo -u deployer ln -sfn "$SHARED/storage" "$REL/storage"
# на production-сервере добавить опцию --no-dev
sudo -u deployer bash -c "cd '$REL' && composer install --optimize-autoloader --no-interaction"
sudo -u deployer php "$REL/artisan" storage:link
sudo -u deployer php "$REL/artisan" config:cache
sudo -u deployer php "$REL/artisan" route:cache
sudo -u deployer php "$REL/artisan" view:cache
sudo -u deployer php "$REL/artisan" event:cache
sudo -u deployer php "$REL/artisan" migrate --force
sudo rm -rf "$BASE/current.new"
sudo -u deployer ln -sfn "$REL" "$BASE/current.new"
sudo -u deployer mv -Tf "$BASE/current.new" "$BASE/current"
Что делает скрипт:
- Создает структуру проекта
- Копирует shared-файлы из текущего проекта
- Создает первый релиз с префиксом manual
- Устанавливает права
Шаг 6. Переключение проекта на новую папку
6.1 В файле конфигурации Nginx меняем путь для root на
/var/www/my-project.com/cicd-deploy/current/public
Перезагружаем Nginx
nginx -t
systemctl reload nginx 6.2 Меняем конфигурацию supervisor
nano /etc/supervisor/conf.d/horizon.conf
## меняем пути
command=php /var/www/my-project.com/cicd-deploy/current/artisan horizon
directory=/var/www/my-project.com/cicd-deploy/current
stdout_logfile=/var/www/my-project.com/cicd-deploy/shared/storage/logs/horizon.log
supervisorctl reread && supervisorctl update
6.3 Важно: не забыть проверить и заменить пути в заданиях cron, пути (если есть) в файле .env, в файле конфигураций.
Теперь проект работает из новой папки, релиз -manual.
Проверяем, что все работает, адрес /health/ready возвращает JSON с 200
Шаг 7. Первый деплой через Gitlab CICD Pipeline.
7.1 На локальном сервере добавляем в корневую проекта файл пайплайна .gitlab-ci.yaml
stages:
- test
- deploy
# ──────────────── ТЕСТЫ ────────────────
test:
stage: test
image: php:8.5-cli
cache:
key:
files: [composer.lock]
paths: [vendor/]
variables:
DB_CONNECTION: sqlite
DB_DATABASE: ":memory:"
before_script:
- apt-get update -qq && apt-get install -y -qq git unzip libicu-dev libzip-dev libsqlite3-dev
- docker-php-ext-install intl pcntl zip pdo_sqlite
- curl -sS https://getcomposer.org/installer | php
- php composer.phar install --prefer-dist --no-ansi --no-progress
- cp .env.example .env
- php artisan key:generate
script:
- php artisan test --compact
rules:
- if: $CI_COMMIT_BRANCH == "main" # запускаем тест при пуше в main
- if: $CI_COMMIT_BRANCH == "develop" # запускаем тест при пуше в develop
- if: $CI_PIPELINE_SOURCE == "merge_request_event" # запускаем тест при PR
# тесты не запускаются при пуше в feature-ветки
# ──────────────── ДЕПЛОЙ НА DEV ────────────────
deploy:dev:
stage: deploy
needs: [test]
image: alpine:latest
variables:
KEEP_RELEASES: "5"
HEALTH_URL: "${DEV_APP_URL}/health/ready"
script:
- apk add --no-cache openssh-client curl
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- echo "$DEV_SSH_PRIVATE_KEY_B64" | base64 -d > ~/.ssh/id_deploy
- chmod 600 ~/.ssh/id_deploy
- ssh-keyscan -H "$DEV_HOST" >> ~/.ssh/known_hosts
- |
ssh -i ~/.ssh/id_deploy "$DEV_USER@$DEV_HOST" \
"BASE='$DEV_BASE' SHA='$CI_COMMIT_SHORT_SHA' FULL_SHA='$CI_COMMIT_SHA' REPO='$CI_REPOSITORY_URL' KEEP_RELEASES='$KEEP_RELEASES' bash -se" <<'EOSSH'
set -euo pipefail
umask 0002
TS=$(date +%Y%m%d-%H%M)
REL="$BASE/releases/${TS}-${SHA}"
SHARED="$BASE/shared"
git config --global --add safe.directory "$REL"
# 1. Клонируем релиз (shallow) от develop
git clone --depth 1 --branch develop "$REPO" "$REL"
cd "$REL"
echo "$FULL_SHA" > RELEASE
# 2. Гарантируем структуру shared (на случай первого деплоя/чистого shared)
mkdir -p "$SHARED/storage/app/public" \
"$SHARED/storage/framework/cache/data" \
"$SHARED/storage/framework/sessions" \
"$SHARED/storage/framework/testing" \
"$SHARED/storage/framework/views" \
"$SHARED/storage/logs"
# 3. Линкуем shared
ln -sfn "$SHARED/.env" .env
rm -rf storage && ln -sfn "$SHARED/storage" storage
# 3. Установка зависимостей (на новом релизе, не трогая current)
composer install --optimize-autoloader --no-interaction --no-progress
# 4. Symlink public/storage -> storage/app/public
php artisan storage:link
# 5. Кеши конфигов/роутов/вью — в новом релизе
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
# 5. Миграции (один раз на релиз; делаем ДО переключения)
php artisan migrate --force
# 6. Атомарное переключение symlink current
ln -sfn "$REL" "$BASE/current.new"
mv -Tf "$BASE/current.new" "$BASE/current"
# 7. Перезапуск Horizon (graceful)
sudo supervisorctl signal SIGTERM horizon:* || php artisan horizon:terminate
# 8. Reload php-fpm для сброса opcache (graceful, без downtime)
sudo systemctl reload php8.5-fpm
# 9. Чистим старые релизы
cd "$BASE/releases" && ls -1t | tail -n +$((KEEP_RELEASES+1)) | xargs -r rm -rf
EOSSH
# 10. Health check после деплоя
- |
echo "Health check: $HEALTH_URL"
for i in 1 2 3 4 5; do
code=$(curl -s -o /tmp/health.out -w "%{http_code}" "$HEALTH_URL" || echo "000")
echo "attempt $i: HTTP $code"
if [ "$code" = "200" ]; then
cat /tmp/health.out; echo
exit 0
fi
sleep 3
done
echo "Health check failed"; cat /tmp/health.out 2>/dev/null || true
exit 1
rules:
- if: $CI_COMMIT_BRANCH == "develop"
# ──────────────── ДЕПЛОЙ НА PROD ────────────────
deploy:prod:
stage: deploy
when: manual
needs: [test]
image: alpine:latest
variables:
KEEP_RELEASES: "5"
HEALTH_URL: "${PROD_APP_URL}/health/ready"
script:
- apk add --no-cache openssh-client curl
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- echo "$PROD_SSH_PRIVATE_KEY_B64" | base64 -d > ~/.ssh/id_deploy
- chmod 600 ~/.ssh/id_deploy
- ssh-keyscan -H "$PROD_HOST" >> ~/.ssh/known_hosts
- |
ssh -i ~/.ssh/id_deploy "$PROD_USER@$PROD_HOST" \
"BASE='$PROD_BASE' SHA='$CI_COMMIT_SHORT_SHA' FULL_SHA='$CI_COMMIT_SHA' REPO='$CI_REPOSITORY_URL' KEEP_RELEASES='$KEEP_RELEASES' bash -se" <<'EOSSH'
set -euo pipefail
umask 0002
TS=$(date +%Y%m%d-%H%M)
REL="$BASE/releases/${TS}-${SHA}"
SHARED="$BASE/shared"
git config --global --add safe.directory "$REL"
# 1. Клонируем релиз (shallow) от main
git clone --depth 1 --branch main "$REPO" "$REL"
cd "$REL"
echo "$FULL_SHA" > RELEASE
# 2. Гарантируем структуру shared (на случай первого деплоя/чистого shared)
mkdir -p "$SHARED/storage/app/public" \
"$SHARED/storage/framework/cache/data" \
"$SHARED/storage/framework/sessions" \
"$SHARED/storage/framework/testing" \
"$SHARED/storage/framework/views" \
"$SHARED/storage/logs"
# 3. Линкуем shared
ln -sfn "$SHARED/.env" .env
rm -rf storage && ln -sfn "$SHARED/storage" storage
# 3. Установка зависимостей (на новом релизе, не трогая current)
composer install --no-dev --optimize-autoloader --no-interaction --no-progress
# 4. Symlink public/storage -> storage/app/public
php artisan storage:link
# 5. Кеши конфигов/роутов/вью — в новом релизе
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
# 5. Миграции (один раз на релиз; делаем ДО переключения)
php artisan migrate --force
# 6. Атомарное переключение symlink current
ln -sfn "$REL" "$BASE/current.new"
mv -Tf "$BASE/current.new" "$BASE/current"
# 7. Перезапуск Horizon (graceful)
sudo supervisorctl signal SIGTERM horizon:* || php artisan horizon:terminate
# 8. Reload php-fpm для сброса opcache (graceful, без downtime)
sudo systemctl reload php8.5-fpm
# 9. Чистим старые релизы
cd "$BASE/releases" && ls -1t | tail -n +$((KEEP_RELEASES+1)) | xargs -r rm -rf
EOSSH
# 10. Health check после деплоя
- |
echo "Health check: $HEALTH_URL"
for i in 1 2 3 4 5; do
code=$(curl -s -o /tmp/health.out -w "%{http_code}" "$HEALTH_URL" || echo "000")
echo "attempt $i: HTTP $code"
if [ "$code" = "200" ]; then
cat /tmp/health.out; echo
exit 0
fi
sleep 3
done
echo "Health check failed"; cat /tmp/health.out 2>/dev/null || true
exit 1
rules:
- if: $CI_COMMIT_BRANCH == "main"
Необходимо убедится, что адрес /health/ready доступен, возвращает JSON с 200.
7.2 Тестируем пайплайн
Сначала проверяем дев-сервер.
На локальной машине в ветке develop делаем правки, коммитим и пушим в репозиторий Gitlab, в удаленную ветку develop.
Gitlab запускает пайплайн для develop, посмотреть можно в резделе Project->Build->Pipelines.
Сначала выполняется задача с тестами, затем задача с деплоем на дев-сервер. Если все успешно — появляется зеленая метка Passed. Проверяем на дев-сервере, что обновление загрузилось.
Если какая-либо задача в пайплайне завершилась с ошибкой — появится метка Failed, нажав на нее можно увидеть какая именно задача зафейлилась, нажав на задачу можно посмотреть лог, в чем именно ошибка. Там же можно перезапустить задачу, если исправление ошибки не требует нового коммита.
Теперь проверяем деплой на production.
Текущее состояние production перед тестом:
- две папки проектов: /classic-deploy и /cicd-deploy
- проект работает из папки /cicd-deploy, current указывает на релиз ‘-manual’
Для запуска пайплайна в удаленную ветку main необходимо загрузить коммит слияния с веткой develop.
Есть 2 варианта, можно выбрать любой:
a. Смерджить в локальном репозитории develop в main и затем запушить main в удаленный.
b. Смерджить ветку develop в main в самом Gitlab.
Выбираем вариант b.
Переходим Project->Code->Merge request, нажимаем Create merge request
Появляется надпись Ready to merge, перед слиянием запускается задача пайплайна с тестом.

Если тесты проходят, ветки сливаются, ‘Ready to merge’ меняется на ‘mered by … ‘ и запускается заново пайплайн.
Снова проходит задача с тестом, а задача деплоя на прод, в отличие от задачи деплоя на dev-сервер, не запускается, а пропускается, становится статус ‘passed’. Это защита от случайного пуша в main, в пайплайне в задаче деплоя на прод стоит опция when:manual, то есть нужен ручной запуск. Заходим в задачу и нажимаем кнопку Run.
После завершения задач на проде появляется новый релиз, current переключен на него.
Проверяем, что проект работает, после этого папку со старым вариантом деплоя (classic-deploy) можно удалить.
Шаг 8. Откат
Что бы вернутся на предыдущий релиз, необходимо переключить ссылку current на папку предыдущего релиза.
Для этого можно использовать следующий скрипт,
#!/bin/bash
set -euo pipefail
BASE=//var/www/my-project.com/cicd-deploy
cd "$BASE/releases"
PREV=$(ls -1t | sed -n '2p') # второй сверху = предыдущий
[ -z "$PREV" ] && { echo "No previous release"; exit 1; }
echo "Rolling back to: $PREV"
ln -sfn "$BASE/releases/$PREV" "$BASE/current.new"
mv -Tf "$BASE/current.new" "$BASE/current"
sudo supervisorctl signal SIGTERM horizon:* || true
sudo systemctl reload php8.5-fpm
echo "Done."
Файл скрипта rollback.sh можно положить в папку ~/home/deployer/deploy_scripts/my-project/ и запускать командой
bash rollback.shДанный скрипт не выполняет откаты миграции, поэтому, при необходимости их нужно выполнить вручную, в папке последнего релиза.