Деплой без простоя можно построить вокруг каталогов releases и символической ссылки current: новая версия приложения загружается в отдельный каталог, проходит подготовку, после чего current переключается на новый релиз. Если health check завершается ошибкой, ссылка автоматически возвращается на предыдущую рабочую версию.
В этом руководстве настроим отдельного пользователя и deploy key без административного доступа, сборку и передачу артефакта, health check и rollback, а затем покажем два варианта CI/CD-конвейера — для GitHub Actions и GitLab CI/CD.
Как устроен деплой без простоя
Один из простых способов организовать обновление приложения без длительного простоя — хранить каждую версию в отдельном каталоге и использовать символическую ссылку current, которая указывает на активный релиз.
Каталоги releases и символическая ссылка current
Структура приложения может выглядеть так:

Каждый новый деплой получает собственный каталог внутри releases. Рабочий сервис при этом запускает приложение через путь /var/www/app/current, а сама ссылка current переключается на нужную версию.
Проверить активный релиз можно командой: readlink -f /var/www/app/current
Сценарий успешного деплоя и rollback

При обычном деплое CI/CD выполняет несколько последовательных действий:
- Собирает приложение в артефакт.
- Передает артефакт на VPS.
- Создает новый каталог внутри releases.
- Распаковывает туда новую версию.
- Переключает current на новый релиз.
- Перезапускает приложение.
- Выполняет health check.
Если новая версия отвечает корректно, релиз остается активным. Если health check завершается ошибкой, скрипт возвращает current на предыдущий каталог и снова запускает приложение.
Такой подход не требует перезаписывать файлы работающей версии непосредственно во время деплоя и упрощает возврат к предыдущему состоянию.
Подготовка VPS
Для CI/CD не следует использовать учетную запись root. Создадим отдельного пользователя deploy, который получит доступ только к каталогам приложения и необходимым операциям развертывания.
Создание отдельного пользователя deploy
Создайте пользователя: sudo adduser --disabled-password --gecos "" deploy
Проверить его можно командой: id deploy
У пользователя будет собственный домашний каталог /home/deploy, но административные права ему не назначаются.
Ограничение административных прав
Не добавляйте пользователя deploy в группы sudo или admin.
Проверить его группы можно командой: groups deploy
Также можно убедиться, что прямой вызов sudo запрещен: sudo -u deploy sudo -n true
Для пользователя без соответствующих прав команда завершится ошибкой.
Если для деплоя потребуется перезапуск конкретного systemd-сервиса, безопаснее разрешить только эту операцию отдельным правилом sudoers, а не предоставлять полный административный доступ.
Например: sudo visudo -f /etc/sudoers.d/deploy-app
И добавить: deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart app.service
Так CI/CD сможет перезапустить только app.service, но не получит произвольный root-доступ.
Создание каталогов приложения и релизов
Создайте базовую структуру: sudo mkdir -p /var/www/app/releases
Передайте ее пользователю deploy: sudo chown -R deploy:deploy /var/www/app
Создадим первый тестовый релиз: sudo -u deploy mkdir -p /var/www/app/releases/initial
И символическую ссылку current: sudo -u deploy ln -s /var/www/app/releases/initial /var/www/app/current
Проверить структуру можно командами:
ls -la /var/www/app
ls -la /var/www/app/releases
Настройка SSH-доступа для CI/CD
CI/CD будет подключаться к VPS по отдельному SSH-ключу. Этот ключ предназначен только для автоматического деплоя и не должен совпадать с личным административным ключом.
Создание отдельного deploy key
На локальной машине или в безопасной административной среде создайте новую пару ключей: ssh-keygen -t ed25519 -f deploy_key -C "ci-deploy"
В результате появятся два файла:
deploy_key
deploy_key.pub
Файл deploy_key является приватным ключом и позже сохраняется в Secrets или CI/CD Variables.
Файл deploy_key.pub добавляется на VPS.
Добавление публичного ключа на VPS
Создайте SSH-каталог пользователя: sudo mkdir -p /home/deploy/.ssh
Откройте (создайте) файл: sudo nano /home/deploy/.ssh/authorized_keys
Добавьте содержимое deploy_key.pub.
После этого задайте корректные права:
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys
Ограничение возможностей deploy key
Дополнительные ограничения можно задать непосредственно перед ключом в authorized_keys.
Например: no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAA... ci-deploy
Такая запись запрещает агент-форвардинг, перенаправление портов, X11 и интерактивный псевдотерминал.
При необходимости доступ можно ограничить еще сильнее, например разрешить подключение только с определенного адреса или заставить ключ выполнять конкретный deployment-скрипт через параметр command.
При этом сам пользователь deploy по-прежнему не получает полный административный доступ к серверу.
Проверка SSH-доступа под пользователем deploy
Проверьте подключение с приватным ключом: ssh -i deploy_key deploy@203.0.113.10
Если для ключа установлен параметр no-pty, полноценная интерактивная оболочка может быть недоступна. В таком случае удобно проверить выполнение отдельной команды: ssh -i deploy_key deploy@203.0.113.10 "whoami && id"
Ожидаемый результат:
deploy
uid=1001(deploy) gid=1001(deploy) groups=1001(deploy)
Дополнительно проверьте отсутствие произвольного sudo-доступа: ssh -i deploy_key deploy@203.0.113.10 "sudo -n id"
Команда должна завершиться отказом, если для пользователя разрешены только специально определенные операции.
Подготовка приложения к автоматическому деплою
До настройки GitHub Actions или GitLab CI/CD необходимо подготовить само приложение к безопасному переключению между релизами. Для этого сервис должен запускаться не из жестко заданного каталога конкретной версии, а через символическую ссылку current.
Кроме того, приложению нужен отдельный health endpoint. CI/CD будет обращаться к нему сразу после переключения релиза и использовать результат проверки для решения: оставить новую версию активной или выполнить rollback.
Создание health endpoint
Health endpoint — это простой HTTP-маршрут, который позволяет автоматически проверить, запустилось ли приложение и способно ли оно отвечать на запросы.
Для примера используем небольшое приложение на FastAPI. Создайте файл: nano /var/www/app/releases/initial/main.py
Добавьте:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def root():
return {
"message": "Application is running",
"version": "initial"
}
@app.get("/health")
def health():
return {"status": "ok"}
Health endpoint будет доступен по адресу: http://127.0.0.1:8000/health
Для базовой проверки достаточно ответа с кодом HTTP 200. В реальном проекте health check также может проверять доступность базы данных, Redis, очереди сообщений или других критичных зависимостей.
Для дальнейшего запуска установите FastAPI и Uvicorn внутри первого релиза:
cd /var/www/app/releases/initial
python3 -m venv venv
source venv/bin/activate
pip install fastapi uvicorn
Сохраните зависимости: pip freeze > requirements.txt
Теперь первый релиз содержит приложение и необходимые для его запуска Python-пакеты.
Настройка systemd-сервиса
Чтобы приложение автоматически запускалось вместе с VPS и могло перезапускаться после переключения релиза, создадим systemd-сервис.
Важно, что в его конфигурации используется путь /var/www/app/current, а не конкретный каталог внутри releases. Благодаря этому unit-файл не придется изменять при каждом новом деплое.
Создайте: sudo nano /etc/systemd/system/app.service
Добавьте:
[Unit]
Description=Application service
After=network.target
[Service]
User=deploy
Group=deploy
WorkingDirectory=/var/www/app/current
ExecStart=/var/www/app/current/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
Перечитайте конфигурацию systemd: sudo systemctl daemon-reload
Добавьте сервис в автозагрузку: sudo systemctl enable app.service
Ранее для пользователя deploy было разрешено выполнение только конкретной команды: /usr/bin/systemctl restart app.service
Это позволяет CI/CD перезапускать приложение после деплоя, не предоставляя пользователю полный доступ через sudo.
Запуск приложения через current
Проверьте, куда сейчас указывает символическая ссылка: readlink -f /var/www/app/current
Для первого релиза результат будет выглядеть примерно так: /var/www/app/releases/initial
Запустите сервис: sudo systemctl start app.service
Проверьте его состояние: sudo systemctl status app.service --no-pager
Затем отправьте запрос к health endpoint: curl http://127.0.0.1:8000/health
Ожидаемый ответ: {"status":"ok"}
На этом этапе приложение уже запускается через current. Поэтому при следующем деплое достаточно подготовить новый каталог, переключить символическую ссылку и перезапустить app.service.
Сборка и размещение релиза
Теперь рассмотрим сам процесс обновления. Вместо копирования файлов непосредственно поверх работающего приложения CI/CD сначала формирует отдельный артефакт, передает его на VPS и разворачивает в новом каталоге внутри releases.
Текущая рабочая версия при этом остается на месте до момента переключения current.
Создание артефакта приложения
Артефакт — это архив с файлами конкретной версии приложения, который создается на этапе CI.
Например, для небольшого Python-проекта в него могут входить:
main.py
requirements.txt
В каталоге исходного проекта создайте архив: tar -czf release.tar.gz main.py requirements.txt
Каталог виртуального окружения venv в артефакт обычно не включают. Он может занимать много места и содержать файлы, зависящие от окружения, в котором был создан.
Вместо этого зависимости фиксируются в requirements.txt и устанавливаются непосредственно при подготовке релиза на VPS.
Передача артефакта на VPS
Созданный архив CI/CD может передать на сервер через scp.
Например: scp -i deploy_key release.tar.gz deploy@203.0.113.10:/tmp/release.tar.gz
В GitHub Actions или GitLab CI/CD вместо локального пути к deploy_key будет использоваться приватный ключ, сохраненный в Secrets или CI/CD Variables.
Пользователь deploy получает только возможность загрузить файл и работать внутри каталога приложения. Полноценный административный SSH-доступ для этого не требуется.
Распаковка в новый каталог releases
Для каждого релиза удобно использовать уникальное имя, например временную метку: RELEASE_ID=$(date +%Y%m%d%H%M%S)
Создайте новый каталог: mkdir -p /var/www/app/releases/$RELEASE_ID
Распакуйте туда артефакт: tar -xzf /tmp/release.tar.gz -C /var/www/app/releases/$RELEASE_ID
Затем подготовьте Python-окружение:
cd /var/www/app/releases/$RELEASE_ID
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
В результате новый релиз полностью подготовлен в отдельном каталоге, но пока не обслуживает пользовательские запросы.
Это важный момент: старая версия продолжает работать через current, пока новая версия только собирается и устанавливает зависимости.
Переключение символической ссылки current

