Как настроить автоматический деплой на VPS через GitHub Actions или GitLab CICD

Валерий Волков

Время прочтения 14 минут

Деплой без простоя можно построить вокруг каталогов 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 выполняет несколько последовательных действий:

  1. Собирает приложение в артефакт.
  2. Передает артефакт на VPS.
  3. Создает новый каталог внутри releases.
  4. Распаковывает туда новую версию.
  5. Переключает current на новый релиз.
  6. Перезапускает приложение.
  7. Выполняет 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.

Список источников

  1. GitHub Docs — Using secrets in GitHub Actions
  2. GitLab Docs — CI/CD variables
  3. GitLab Docs — CI/CD YAML syntax reference
  4. Systemd — journalctl

Подпишитесь на нашу рассылку и получайте статьи и новости

    Ознакомьтесь с другими нашими материалами