Зачем PHP-проекту CI/CD на VDS
Когда проект живет на VDS, главная боль — стабильный и повторяемый деплой без правок «на проде». CI/CD снимает риски: сборка и проверки идут в чистой среде, на сервер попадает только то, что прошло пайплайн, а переключение версий делается атомарно. Для PHP это критично: кэш опкода, миграции БД, права на файлы, секреты в .env
— всё должно быть под контролем и воспроизводимо.
В этой статье показываю рабочую схему: GitHub Actions собирает приложение, деплоит на VDS через rsync
/SSH
, на сервере формируются релизы и символическая ссылка current
, секреты не утекут, а откат возможен одной командой.
Архитектура пайплайна
Базовая схема простая:
- GitHub Actions — сборка, тесты, подготовка артефактов, установка SSH-ключа и деплой.
rsync
поSSH
— быстрая передача только изменившихся файлов на VDS.- Каталоги релизов на сервере:
/var/www/app/releases/<timestamp>
и симлинк/var/www/app/current
, который указывает на активную версию. - Секреты в
.env
живут вне релизов (вshared
) и подлинковываются в каждый релиз.
Смысл релизов в том, чтобы переключение на новую версию происходило мгновенно, без состояния «полусобранного» деплоя. А откат — это просто смена симлинка на предыдущую папку.
Подготовка VDS
Системный пользователь и директории
Создайте отдельного пользователя для деплоя и структуру директорий (пример: пользователь deploy
, корень приложения /var/www/app
):
adduser --disabled-password --gecos "" deploy
mkdir -p /var/www/app/releases
mkdir -p /var/www/app/shared
chown -R deploy:www-data /var/www/app
chmod 2775 /var/www/app /var/www/app/releases /var/www/app/shared
Бит 2
(setgid) на директориях гарантирует, что новые файлы получают группу www-data
. Это снижает проблемы прав при работе PHP-FPM.
Где хранить .env
Файл .env
храните в /var/www/app/shared/.env
с ограниченными правами:
touch /var/www/app/shared/.env
chown deploy:www-data /var/www/app/shared/.env
chmod 640 /var/www/app/shared/.env
В каждом релизе он должен быть подлинкован на этот файл: releases/<timestamp>/.env -> ../shared/.env
. Так секреты не копируются по каждому релизу и остаются в одном месте.
Веб-сервер
Корень сайта указывайте на /var/www/app/current/public
(если это Laravel/Symfony) или просто /var/www/app/current
для классических PHP-проектов. После переключения симлинка перезапуск веб-сервера не требуется, но полезно перезагрузить PHP-FPM для сброса опкода.
SSH-ключи и безопасность
Сгенерируйте отдельную пару ключей под деплой, публичный ключ положите в ~deploy/.ssh/authorized_keys
. В sshd_config
включите вход только по ключам и ограничьте доступ по пользователю:
PermitRootLogin no
PasswordAuthentication no
AllowUsers deploy
Перезапустите SSH и проверьте вход. На стороне GitHub Actions приватный ключ сохраните как секрет репозитория.

