← Все записи
GitHub Actions / Kamal / CI/CD

Автодеплой с GitHub Actions: от merge в main до обновления сайта

Добавляем проверки pull request, отдельный SSH-ключ для CI, публикацию в GHCR и автоматический деплой через Kamal.

В первой статье мы развернули одну HTML-страницу через Kamal. Теперь перенесём публикацию в GitHub Actions: изменения проходят проверки, попадают в main, после чего сайт обновляется автоматически.

Пример рассчитан на тот же проект hello-kamal: есть Dockerfile, Nginx с /up, рабочий config/deploy.yml и уже выполненный первый деплой. Везде используются вымышленные example.com, 203.0.113.10 и your-github-user. Значения секретов в примерах обозначаются только как ********.

Что будет происходить

Pull request → сборка и проверка контейнера
Merge в main → те же проверки → GHCR → Kamal → проверка HTTPS

В pull request доступ к серверу не нужен. SSH-ключ появляется только в задании деплоя, которое запускается для main. Даже если сборка образа прошла, публикация считается законченной только после проверки сайта.

Отдельный ключ для CI

Создайте на компьютере ключ для этого проекта. Он будет без парольной фразы, поскольку Actions должен использовать его без интерактивного ввода:

ssh-keygen -t ed25519 -N '' -C hello-kamal-actions -f ~/.ssh/hello_kamal_actions

Если файл уже существует, используйте другое имя: не перезаписывайте рабочий ключ. Добавьте публичную часть пользователю deploy на сервере:

awk '{print "restrict " $0}' ~/.ssh/hello_kamal_actions.pub |
  ssh deploy@203.0.113.10 'umask 077; mkdir -p ~/.ssh; cat >> ~/.ssh/authorized_keys'

Опция restrict отключает, среди прочего, перенаправление портов и выделение PTY. Она не ограничивает доступ к Docker конкретным приложением: ключ пользователя с доступом к Docker остаётся привилегированным. Храните его только в доверенном репозитории.

Сохраните приватную часть в GitHub Actions secret через stdin:

gh secret set DEPLOY_SSH_KEY --repo your-github-user/hello-kamal < ~/.ssh/hello_kamal_actions

Не выводите приватный ключ через cat для копирования в статью, лог или переписку.

Проверяем подлинность сервера

Runner должен проверять ключ сервера. Сначала выполните обычное SSH-подключение и сравните fingerprint с тем, который доступен через доверенную консоль сервера или панель провайдера.

Затем возьмите уже проверенную запись из локального known_hosts:

ssh-keygen -F 203.0.113.10 | awk '!/^#/ {print}' > /tmp/hello-kamal-known-hosts
test -s /tmp/hello-kamal-known-hosts
gh secret set DEPLOY_KNOWN_HOSTS --repo your-github-user/hello-kamal < /tmp/hello-kamal-known-hosts
rm /tmp/hello-kamal-known-hosts

Публичный ключ сервера не является паролем, но его значение здесь тоже не публикуется: в условном примере оно выглядело бы как ********. StrictHostKeyChecking=no не требуется.

Авторизация в GHCR без личного токена

Для публикации образа workflow получает временный GITHUB_TOKEN. Разрешение packages: write выдадим только заданию деплоя. Долгоживущий Personal Access Token для этого сценария не нужен. Это стандартный механизм публикации контейнеров из Actions.

Файл .kamal/secrets из первой статьи остаётся таким:

KAMAL_REGISTRY_PASSWORD=$(gh auth token --hostname github.com)

На компьютере команда использует авторизацию GitHub CLI. В Actions она получает токен из переменной GH_TOKEN. Выражение ${{ github.token }} в YAML — ссылка на токен времени выполнения, а не его открытое значение.

Если образ уже публиковался вручную, проверьте доступ репозитория к пакету: в настройках GHCR-пакета, в разделе Manage Actions access, добавьте репозиторий с правом записи. Имя образа само по себе не гарантирует такой доступ. См. настройку доступа к пакету.

