Автодеплой с 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 или лог, сначала отзовите или замените его: добавление звёздочек в следующий коммит не удаляет прежнюю утечку.