Перед переключением сохраним путь к текущему рабочему релизу: PREVIOUS_RELEASE=$(readlink -f /var/www/app/current)
После этого атомарно заменим символическую ссылку: ln -sfn /var/www/app/releases/$RELEASE_ID /var/www/app/current
Проверьте результат: readlink -f /var/www/app/current
Теперь current должен указывать на новый каталог: /var/www/app/releases/20260811140000
Для проверки структуры можно выполнить: ls -la /var/www/app
После переключения ссылки файлы предыдущей версии не удаляются. Они остаются в releases, поэтому при необходимости к ним можно быстро вернуться.
Health check и автоматический rollback
Само переключение current еще не означает, что новый релиз действительно работоспособен. Например, приложение может завершиться при запуске из-за синтаксической ошибки, отсутствующей зависимости или неправильной конфигурации.
Поэтому сразу после активации новой версии CI/CD должен перезапустить сервис и выполнить health check.
Проверка приложения после переключения

Перезапустите приложение: sudo systemctl restart app.service
Дадим сервису несколько секунд на запуск: sleep 3
Теперь проверим health endpoint: curl --fail http://127.0.0.1:8000/health
Параметр --fail заставляет curl завершиться с ненулевым кодом, если сервер вернет HTTP-ошибку. Это важно для CI/CD: pipeline сможет определить, что проверка не прошла.
При успешном деплое получим: {"status":"ok"}
Удобнее добавить несколько попыток, поскольку приложение может запускаться не мгновенно:
for i in {1..10}; do
if curl --fail --silent http://127.0.0.1:8000/health; then
echo
echo "Health check passed"
exit 0
fi
sleep 2
done
echo "Health check failed"
exit 1
Так pipeline дает приложению до 20 секунд на успешный запуск, прежде чем считать релиз неисправным.
Возврат на предыдущий релиз при ошибке