Workflow

В примере образ собирается на runner GitHub. В config/deploy.yml должен использоваться локальный builder; если раньше была настроена удалённая сборка, замените её блок:

builder:
  arch: amd64
  local: true

Создайте .github/workflows/deploy.yml:

name: Check and deploy

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

jobs:
  check:
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v6
        with:
          persist-credentials: false
      - run: docker build -t hello-kamal-check .
      - name: Check the container
        run: |
          docker run --rm --network none --entrypoint sh hello-kamal-check -c '
            set -eu
            nginx -t
            nginx
            test "$(wget -qO- http://127.0.0.1:8080/up)" = ok
            wget -qO /dev/null http://127.0.0.1:8080/
          '

  deploy:
    needs: check
    if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    concurrency:
      group: production
      cancel-in-progress: false
    permissions:
      contents: read
      packages: write
    env:
      GH_TOKEN: ${{ github.token }}
      REGISTRY_USER: ${{ github.repository_owner }}
    steps:
      - uses: actions/checkout@v6
        with:
          persist-credentials: false
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.4'
      - run: gem install kamal --version 2.11.0 --no-document
      - name: Configure SSH
        env:
          DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
        run: |
          test -n "$DEPLOY_SSH_KEY"
          test -n "$DEPLOY_KNOWN_HOSTS"
          install -d -m 700 ~/.ssh
          printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
          chmod 600 ~/.ssh/known_hosts
          ssh -o BatchMode=yes -o StrictHostKeyChecking=yes deploy@203.0.113.10 true
      - name: Authenticate GHCR
        run: gh auth token --hostname github.com | docker login ghcr.io --username "$REGISTRY_USER" --password-stdin
      - run: kamal deploy
      - name: Verify HTTPS
        run: curl --fail --silent --show-error --location --retry 3 --max-time 30 https://example.com --output /dev/null
      - name: Remove SSH credentials
        if: always()
        run: rm -f ~/.ssh/id_ed25519 ~/.ssh/known_hosts

Проверка контейнера выполняется без опубликованных портов и внешней сети. Она проверяет синтаксис Nginx, /up и главную страницу. Если в проекте есть приложение со сборкой и тестами, добавьте его собственные проверки в check.

Версии Actions здесь показаны тегами для читаемости. Для рабочего репозитория закрепите проверенные полные commit SHA. Это идентификаторы публичного кода, а не секреты.

Почему нужны needs и concurrency

needs: check не даёт перейти к деплою после провалившейся проверки. Условие if исключает деплой из pull request. Событие push на main покрывает и merge, и прямые коммиты; workflow_dispatch позволяет повторить запуск вручную.

concurrency не даёт двум заданиям этой группы деплоить одновременно. С cancel-in-progress: false новый запуск не прервёт уже идущий деплой. При этом ожидающий запуск может быть заменён более новым: это не обещание очереди FIFO. Подробности — в документации concurrency.

Первый запуск

Создайте pull request с workflow. Убедитесь, что выполняется только check, затем слейте изменения в main. Во вкладке Actions должны последовательно завершиться оба задания. Откройте сайт и проверьте именно новое содержимое, а не только статус 200.

Если шаг проверки HTTPS упал после успешного kamal deploy, новая версия уже может обслуживать трафик. Такой workflow автоматически не откатывает релиз. Порядок диагностики и восстановления описан в статье про откат.

Что не должно попадать в логи

Не включайте set -x на шагах с секретами и не печатайте env, приватные ключи, Docker config или полный вывод конфигурации с паролями. В публичных примерах оставляйте только маску:

registry_password: ********
ssh_private_key: ********
Authorization: Bearer ********

Маска показывает место секрета, но не является рабочим значением. Если настоящий токен уже попал в Git или лог, сначала отзовите или замените его: добавление звёздочек в следующий коммит не удаляет прежнюю утечку.