Деплой по rsync через CI до сих пор остаётся одним из самых простых и надёжных способов выката веб‑проектов. Не нужен Docker, нет привязки к конкретному оркестратору, можно работать и с простым виртуальным хостингом, и с собственным VDS. При этом у такого подхода есть два частых «камня преткновения»:
— что именно считать артефактами сборки и как их передавать между шагами в CI;
— как организовать безопасный деплой с возможностью быстрого rollback без ручного копания на сервере.
В этой статье разберём практическую схему CI/CD (на примерах GitHub Actions и GitLab CI), в которой:
- сборка выполняется в CI‑окружении, а на сервер уезжают только артефакты;
- выкат на прод происходит по
rsyncповерх SSH; - на сервере хранится несколько версий релизов и реализован быстрый откат;
- всё это можно адаптировать под PHP/Node.js/статические сайты.
Зачем вообще артефакты в CI/CD и почему rsync
Грубая схема классического деплоя по rsync выглядит так: CI‑runner просто «заливает» на сервер ваш репозиторий, а на сервере уже крутятся composer install, npm install, npm run build и прочее.
Проблема: сервер превращается в помойку зависимостей и билд‑скриптов, билды могут отличаться от окружения к окружению, сильно растёт время выката. К тому же деплой становится «толстым» — можно легко схлопотать простой, если сборка на проде затянется или что‑то пойдёт не так.
Подход с артефактами предлагает другое:
- вся сборка происходит в CI‑окружении (GitHub Actions / GitLab CI / другой runner);
- на выходе мы получаем готовый «пакет релиза» — каталог
release/сbuild/, нужнымиvendor/,node_modules/и конфигами без секретов; - в деплое по
rsyncмы передаём только этот артефакт, а не весь репозиторий; - на сервере мы не билдим ничего, только переключаем симлинк
currentи (опционально) запускаем миграции.
Результат:
- быстрый и предсказуемый деплой;
- чистое прод‑окружение без dev‑зависимостей;
- возможность хранить несколько релизов и откатываться к любому из них за секунды.
Что считать артефактами: типовые наборы файлов
Набор артефактов зависит от стека, но есть типовые паттерны. Главное правило: в деплойный артефакт не попадают секреты и всё, что может или должно быть специфичным для окружения (prod/stage/dev) и сервера (лог‑директории, кеш и т.п.).
PHP‑приложения (Laravel / Symfony / CMS)
Обычно в артефакт идут:
- собранные фронтенд‑ассеты:
public/build/илиpublic/dist/; - директория с продакшен‑зависимостями:
vendor/(послеcomposer install --no-dev); - исходники приложения:
app/,src/, шаблоны, маршруты, переводы; - базовые конфиги без секретов: шаблоны
.env.example, файлыconfig/*.phpс привязкой к переменным окружения; - migrations и seeders (если используются).
Не включаем:
- реальный
.env(секреты должны задаваться переменными окружения или лежать в недоступном из сети файловом хранилище); - папки
storage/logs,storage/framework/cache,storage/sessions— они живут на сервере и не переезжают между релизами; - dev‑зависимости (
require-dev).
Node.js / SPA / статические сайты
Для SPA и статических сайтов часто достаточно одного каталога dist/ либо build/, который создаётся командой npm run build или аналогичной.
Если это Node.js‑SSR или backend‑приложение, обычно требуется:
- собранный JS‑код (если используется TypeScript/Webpack/Vite);
node_modules/c продакшен‑зависимостями (npm ci --omit=devилиpnpm install --prod);- конфиги без секретов, завязанные на
process.env.*; - скрипты миграций БД (если есть).
Как структурировать артефакты для удобного rsync
Удобно, когда в CI вы собираете всё в единый каталог, например release/:
release/
app/
vendor/
public/
config/
database/
Или для фронтенда:
release/
dist/
server.js
node_modules/
Тогда в job деплоя у вас будет один простой rsync из локального release/ в удалённый releases/<hash>/.

Паттерн директорий на сервере: releases + current + shared
Чтобы иметь возможность быстрых откатов и не портить прод‑окружение, удобно использовать классический паттерн директорий:
/var/www/myapp/releases/— каталог с подкаталогами по релизам, например по SHA коммита или дате;/var/www/myapp/shared/— общие файлы и каталоги между релизами: логи, кеш,.env, загруженные пользователями файлы;/var/www/myapp/current— симлинк на текущий активный релиз.
Идея такая: CI и rsync выкладывают новую сборку в releases/<id>, потом деплой‑скрипт на сервере обновляет симлинк current на новый релиз (и при необходимости запускает миграции, рестарт PHP‑FPM, PM2 и т.д.). Откат — это простое переключение симлинка на предыдущий релиз.
Простейший скелет директорий:
/var/www/myapp
releases/
2025-02-10_120501_4bdc1a9/
2025-02-09_231045_c8e2741/
shared/
.env
storage/
logs/
cache/
sessions/
public/
uploads/
current -> /var/www/myapp/releases/2025-02-10_120501_4bdc1a9
При каждом деплое:
- CI собирает
release/и шлёт его поrsyncв новый каталог вreleases/; - серверный скрипт создаёт или обновляет нужные симлинки от релиза на каталоги в
shared/(например,storageиpublic/uploads); - выполняются миграции БД (если нужно);
- симлинк
currentатомарно переключается на новый релиз; - опционально рестартуются сервисы (php‑fpm не обязательно, чаще нужен только reload nginx или рестарт node‑процесса).
Такой шаблон отлично масштабируется от типовых PHP‑сайтов до проектов с несколькими окружениями на отдельных серверах или облачных VDS.
Базовый rsync‑деплой: ключевые параметры
Сильно упрощённый вариант команды деплоя:
rsync -az --delete --exclude ".git" --exclude ".github" release/ user@prod:/var/www/myapp/releases/2025-02-10_120501_4bdc1a9/
Разберём ключи:
-a— архивный режим (права, рекурсия и пр.);-z— сжатие по сети, полезно при медленном канале;--delete— удалять файлы на сервере, которых больше нет в источнике; позволяет поддерживать чистоту каталога релиза;--exclude— исключения, чтобы не протащить лишнее (CI‑конфиги, тесты, git‑метаданные).
Для production‑деплоя стоит добавить:
--partialи--inplaceпо ситуации, если у вас большие файлы и нужно экономить трафик;--chown=www-data:www-dataили--chmod, если нужно задавать владельцев и права прямо при копировании;--rsync-path, еслиrsyncна сервере лежит не по умолчанию или нуженsudo rsync.
Важно: деплой по rsync запускаем против releases/<id>, а не против current. Симлинк меняет уже отдельный скрипт.
Оркестрация деплоя на сервере и схема rollback
Чтобы CI не знал деталей внутренней кухни сервера, удобно иметь один «входной» скрипт, например /usr/local/bin/deploy-myapp.sh, который принимает ID релиза и делает всё остальное.
Скелет такого скрипта:
#!/bin/bash
set -euo pipefail
APP_DIR="/var/www/myapp"
RELEASES_DIR="$APP_DIR/releases"
SHARED_DIR="$APP_DIR/shared"
CURRENT_LINK="$APP_DIR/current"
NEW_RELEASE_ID="$1" # например 2025-02-10_120501_4bdc1a9
NEW_RELEASE_DIR="$RELEASES_DIR/$NEW_RELEASE_ID"
if [ ! -d "$NEW_RELEASE_DIR" ]; then
echo "Release $NEW_RELEASE_DIR does not exist" 1>&2
exit 1
fi
ln -sfn "$SHARED_DIR/.env" "$NEW_RELEASE_DIR/.env"
ln -sfn "$SHARED_DIR/storage" "$NEW_RELEASE_DIR/storage"
ln -sfn "$SHARED_DIR/public/uploads" "$NEW_RELEASE_DIR/public/uploads"
cd "$NEW_RELEASE_DIR"
php artisan migrate --force
ln -sfn "$NEW_RELEASE_DIR" "$CURRENT_LINK"
systemctl reload php8.2-fpm
Rollback в этой схеме — это или отдельный скрипт, который находит предыдущий релиз и переключает current на него, или просто ручная команда:
cd /var/www/myapp
ln -sfn releases/2025-02-09_231045_c8e2741 current
systemctl reload php8.2-fpm
Чтобы автоматизировать откаты, можно вести простой releases.log или хранить список релизов и их статусов в отдельном файле или БД, но для начала часто достаточно «последнего успешного» релиза из каталога.

Связка rsync + GitHub Actions: пример workflow
Теперь соберём всё воедино в контексте GitHub Actions. Сценарий:
- на пуш в
mainзапускается workflow; - собираются артефакты (PHP+JS);
- артефакт пакуется в
release/; - по SSH и
rsyncрелиз отправляется на сервер и вызываетсяdeploy-myapp.sh; - при ошибке миграций можно запустить откат вручную отдельным job или на сервере.
Упрощённый пример .github/workflows/deploy.yml (без учёта секретов и всех оптимизаций):
name: Deploy via rsync
on:
push:
branches: ["main"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.2"
- name: Install composer deps
run: composer install --no-dev --prefer-dist --no-interaction
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install npm deps and build
run: npm ci && npm run build
- name: Prepare release directory
run: mkdir -p release && rsync -a --exclude ".git" --exclude ".github" --exclude "node_modules" ./ ./release/ && rm -rf release/tests release/.github release/.git
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: release
path: release
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: release
path: release
- name: Setup SSH
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: mkdir -p ~/.ssh && echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa && chmod 600 ~/.ssh/id_rsa && echo "${{ secrets.KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Rsync to server
run: RELEASE_ID=$(date +"%Y-%m-%d_%H%M%S")_${{ github.sha }} && rsync -az --delete release/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/myapp/releases/$RELEASE_ID/ && ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "/usr/local/bin/deploy-myapp.sh $RELEASE_ID"
Ключевые моменты:
- артефакт передаётся между job через
upload-artifact/download-artifact; RELEASE_IDстроится из даты и SHA — легко отыскать нужный релиз на сервере;- секреты (
SSH_PRIVATE_KEY, хост, юзер) хранятся в GitHub Secrets, а не в репозитории; - логика миграций и симлинков инкапсулирована в скрипте на сервере.
GitLab CI: аналогичный пайплайн с артефактами и rsync
В GitLab CI концепция такая же, только слова другие: job, стейджи, artifacts и т.д. Схема:
- стейдж
build— собирает приложение, складывает всё вrelease/и объявляет это артефактом; - стейдж
deploy— получает артефакт, делаетrsyncна сервер и запускает скрипт деплоя.
Пример .gitlab-ci.yml:
stages:
- build
- deploy
variables:
APP_DIR: "/var/www/myapp"
build:
stage: build
image: php:8.2-cli
script:
- apt-get update
- apt-get install -y git unzip nodejs npm rsync
- composer install --no-dev --prefer-dist --no-interaction
- npm ci
- npm run build
- mkdir -p release
- rsync -a --exclude ".git" --exclude ".gitlab" --exclude "node_modules" ./ ./release/
artifacts:
paths:
- release/
expire_in: 1 week
production-deploy:
stage: deploy
image: debian:stable-slim
needs:
- build
script:
- apt-get update
- apt-get install -y rsync openssh-client
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts
- RELEASE_ID=$(date +"%Y-%m-%d_%H%M%S")_${CI_COMMIT_SHORT_SHA}
- rsync -az --delete release/ "$SSH_USER@$SSH_HOST:$APP_DIR/releases/$RELEASE_ID/"
- ssh "$SSH_USER@$SSH_HOST" "$APP_DIR/deploy-myapp.sh $RELEASE_ID"
environment:
name: production
url: https://example.com
only:
- main
Несколько нюансов:
- для
artifactsлучше задать разумноеexpire_in, чтобы не забивать хранилище GitLab; - секреты задаются в Settings → CI/CD → Variables и попадают в job через переменные среды;
- логика rollback будет той же: отдельный скрипт на сервере, который можно вызвать вручную или через отдельный
manual-job.
Rollback: стратегии и подводные камни
Откат релиза — это не только переключение файлов. Есть подводные камни, о которых важно помнить.
Миграции БД и несовместимые изменения
Самый распространённый сценарий проблемного отката:
Новый релиз выполнил миграцию, которая меняет структуру таблицы или удаляет столбец, а старый код с этим не дружит. После rollback сайт перестаёт работать, хоть мы и откатили файлы.
Что можно сделать:
- использовать стратегию миграций «forwards‑compatible schemas»: сначала деплой, который добавляет новые поля или таблицы, но не ломает старый код; позже — деплой, который начинает эти поля использовать и только потом удаляет старое;
- иметь миграции «вперёд» и «назад» (
up/down), с возможностью откатить схему вместе с кодом (подходит не всегда, особенно на больших базах); - минимизировать разрушающие изменения в горячих таблицах; сложные миграции чаще выполнять отдельно от регулярного CI‑деплоя.
Хранение нескольких релизов и сборка индекса
Чтобы rollback был возможен, храните несколько последних релизов в releases/. Типичная практика — 5–10 релизов, остальное периодически убирает cron:
#!/bin/bash
set -euo pipefail
APP_DIR="/var/www/myapp"
cd "$APP_DIR/releases"
ls -1t | tail -n +11 | xargs -r rm -rf
Разумно также вести индекс релизов: кто выкатил, из какого коммита, какой статус (успех или провал). Это можно делать как минимум логированием в deploy.log, а лучше — писать запись из CI (через SSH) в отдельный файл или даже в маленькую служебную БД.
Проверка здоровья после релиза
Чтобы понять, что после выкатки всё живо, удобно встроить шаг health‑check:
- CI после деплоя делает HTTP‑запрос на
/healthили/statusи проверяет код ответа; - при неудаче или тайм‑ауте можно автоматически инициировать rollback (сложнее, требует аккуратной логики, чтобы не устроить «качели» между релизами);
- для начала часто достаточно хотя бы ручной проверки и понятной инструкции по откату.
Для проектов на современных CMS и фреймворках дополнительные оптимизации можно делать отдельно: кеш, очереди, Redis, тонкая настройка PHP. Об этом подробно написано в материале про использование Redis для PHP‑сессий и object‑cache.
Практические советы по безопасности и стабильности
Несколько вещей, которые часто забывают при построении CI/CD‑цепочки с SSH и rsync.
SSH‑ключи и права
Лучший вариант — отдельный системный пользователь на сервере, которому разрешён доступ только к нужной директории и скриптам деплоя. Ограничьте его authorized_keys командой и опциями:
command="/usr/local/bin/deploy-shell-wrapper.sh",no-agent-forwarding,no-port-forwarding,no-pty ssh-ed25519 AAAA... ci-deploy-key
Так CI‑ключ не сможет произвольно выполнять команды.
Идемпотентность скриптов деплоя
Скрипт на сервере должен выдерживать повторный запуск с тем же RELEASE_ID без разрушения состояния:
- создание симлинков — только через
ln -sfn; - миграции — должны уметь пропускать уже применённые шаги;
- никаких
rm -rf currentдо обновления, только обновление ссылки.
Логирование деплой‑скриптов
Все ключевые шаги (создание релиза, выполнение миграций, переключение симлинка, рестарт сервисов) логируйте в отдельный файл с отметкой времени. Это сильно помогает при расследовании причин падения и при обучении команды пользоваться rollback.
Для проектов, которые растут по нагрузке, особенно на PHP и Node.js, полезно заранее закладываться на масштабирование и производительность. Для этого подойдёт перенос на быстрый облачный VDS с достаточным запасом по ресурсам и гибкой настройкой окружения.
Итоги
Построить надёжный CI/CD с использованием rsync, артефактов и возможностью быстрых откатов вполне реально без тяжёлой инфраструктуры. Ключевые элементы такой схемы:
- сборка артефактов в CI, а не на продакшене;
- структура
releases/current/sharedна сервере; - деплой только в новый каталог релиза и атомарное переключение симлинка;
- миграции БД с учётом обратной совместимости и стратегии rollback;
- минимальные, но достаточные проверки здоровья приложения после выката.
Такой подход хорошо масштабируется: от простого PHP‑сайта на обычном хостинге до крупных проектов на отдельных серверах или VDS с несколькими окружениями и сложными пайплайнами GitHub Actions и GitLab CI. Главное — один раз настроить понятный процесс и не бояться автоматизировать рутину, чтобы откат релиза перестал быть «ручной магией по SSH ночью», а превратился в быструю и предсказуемую операцию.