Если новый релиз не проходит health check, current необходимо вернуть на сохраненный ранее путь PREVIOUS_RELEASE.
Логика rollback может выглядеть так:
if ! curl --fail --silent http://127.0.0.1:8000/health; then
echo "Health check failed. Rolling back..."
ln -sfn "$PREVIOUS_RELEASE" /var/www/app/current
sudo systemctl restart app.service
echo "Rollback completed"
fi
После возврата ссылки можно повторно проверить приложение: curl --fail http://127.0.0.1:8000/health
А также убедиться, что current снова указывает на предыдущий релиз: readlink -f /var/www/app/current
В полноценном deployment-скрипте лучше объединить несколько попыток health check и rollback в одну последовательность:
HEALTH_OK=0
for i in {1..10}; do
if curl --fail --silent http://127.0.0.1:8000/health > /dev/null; then
HEALTH_OK=1
break
fi
sleep 2
done
if [ "$HEALTH_OK" -ne 1 ]; then
echo "Health check failed. Rolling back to $PREVIOUS_RELEASE"
ln -sfn "$PREVIOUS_RELEASE" /var/www/app/current
sudo systemctl restart app.service
sleep 3
curl --fail http://127.0.0.1:8000/health
exit 1
fi
echo "Deployment completed successfully"
Здесь ошибка health check приводит к автоматическому возврату предыдущей версии, после чего pipeline завершается с ошибкой. Это позволяет одновременно сохранить рабочее приложение и показать в CI/CD, что новый релиз не был принят.
Автоматический деплой через GitHub Actions
Когда серверная часть схемы уже подготовлена, ручные операции можно перенести в GitHub Actions. Workflow будет запускаться после изменения основной ветки, собирать файлы приложения в архив, подключаться к VPS под ограниченным пользователем deploy и выполнять тот же сценарий, который ранее проверялся вручную.
Конфигурация GitHub Actions хранится в YAML-файле внутри каталога .github/workflows. Секретные значения при этом не нужно записывать непосредственно в репозиторий: GitHub позволяет передавать их workflow через Secrets.
Какие Secrets нужны для workflow
Для рассматриваемой схемы понадобятся четыре значения:
VPS_HOST
VPS_USER
SSH_PRIVATE_KEY
SSH_KNOWN_HOSTS
VPS_HOST содержит адрес сервера, например 203.0.113.10, а VPS_USER — имя созданного ранее пользователя: deploy
В SSH_PRIVATE_KEY сохраняется приватная часть отдельного deploy key. Этот ключ используется только CI/CD и соответствует публичному ключу в /home/deploy/.ssh/authorized_keys.
SSH_KNOWN_HOSTS содержит SSH host key сервера. Он нужен, чтобы runner мог удостовериться, что подключается именно к ожидаемому VPS, а не отключал проверку подлинности сервера.
Получить запись заранее можно с доверенной машины: ssh-keyscan -H 203.0.113.10
После этого полученное значение сохраняется как отдельный секрет. Такой подход предпочтительнее использования StrictHostKeyChecking=no: SSH продолжает проверять сервер при каждом подключении.
Сам приватный ключ не следует добавлять в исходный код или YAML-файл. GitHub Secrets предназначены для передачи чувствительных значений workflow без их хранения непосредственно в репозитории.
Создание workflow-файла deploy.yml
В репозитории создайте файл: .github/workflows/deploy.yml
Базовая конфигурация может выглядеть так:
name: Deploy to VPS
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build release artifact
run: |
tar -czf release.tar.gz main.py requirements.txt
- name: Configure SSH
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Upload artifact
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
run: |
scp -i ~/.ssh/deploy_key \
release.tar.gz \
"$VPS_USER@$VPS_HOST:/tmp/release-${GITHUB_SHA}.tar.gz"
- name: Deploy release
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
run: |
ssh -i ~/.ssh/deploy_key "$VPS_USER@$VPS_HOST" \
"RELEASE_ID=${GITHUB_SHA} bash -s" <<'DEPLOY'
set -e
APP_DIR="/var/www/app"
RELEASE_DIR="$APP_DIR/releases/$RELEASE_ID"
ARCHIVE="/tmp/release-$RELEASE_ID.tar.gz"
PREVIOUS_RELEASE=$(readlink -f "$APP_DIR/current")
mkdir -p "$RELEASE_DIR"
tar -xzf "$ARCHIVE" -C "$RELEASE_DIR"
cd "$RELEASE_DIR"
python3 -m venv venv
./venv/bin/pip install -r requirements.txt
ln -sfn "$RELEASE_DIR" "$APP_DIR/current"
sudo /usr/bin/systemctl restart app.service
HEALTH_OK=0
for i in {1..10}; do
if curl --fail --silent \
http://127.0.0.1:8000/health > /dev/null; then
HEALTH_OK=1
break
fi
sleep 2
done
if [ "$HEALTH_OK" -ne 1 ]; then
echo "Health check failed. Rolling back..."
ln -sfn "$PREVIOUS_RELEASE" "$APP_DIR/current"
sudo /usr/bin/systemctl restart app.service
sleep 3
curl --fail http://127.0.0.1:8000/health
rm -f "$ARCHIVE"
exit 1
fi
echo "Deployment completed successfully"
readlink -f "$APP_DIR/current"
rm -f "$ARCHIVE"
DEPLOY
Workflow запускается после push в ветку main. GitHub Actions позволяет определять события запуска и последовательность jobs и steps непосредственно в workflow-файле.
Здесь идентификатор Git-коммита GITHUB_SHA одновременно используется как имя релиза. В результате каталоги на VPS можно связать с конкретными версиями исходного кода:
/var/www/app/releases/3f2a8c...
/var/www/app/releases/8db419…
Сборка артефакта и отправка на VPS
На шаге Build release artifact файлы проекта упаковываются: tar -czf release.tar.gz main.py requirements.txt
Для более крупного приложения в архив вместо отдельных файлов можно добавить каталог исходного кода, конфигурационные шаблоны и другие необходимые для запуска ресурсы.
Важен сам принцип: runner сначала формирует законченную версию релиза, а уже затем передает ее серверу.
В рассматриваемом workflow передача выполняется через scp:
scp -i ~/.ssh/deploy_key \
release.tar.gz \
"$VPS_USER@$VPS_HOST:/tmp/release-${GITHUB_SHA}.tar.gz"
Ключ существует только внутри окружения job и используется для подключения под deploy. Даже если CI/CD-команда выполняется удаленно, возможности пользователя на VPS остаются ограниченными ранее настроенными файловыми правами и правилом sudoers.
При необходимости собранные файлы также можно сохранять как GitHub workflow artifacts — GitHub предоставляет отдельные механизмы загрузки и передачи артефактов между jobs. Для простого VPS-деплоя в этом примере дополнительное хранение не требуется: архив сразу отправляется на сервер.
Переключение релиза, health check и rollback
После подключения к VPS workflow сохраняет путь к рабочей версии: PREVIOUS_RELEASE=$(readlink -f "$APP_DIR/current")
Затем создается новый каталог, туда распаковывается архив и устанавливаются зависимости:
mkdir -p "$RELEASE_DIR"
tar -xzf "$ARCHIVE" -C "$RELEASE_DIR"
cd "$RELEASE_DIR"
python3 -m venv venv
./venv/bin/pip install -r requirements.txt
Только после успешной подготовки новой версии переключается current: ln -sfn "$RELEASE_DIR" "$APP_DIR/current"
Сервис перезапускается разрешенной для пользователя deploy командой: sudo /usr/bin/systemctl restart app.service
После этого workflow до десяти раз проверяет /health. Если приложение начинает отвечать успешно, релиз считается рабочим.
Если проверка не проходит, выполняется обратное переключение:
ln -sfn "$PREVIOUS_RELEASE" "$APP_DIR/current"
sudo /usr/bin/systemctl restart app.service
Таким образом ошибка новой версии приводит к падению самого workflow, но предыдущий релиз снова становится активным.
Автоматический деплой через GitLab CI/CD
Тот же серверный сценарий можно использовать с GitLab CI/CD. Меняется прежде всего синтаксис pipeline и способ передачи переменных, тогда как структура releases, символическая ссылка, health check и rollback на VPS остаются теми же.
GitLab определяет pipeline через файл .gitlab-ci.yml, расположенный в репозитории. Jobs, stages, artifacts и другие параметры описываются в YAML-конфигурации.
Какие CI/CD Variables нужны для pipeline
Создайте следующие CI/CD Variables:
VPS_HOST
VPS_USER
SSH_PRIVATE_KEY
SSH_KNOWN_HOSTS
Назначение первых трех такое же, как в GitHub Actions.
Для SSH-ключей GitLab отдельно рекомендует хранить ключ и known_hosts через CI/CD Variables, а host keys получать заранее из доверенной сети, а не выполнять ssh-keyscan непосредственно внутри job. Это защищает runner от подключения к подмененному серверу.
Чувствительные значения, включая приватный ключ, не следует записывать непосредственно в .gitlab-ci.yml: GitLab указывает, что секретные значения нужно хранить в настройках CI/CD Variables, тогда как переменные внутри YAML доступны пользователям с доступом к репозиторию.
Создание файла .gitlab-ci.yml
В корневом каталоге репозитория создайте: .gitlab-ci.yml
Добавьте конфигурацию:
stages:
- build
- deploy
build:
stage: build
image: alpine:latest
before_script:
- apk add --no-cache tar
script:
- tar -czf release.tar.gz main.py requirements.txt
artifacts:
paths:
- release.tar.gz
expire_in: 1 hour
deploy:
stage: deploy
image: alpine:latest
dependencies:
- build
before_script:
- apk add --no-cache openssh-client bash curl
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
- chmod 600 ~/.ssh/deploy_key
- printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- chmod 600 ~/.ssh/known_hosts
script:
- |
scp -i ~/.ssh/deploy_key \
release.tar.gz \
"$VPS_USER@$VPS_HOST:/tmp/release-${CI_COMMIT_SHA}.tar.gz"
- |
ssh -i ~/.ssh/deploy_key "$VPS_USER@$VPS_HOST" \
"RELEASE_ID=${CI_COMMIT_SHA} bash -s" <<'DEPLOY'
set -e
APP_DIR="/var/www/app"
RELEASE_DIR="$APP_DIR/releases/$RELEASE_ID"
ARCHIVE="/tmp/release-$RELEASE_ID.tar.gz"
PREVIOUS_RELEASE=$(readlink -f "$APP_DIR/current")
mkdir -p "$RELEASE_DIR"
tar -xzf "$ARCHIVE" -C "$RELEASE_DIR"
cd "$RELEASE_DIR"
python3 -m venv venv
./venv/bin/pip install -r requirements.txt
ln -sfn "$RELEASE_DIR" "$APP_DIR/current"
sudo /usr/bin/systemctl restart app.service
HEALTH_OK=0
for i in {1..10}; do
if curl --fail --silent \
http://127.0.0.1:8000/health > /dev/null; then
HEALTH_OK=1
break
fi
sleep 2
done
if [ "$HEALTH_OK" -ne 1 ]; then
echo "Health check failed. Rolling back..."
ln -sfn "$PREVIOUS_RELEASE" "$APP_DIR/current"
sudo /usr/bin/systemctl restart app.service
sleep 3
curl --fail http://127.0.0.1:8000/health
rm -f "$ARCHIVE"
exit 1
fi
echo "Deployment completed successfully"
readlink -f "$APP_DIR/current"
rm -f "$ARCHIVE"
DEPLOY
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
Pipeline состоит из двух стадий:
build
deploy
На первой создается архив, а на второй он загружается на VPS и активируется.
GitLab позволяет сохранять результаты job как artifacts и передавать их последующим jobs. Именно поэтому release.tar.gz, созданный на стадии build, становится доступен стадии deploy.
Сборка артефакта и отправка на VPS
На стадии build выполняется: tar -czf release.tar.gz main.py requirements.txt
После этого GitLab сохраняет полученный файл:
artifacts:
paths:
- release.tar.gz
expire_in: 1 hour
Устанавливать большой срок хранения здесь необязательно: архив нужен только следующей стадии pipeline.
В deploy он передается на VPS:
scp -i ~/.ssh/deploy_key \
release.tar.gz \
"$VPS_USER@$VPS_HOST:/tmp/release-${CI_COMMIT_SHA}.tar.gz"
Переменная CI_COMMIT_SHA позволяет использовать идентификатор Git-коммита как имя релиза.
Переключение релиза, health check и rollback
После передачи архива GitLab выполняет на VPS практически тот же deployment-скрипт, что и GitHub Actions.
Последовательность остается неизменной:
artifact
↓
new release
↓
dependencies
↓
current → new release
↓
restart
↓
health check
↓
success / rollback
Это важное свойство схемы: deployment-механизм не зависит от конкретной CI/CD-платформы. GitHub Actions и GitLab CI/CD только запускают заранее определенную последовательность действий.
При неудачном health check pipeline возвращает current на значение PREVIOUS_RELEASE, перезапускает app.service и завершается с ошибкой. При успешной проверке новая версия остается активной.
Проверка деплоя без простоя
После настройки pipeline стоит отдельно проверить не только факт выполнения команд, но и итоговое состояние VPS. Активным должен оказаться новый каталог релиза, /health должен отвечать успешно, а предыдущая версия — оставаться в releases на случай rollback.
Символическая ссылка переключается одной файловой операцией, поэтому файлы работающей версии не заменяются по одному. Однако если используется единственный процесс приложения и systemctl restart, непосредственно перезапуск процесса может создать очень короткое окно недоступности. Для строгого zero-downtime в нагруженных production-системах обычно применяют несколько экземпляров приложения и поочередное переключение трафика между ними.
Выпуск новой версии приложения
Для проверки измените номер версии в main.py.
Например:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def root():
return {
"message": "Application is running",
"version": "2.0"
}
@app.get("/health")
def health():
return {"status": "ok"}
После сохранения и отправки изменений в основную ветку CI/CD должен сформировать новый артефакт.
В результате на VPS появится еще один каталог:

Старая версия при этом не перезаписывается.
Проверка активного релиза во время обновления
Перед переключением нового релиза можно проверить текущую ссылку: readlink -f /var/www/app/current
После завершения deployment-скрипта выполните команду снова: readlink -f /var/www/app/current
Путь должен измениться на каталог нового релиза.
Список сохраненных версий можно посмотреть командой: ls -lah /var/www/app/releases
Таким образом предыдущий релиз продолжает физически существовать на VPS и остается доступным для rollback.
Проверка приложения после деплоя

Сначала проверим health endpoint: curl --fail http://127.0.0.1:8000/health
Ожидаемый ответ: {"status":"ok"}
Теперь запросим версию приложения: curl http://127.0.0.1:8000/
После выпуска тестовой версии ответ будет выглядеть так: {"message":"Application is running","version":"2.0"}
Для финальной проверки удобно выполнить несколько команд подряд:
echo "Active release:"
readlink -f /var/www/app/current
echo
echo "Health check:"
curl --fail http://127.0.0.1:8000/health
echo
echo "Application version:"
curl http://127.0.0.1:8000/
В одном выводе будут видны активный каталог, успешный health check и новая версия приложения.
Обслуживание схемы деплоя
После настройки автоматического развертывания основная работа сводится к контролю состояния приложения, очистке старых релизов и периодической замене ключей доступа. Эти операции не влияют на логику pipeline, но помогают поддерживать VPS в предсказуемом и безопасном состоянии.
Просмотр логов systemd
Поскольку приложение работает как systemd-сервис, его журнал можно просматривать через journalctl. Утилита читает записи, собранные systemd-journald.
Чтобы вывести последние записи app.service, выполните: sudo journalctl -u app.service -n 50 --no-pager
Для просмотра сообщений в реальном времени: sudo journalctl -u app.service -f
Логи особенно полезны после неудачного health check: в них можно увидеть ошибки импорта, отсутствующие зависимости, исключения приложения или причины завершения процесса.
Дополнительно состояние сервиса можно быстро проверить командой: sudo systemctl status app.service --no-pager
Удаление старых релизов
Каждый деплой создает новый каталог внутри /var/www/app/releases. Со временем старые версии начинают занимать место, поэтому их желательно периодически удалять.
Сначала посмотрите список релизов: ls -lt /var/www/app/releases
Не следует удалять каталог, на который в данный момент указывает current. Проверить активную версию можно так: readlink -f /var/www/app/current
Например, чтобы оставить пять последних релизов, а более старые удалить, можно использовать:
cd /var/www/app/releases
ls -1dt */ | tail -n +6 | xargs -r rm -rf
Такую очистку можно выполнять вручную после нескольких деплоев либо добавить отдельным завершающим этапом deployment-скрипта.
При этом полезно оставлять как минимум несколько предыдущих версий. Они позволяют выполнить ручной rollback, если проблема обнаружится уже после завершения автоматического health check.
Ротация deploy key и CI/CD-секретов
Deploy key не следует считать бессрочным. Если ключ мог попасть в чужие руки, изменился состав команды или проводится плановая ротация учетных данных, лучше создать новую пару и заменить старую.
Сгенерировать новый ключ можно командой: ssh-keygen -t ed25519 -f deploy_key_new -C "ci-deploy"
Новый публичный ключ добавляется в: /home/deploy/.ssh/authorized_keys
После проверки подключения старую запись можно удалить.
Файл authorized_keys используется OpenSSH для хранения публичных ключей, разрешенных для входа под конкретным пользователем. Для каждой записи также можно задавать дополнительные ограничения доступа.
Одновременно следует обновить приватный ключ в SSH_PRIVATE_KEY внутри GitHub Secrets или GitLab CI/CD Variables. GitHub предоставляет отдельное хранилище secrets для workflow, а GitLab позволяет хранить чувствительные значения в CI/CD Variables вместо добавления их непосредственно в YAML-файл.
Если меняется сам VPS или его SSH host key, необходимо также обновить SSH_KNOWN_HOSTS.
Заключение