Секреты и .env: два практичных паттерна
1) Сервер — «источник истины»
Простой и безопасный подход: .env
создаётся и редактируется только на сервере. Пайплайн никогда не видит содержимое секретов. В момент деплоя мы лишь подлинковываем файл в новый релиз. Этот вариант минимизирует риски утечки через логи CI/CD.
2) .env собирается в пайплайне
Если нужно управлять конфигурацией централизованно, собирайте .env
из секретов GitHub Actions и отправляйте его в shared
. Важно: не печатать содержимое файла в логи и не коммитить его. Запрещаем историю, даём файлу минимальные права и сразу стираем локальную копию после отправки.
Пример workflow GitHub Actions
Сценарий ниже делает следующее: чекаут кода, подготовка PHP, установка зависимостей, формирование номера релиза, загрузка кода на VDS по rsync
, установка зависимостей уже на сервере, прогрев кэшей, атомарное переключение current
, очистка старых релизов.
name: ci-cd-php
on:
push:
branches: [ "main" ]
workflow_dispatch: {}
jobs:
deploy:
runs-on: ubuntu-latest
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_PORT: ${{ secrets.SSH_PORT }}
SSH_USER: ${{ secrets.SSH_USER }}
REMOTE_DIR: /var/www/app
PHP_VERSION: '8.2'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ env.PHP_VERSION }}
extensions: mbstring, intl, zip, bcmath, pdo_mysql
coverage: none
- name: Cache Composer
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
- name: Install dependencies (dev)
run: composer install --no-interaction --prefer-dist
# Если нужно собирать фронтенд, добавьте шаги npm ci / build
- name: Create release id
run: |
REL=$(date +%Y%m%d%H%M%S)
echo "RELEASE=$REL" >> $GITHUB_ENV
- name: Prepare SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
ssh-keyscan -p $SSH_PORT $SSH_HOST >> ~/.ssh/known_hosts
# Если .env собирается в CI, раскомментируйте блок ниже
# - name: Build .env from secrets
# run: |
# cat > .env << 'EOF'
# APP_ENV=production
# APP_DEBUG=false
# APP_KEY=${{ secrets.APP_KEY }}
# DB_HOST=${{ secrets.DB_HOST }}
# DB_DATABASE=${{ secrets.DB_NAME }}
# DB_USERNAME=${{ secrets.DB_USER }}
# DB_PASSWORD=${{ secrets.DB_PASS }}
# EOF
- name: Rsync code to release
run: rsync -az --delete --exclude ".git" --exclude ".github" --exclude "node_modules" --exclude "vendor" -e "ssh -i ~/.ssh/id_deploy -p $SSH_PORT" ./ $SSH_USER@$SSH_HOST:$REMOTE_DIR/releases/$RELEASE/
# Если .env собирается в CI, отправим его на сервер и удалим локально
# - name: Upload .env to shared
# run: |
# rsync -az -e "ssh -i ~/.ssh/id_deploy -p $SSH_PORT" .env $SSH_USER@$SSH_HOST:$REMOTE_DIR/shared/.env
# shred -u .env
- name: Remote install and switch
run: ssh -i ~/.ssh/id_deploy -p $SSH_PORT $SSH_USER@$SSH_HOST "cd $REMOTE_DIR/releases/$RELEASE && ln -sfn $REMOTE_DIR/shared/.env .env && composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader && php artisan config:cache || true && php artisan route:cache || true && php artisan view:cache || true && ln -sfn $REMOTE_DIR/releases/$RELEASE $REMOTE_DIR/current && chown -h deploy:www-data $REMOTE_DIR/current && sudo systemctl reload php8.2-fpm || true"
- name: Cleanup old releases (keep 5)
run: ssh -i ~/.ssh/id_deploy -p $SSH_PORT $SSH_USER@$SSH_HOST "cd $REMOTE_DIR/releases && ls -1t | tail -n +6 | xargs -r rm -rf"
Пояснения:
- Зависимости Composer ставим на сервере внутри релиза, чтобы не тянуть
vendor
по сети. Убедитесь, что Composer установлен на VDS. - Опциональные команды
artisan
помечены|| true
, чтобы не обрушить деплой на проектах без Laravel. Подставьте свои команды сборки/кэшей (Symfony, WordPress MU-plugins и т.п.). - Переключение
current
делается после сборки, что обеспечивает атомарность.
Релизы и откаты
Каждая версия создаётся в отдельной папке, затем обновляется симлинк current
. Если релиз неудачный, откат — это выбор предыдущей папки и смена ссылки:
cd /var/www/app
PREV=$(ls -1t releases | sed -n '2p')
ln -sfn /var/www/app/releases/$PREV /var/www/app/current
sudo systemctl reload php8.2-fpm
Держите 5–10 релизов для отладки. При миграции проекта без простоя может пригодиться чек-лист из статьи про перенос сайтов: переезд без простоя.
Права, группы и владельцы
Типичные ошибки деплоя связаны с правами. Рекомендации:
deploy
должен быть владельцем файлов, а группа —www-data
(или группа PHP-FPM). Директории сsetgid
.- Каталоги для записываемых данных (например,
storage
у Laravel,wp-content/uploads
у WordPress) вынесите вshared
и подлинкуйте в релиз. - Не давайте
777
; достаточно750
/640
при корректных владельцах.
Оптимизация rsync
rsync
хорош тем, что передаёт только изменения и сохраняет права. Полезные флаги:
-a
— архивный режим (права, время, симлинки);-z
— сжатие по сети;--delete
— чистит файлы, удалённые в репозитории (безопаснее применять в каталоге релиза, а не вcurrent
);--info=progress2
— для диагностики при ручных прогонах.
Сброс OPCache и перезагрузка PHP-FPM
После переключения релиза полезно сбросить коды в OPCache. Самый простой способ — мягкая перезагрузка PHP-FPM. Для высокой нагрузки рассмотрите управляемый сброс через административный endpoint приложения (безопасно ограниченный) — но не держите публичных скриптов, сбрасывающих кэш, в веб-корне.
Миграции БД без простоя
Хорошая практика — выполнять миграции до переключения симлинка, если они обратно совместимы. Иначе используйте последовательность: включить режим обслуживания, применить миграции, прогреть кэши, переключить current
, выключить режим обслуживания. Для Laravel это php artisan down
/up
и migrate --force
.
Управление несколькими окружениями
Для staging и production создайте два деплоя: раздельные секреты, разные серверы/порты/пользователи, отдельные .env
. В GitHub Actions удобно назначать environments с обязательным подтверждением релиза на проде. Теги (vX.Y.Z
) можно использовать как триггер production-пайплайна.
Безопасность пайплайна
- Храните приватный ключ деплоя только в секретах CI. Публичный — узкоспециализированный, отдельный от личных ключей.
- Ограничьте пользователя
deploy
минимумом прав. Не выдавайтеsudo
без необходимости. - Проверяйте ключ хоста:
ssh-keyscan
добавляет отпечаток вknown_hosts
; фиксируйте его и не перезаписывайте без проверки. - Фильтруйте логи: не печатайте секреты, не включайте избыточный verbose там, где это может раскрыть конфиденциальные строки.
Диагностика и частые проблемы
- Permission denied при записи — проверьте владельцев и группу, а также
setgid
на директориях. - No such file or directory .env — забудьте создать
shared/.env
или симлинк в релизе. - Class not found после деплоя — выполните
composer dump-autoload -o
внутри релиза и сбросьте OPCache. - 502/504 сразу после релиза — проверьте логи PHP-FPM и веб-сервера; возможно, кэш маршрутов несовместим с кодом или миграции не применены.
rsync: mkstemp failed
— нет прав на каталог назначения или мало места на диске.
Вариант: деплой только артефактов
Если сборка тяжёлая (например, фронтенд), логичнее собирать артефакты в CI (включая vendor
) и выкатывать их единым архивом. Тогда на сервере не нужен Composer/Node.js. Минус — больший трафик. Плюс — детерминированная сборка в чистой среде. Для фоновых очередей и воркеров посмотрите, как поднимать workers через systemd: очереди и Supervisor.
Резюме
CI/CD на VDS для PHP-проектов можно держать максимально прозрачным: GitHub Actions + rsync
/SSH
, релизы в отдельных директориях, симлинк current
, аккуратная работа с .env
и простой откат. Начните с минимальной схемы, добавьте миграции, кэши и проверки, и только потом усложняйте. Такая архитектура масштабируется от небольших сайтов до крупных приложений и не привязана к конкретному фреймворку.