Автоматический деплой на VPS можно построить без выдачи CI/CD полного административного доступа. Для этого используется отдельный пользователь deploy, самостоятельный SSH-ключ и минимальный набор разрешений, необходимый только для размещения релизов и перезапуска конкретного systemd-сервиса.
Каждая версия приложения разворачивается в отдельном каталоге releases, а символическая ссылка current определяет активный релиз. После переключения pipeline выполняет health check. Если новая версия не запускается корректно, ссылка возвращается на предыдущий каталог и приложение автоматически перезапускается уже со старым рабочим релизом.
Одна и та же серверная схема подходит и для GitHub Actions, и для GitLab CI/CD. Различаются в основном синтаксис pipeline и способ хранения секретов, тогда как логика сборки артефакта, передачи на VPS, переключения релиза и rollback остается одинаковой.
FAQ
Зачем хранить каждый релиз в отдельном каталоге?
Это позволяет не перезаписывать файлы работающей версии непосредственно во время деплоя. Новый релиз сначала полностью подготавливается отдельно, а затем current переключается на него. Предыдущие версии при этом остаются доступными для rollback.
Почему CI/CD не должен подключаться к VPS под root?
Компрометация CI/CD-ключа в таком случае фактически дает злоумышленнику административный доступ ко всему серверу. Отдельный пользователь deploy с минимальными правами ограничивает последствия утечки ключа.
Если необходимо перезапускать systemd-сервис, пользователю можно разрешить через sudoers только конкретную команду, а не весь sudo.
Чем deploy key отличается от обычного SSH-ключа администратора?
Технически используется та же SSH-аутентификация по ключу, однако deploy key создается специально для автоматизации. Он привязан к отдельному пользователю с ограниченными правами и не используется для обычного администрирования VPS.
OpenSSH позволяет дополнительно ограничивать отдельные записи в authorized_keys, например запрещать port forwarding, agent forwarding и выдачу псевдотерминала.
Что происходит, если health check новой версии не проходит?
Deployment-скрипт возвращает current на путь предыдущего релиза, перезапускает сервис и завершает pipeline с ошибкой. В результате CI/CD сообщает о неудачном деплое, но рабочая версия приложения снова становится активной.
Обеспечивает ли символическая ссылка абсолютный zero-downtime?
Переключение самой ссылки происходит практически мгновенно и не требует копирования файлов поверх работающего релиза. Однако если приложение работает в одном экземпляре и после переключения выполняется systemctl restart, на время перезапуска процесса возможно короткое окно недоступности.
Для строгого zero-downtime обычно используют несколько экземпляров приложения, rolling deployment или blue-green-схему с переключением трафика между уже запущенными версиями.
Где хранить приватный SSH-ключ для pipeline?
Приватный ключ не следует добавлять в Git-репозиторий или записывать непосредственно в deploy.yml и .gitlab-ci.yml.
В GitHub Actions его можно сохранить в repository или environment Secrets.
В GitLab для этой цели используются CI/CD Variables.
Нужно ли удалять предыдущий релиз сразу после успешного деплоя?
Нет. Лучше сохранить несколько предыдущих версий, чтобы иметь возможность быстро выполнить rollback, если проблема обнаружится позже и не будет замечена первоначальным health check.



